[Security Solution][Artifacts][Trusted Apps] Wildcard warning with IS operator for trusted apps creation/editing (#175356)

## Summary

- [x] Adds updated warning messaging for trusted apps entries that use
wildcards `*?` with the "IS" operator
- [x] Three different warnings: callout, individual entry item warnings
and a final confirmation modal when the user tries to add a trusted app
with ineffective IS / wildcard combination etnry.
- [x] Unit tests

# Screenshots
<img width="829" alt="image"
src="c7beec62-a249-4535-ac0b-34f9be57f542">
<img width="1649" alt="image"
src="22f38f1b-7e6b-4b69-8d03-4d74d8674fa6">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Candace Park 2024-02-13 23:43:25 -05:00 committed by GitHub
parent 03a5eb6635
commit 9a73eb4d3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 330 additions and 11 deletions

View file

@ -17,3 +17,4 @@ export * from './src/types';
export * from './src/list_header';
export * from './src/header_menu';
export * from './src/generate_linked_rules_menu_item';
export * from './src/wildcard_with_wrong_operator_callout';

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiCallOut } from '@elastic/eui';
export const WildCardWithWrongOperatorCallout = () => {
return (
<EuiCallOut
title={i18n.translate('exceptionList-components.wildcardWithWrongOperatorCallout.title', {
defaultMessage: 'Please review your entries',
})}
iconType="warning"
color="warning"
size="s"
data-test-subj="wildcardWithWrongOperatorCallout"
>
<p>
<FormattedMessage
id="exceptionList-components.wildcardWithWrongOperatorCallout.body"
defaultMessage="Using a '*' or a '?' in the value with the 'IS' operator can make the entry ineffective. {operator} to '{matches}' to ensure wildcards run properly."
values={{
operator: (
<strong>
{i18n.translate(
'exceptionList-components.wildcardWithWrongOperatorCallout.changeTheOperator',
{ defaultMessage: 'Change the operator' }
)}
</strong>
),
matches: (
<strong>
{i18n.translate(
'exceptionList-components.wildcardWithWrongOperatorCallout.matches',
{ defaultMessage: 'matches' }
)}
</strong>
),
}}
/>
</p>
</EuiCallOut>
);
};

View file

@ -19,6 +19,7 @@
"@kbn/securitysolution-autocomplete",
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/i18n-react",
],
"exclude": [
"target/**/*",

View file

@ -11,6 +11,7 @@ import {
hasSimpleExecutableName,
OperatingSystem,
ConditionEntryField,
hasWildcardAndInvalidOperator,
validatePotentialWildcardInput,
validateFilePathInput,
validateWildcardInput,
@ -128,6 +129,21 @@ describe('validateFilePathInput', () => {
});
});
describe('Wildcard and invalid operator', () => {
it('should return TRUE when operator is not "WILDCARD" and value contains a wildcard', () => {
expect(hasWildcardAndInvalidOperator({ operator: 'match', value: 'asdf*' })).toEqual(true);
});
it('should return FALSE when operator is not "WILDCARD" and value does not contain a wildcard', () => {
expect(hasWildcardAndInvalidOperator({ operator: 'match', value: 'asdf' })).toEqual(false);
});
it('should return FALSE when operator is "WILDCARD" and value contains a wildcard', () => {
expect(hasWildcardAndInvalidOperator({ operator: 'wildcard', value: 'asdf*' })).toEqual(false);
});
it('should return FALSE when operator is "WILDCARD" and value does not contain a wildcard', () => {
expect(hasWildcardAndInvalidOperator({ operator: 'wildcard', value: 'asdf' })).toEqual(false);
});
});
describe('No Warnings', () => {
it('should not show warnings on non path entries ', () => {
expect(

View file

@ -106,6 +106,20 @@ export const validateWildcardInput = (value?: string): string | undefined => {
}
};
export const hasWildcardAndInvalidOperator = ({
operator,
value,
}: {
operator: EntryTypes | TrustedAppEntryTypes;
value: string;
}): boolean => {
if (operator !== 'wildcard' && validateWildcardInput(value)) {
return true;
} else {
return false;
}
};
export const hasSimpleExecutableName = ({
os,
type,

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiText,
} from '@elastic/eui';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
interface ConfirmArtifactModalProps {
title: string;
body: string;
confirmButton: string;
cancelButton: string;
onCancel: () => void;
onSuccess: () => void;
'data-test-subj'?: string;
}
export const ArtifactConfirmModal = memo<ConfirmArtifactModalProps>(
({
title,
body,
confirmButton,
cancelButton,
onCancel,
onSuccess,
'data-test-subj': dataTestSubj,
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
return (
<EuiModal onClose={onCancel} data-test-subj={dataTestSubj}>
<EuiModalHeader data-test-subj={getTestId('header')}>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody data-test-subj={getTestId('body')}>
<EuiText>
<p>{body}</p>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel} data-test-subj={getTestId('cancelButton')}>
{cancelButton}
</EuiButtonEmpty>
<EuiButton fill onClick={onSuccess} data-test-subj={getTestId('submitButton')}>
{confirmButton}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
}
);
ArtifactConfirmModal.displayName = 'ArtifactConfirmModal';

View file

@ -324,6 +324,51 @@ describe('When the flyout is opened in the ArtifactListPage component', () => {
expect(location.search).toBe('');
});
});
describe('and there is a confirmModal', () => {
beforeEach(async () => {
const _renderAndWaitForFlyout = render;
// Override renderAndWaitForFlyout to also set the form data as "valid"
render = async (...props) => {
await _renderAndWaitForFlyout(...props);
act(() => {
const lastProps = getLastFormComponentProps();
lastProps.onChange({
item: { ...lastProps.item, name: 'some name' },
isValid: true,
confirmModalLabels: {
title: 'title',
body: 'body',
confirmButton: 'add',
cancelButton: 'cancel',
},
});
});
return renderResult;
};
});
it('should show the warning modal', async () => {
await render();
act(() => {
userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton'));
});
expect(renderResult.getByTestId('artifactConfirmModal')).toBeTruthy();
expect(renderResult.getByTestId('artifactConfirmModal-header').textContent).toEqual(
'title'
);
expect(renderResult.getByTestId('artifactConfirmModal-body').textContent).toEqual('body');
expect(renderResult.getByTestId('artifactConfirmModal-submitButton').textContent).toEqual(
'add'
);
expect(renderResult.getByTestId('artifactConfirmModal-cancelButton').textContent).toEqual(
'cancel'
);
});
});
});
describe('and in Edit mode', () => {

View file

@ -42,6 +42,7 @@ import { useWithArtifactSubmitData } from '../hooks/use_with_artifact_submit_dat
import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_allowed_per_policy_usage';
import { useGetArtifact } from '../../../hooks/artifacts';
import type { PolicyData } from '../../../../../common/endpoint/types';
import { ArtifactConfirmModal } from './artifact_confirm_modal';
export const ARTIFACT_FLYOUT_LABELS = Object.freeze({
flyoutEditTitle: i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditTitle', {
@ -207,11 +208,12 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
..._labels,
};
}, [_labels]);
// TODO:PT Refactor internal/external state into the `useEithArtifactSucmitData()` hook
// TODO:PT Refactor internal/external state into the `useWithArtifactSubmitData()` hook
const [externalIsSubmittingData, setExternalIsSubmittingData] = useState<boolean>(false);
const [externalSubmitHandlerError, setExternalSubmitHandlerError] = useState<
IHttpFetchError | undefined
>(undefined);
const [showConfirmModal, setShowConfirmModal] = useState<boolean>(false);
const isEditFlow = urlParams.show === 'edit';
const formMode: ArtifactFormComponentProps['mode'] = isEditFlow ? 'edit' : 'create';
@ -270,11 +272,12 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
}, [isSubmittingData, onClose, setUrlParams, urlParams]);
const handleFormComponentOnChange: ArtifactFormComponentProps['onChange'] = useCallback(
({ item: updatedItem, isValid }) => {
({ item: updatedItem, isValid, confirmModalLabels }) => {
if (isMounted()) {
setFormState({
item: updatedItem,
isValid,
confirmModalLabels,
});
}
},
@ -316,10 +319,42 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
setExternalIsSubmittingData(false);
}
});
} else if (formState.confirmModalLabels) {
setShowConfirmModal(true);
} else {
submitData(formState.item).then(handleSuccess);
}
}, [formMode, formState.item, handleSuccess, isMounted, submitData, submitHandler]);
}, [
formMode,
formState.item,
formState.confirmModalLabels,
handleSuccess,
isMounted,
submitData,
submitHandler,
]);
const confirmModalOnSuccess = useCallback(
() => submitData(formState.item).then(handleSuccess),
[submitData, formState.item, handleSuccess]
);
const confirmModal = useMemo(() => {
if (formState.confirmModalLabels) {
const { title, body, confirmButton, cancelButton } = formState.confirmModalLabels;
return (
<ArtifactConfirmModal
title={title}
body={body}
confirmButton={confirmButton}
cancelButton={cancelButton}
onSuccess={confirmModalOnSuccess}
onCancel={() => setShowConfirmModal(false)}
data-test-subj="artifactConfirmModal"
/>
);
}
}, [formState, confirmModalOnSuccess]);
// If we don't have the actual Artifact data yet for edit (in initialization phase - ex. came in with an
// ID in the url that was not in the list), then retrieve it now
@ -342,7 +377,7 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
isMounted,
]);
// If we got an error while trying ot retrieve the item for edit, then show a toast message
// If we got an error while trying to retrieve the item for edit, then show a toast message
useEffect(() => {
if (isEditFlow && error) {
toasts.addWarning(labels.flyoutEditItemLoadFailure(error?.body?.message || error.message));
@ -363,7 +398,6 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
<h2>{isEditFlow ? labels.flyoutEditTitle : labels.flyoutCreateTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
{!isInitializing && showExpiredLicenseBanner && (
<EuiCallOut
title={labels.flyoutDowngradedLicenseTitle}
@ -375,7 +409,6 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
{labels.flyoutDowngradedLicenseDocsInfo(securitySolution)}
</EuiCallOut>
)}
<EuiFlyoutBody>
{isInitializing && <ManagementPageLoader data-test-subj={getTestId('loader')} />}
@ -391,7 +424,6 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
/>
)}
</EuiFlyoutBody>
{!isInitializing && (
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
@ -420,6 +452,7 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
</EuiFlexGroup>
</EuiFlyoutFooter>
)}
{showConfirmModal && confirmModal}
</EuiFlyout>
);
}

View file

@ -40,4 +40,12 @@ export interface ArtifactFormComponentProps {
export interface ArtifactFormComponentOnChangeCallbackProps {
isValid: boolean;
item: ExceptionListItemSchema | CreateExceptionListItemSchema;
confirmModalLabels?: ArtifactConfirmModalLabelProps;
}
export interface ArtifactConfirmModalLabelProps {
title: string;
body: string;
confirmButton: string;
cancelButton: string;
}

View file

@ -531,6 +531,16 @@ describe('Trusted apps form', () => {
});
});
describe('and a wildcard value is used with the IS operator', () => {
beforeEach(() => render());
it('shows callout warning and help text warning', () => {
setTextFieldValue(getConditionValue(getCondition()), 'somewildcard*');
rerenderWithLatestProps();
expect(renderResult.getByTestId('wildcardWithWrongOperatorCallout')).toBeTruthy();
expect(renderResult.getByText(INPUT_ERRORS.wildcardWithWrongOperatorWarning(0)));
});
});
describe('and all required data passes validation', () => {
it('should call change callback with isValid set to true and contain the new item', () => {
const propsItem: Partial<ArtifactFormComponentProps['item']> = {

View file

@ -22,12 +22,13 @@ import {
import type { AllConditionEntryFields, EntryTypes } from '@kbn/securitysolution-utils';
import {
hasSimpleExecutableName,
hasWildcardAndInvalidOperator,
isPathValid,
ConditionEntryField,
OperatingSystem,
} from '@kbn/securitysolution-utils';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { WildCardWithWrongOperatorCallout } from '@kbn/securitysolution-exception-list-components';
import type {
TrustedAppConditionEntry,
NewTrustedApp,
@ -57,6 +58,7 @@ import {
NAME_LABEL,
POLICY_SELECT_DESCRIPTION,
SELECT_OS_LABEL,
CONFIRM_WARNING_MODAL_LABELS,
} from '../translations';
import { OS_TITLES } from '../../../../common/translations';
import type { LogicalConditionBuilderProps } from './logical_condition';
@ -87,13 +89,17 @@ interface ValidationResult {
result: Partial<{
[key in keyof NewTrustedApp]: FieldValidationState;
}>;
/** Additional Warning callout after submit */
extraWarning?: boolean;
}
const addResultToValidation = (
validation: ValidationResult,
field: keyof NewTrustedApp,
type: 'warnings' | 'errors',
resultValue: React.ReactNode
resultValue: React.ReactNode,
addToFront?: boolean
) => {
if (!validation.result[field]) {
validation.result[field] = {
@ -103,8 +109,14 @@ const addResultToValidation = (
};
}
const errorMarkup: React.ReactNode = type === 'warnings' ? <div>{resultValue}</div> : resultValue;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
validation.result[field]![type].push(errorMarkup);
if (addToFront) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
validation.result[field]![type].unshift(errorMarkup);
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
validation.result[field]![type].push(errorMarkup);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
validation.result[field]!.isInvalid = true;
};
@ -115,6 +127,7 @@ const validateValues = (values: ArtifactFormComponentProps['item']): ValidationR
isValid,
result: {},
};
let extraWarning: ValidationResult['extraWarning'];
// Name field
if (!values.name.trim()) {
@ -152,6 +165,21 @@ const validateValues = (values: ArtifactFormComponentProps['item']): ValidationR
value: (entry as TrustedAppConditionEntry).value,
});
if (
hasWildcardAndInvalidOperator({
operator: entry.type as EntryTypes,
value: (entry as TrustedAppConditionEntry).value,
})
) {
extraWarning = true;
addResultToValidation(
validation,
'entries',
'warnings',
INPUT_ERRORS.wildcardWithWrongOperatorWarning(index)
);
}
if (!entry.field || !(entry as TrustedAppConditionEntry).value.trim()) {
isValid = false;
addResultToValidation(validation, 'entries', 'errors', INPUT_ERRORS.mustHaveValue(index));
@ -181,6 +209,19 @@ const validateValues = (values: ArtifactFormComponentProps['item']): ValidationR
});
}
if (extraWarning) {
addResultToValidation(
validation,
'entries',
'errors',
<>
<EuiSpacer size="s" />
<WildCardWithWrongOperatorCallout />
</>,
true
);
validation.extraWarning = extraWarning;
}
validation.isValid = isValid;
return validation;
};
@ -245,6 +286,9 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
onChange({
item: updatedFormValues,
isValid: updatedValidationResult.isValid,
confirmModalLabels: updatedValidationResult.extraWarning
? CONFIRM_WARNING_MODAL_LABELS
: undefined,
});
},
[onChange]

View file

@ -154,4 +154,31 @@ export const INPUT_ERRORS = {
values: { row: index + 1 },
}
),
wildcardWithWrongOperatorWarning: (index: number) =>
i18n.translate('xpack.securitySolution.trustedapps.create.conditionWrongOperatorMsg', {
defaultMessage: `[{row}] Using a '*' or a '?' in the value with the 'IS' operator can make the entry ineffective. Change the operator to 'matches' to ensure wildcards run properly.`,
values: { row: index + 1 },
}),
};
export const CONFIRM_WARNING_MODAL_LABELS = {
title: i18n.translate('xpack.securitySolution.trustedapps.confirmWarningModal.title', {
defaultMessage: 'Confirm trusted application',
}),
body: i18n.translate('xpack.securitySolution.trustedapps.confirmWarningModal.body', {
defaultMessage:
'Using a "*" or a "?" in the value and with the "IS" operator can make the entry ineffective. Change the operator to matches to ensure wildcards run properly. Select “cancel” to revise your entry, or "add" to continue with entry in its current state.',
}),
confirmButton: i18n.translate(
'xpack.securitySolution.trustedapps.confirmWarningModal.confirmButtonText',
{
defaultMessage: 'Add',
}
),
cancelButton: i18n.translate(
'xpack.securitySolution.trustedapps.confirmWarningModal.cancelButtonText',
{
defaultMessage: 'Cancel',
}
),
};