diff --git a/packages/kbn-securitysolution-exception-list-components/index.ts b/packages/kbn-securitysolution-exception-list-components/index.ts index 4d822c773bae..7dc43b92d045 100644 --- a/packages/kbn-securitysolution-exception-list-components/index.ts +++ b/packages/kbn-securitysolution-exception-list-components/index.ts @@ -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'; diff --git a/packages/kbn-securitysolution-exception-list-components/src/wildcard_with_wrong_operator_callout/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/wildcard_with_wrong_operator_callout/index.tsx new file mode 100644 index 000000000000..a5116a920ac5 --- /dev/null +++ b/packages/kbn-securitysolution-exception-list-components/src/wildcard_with_wrong_operator_callout/index.tsx @@ -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 ( + +

+ + {i18n.translate( + 'exceptionList-components.wildcardWithWrongOperatorCallout.changeTheOperator', + { defaultMessage: 'Change the operator' } + )} + + ), + matches: ( + + {i18n.translate( + 'exceptionList-components.wildcardWithWrongOperatorCallout.matches', + { defaultMessage: 'matches' } + )} + + ), + }} + /> +

+
+ ); +}; diff --git a/packages/kbn-securitysolution-exception-list-components/tsconfig.json b/packages/kbn-securitysolution-exception-list-components/tsconfig.json index 988ad42191b7..b3df3a2aa208 100644 --- a/packages/kbn-securitysolution-exception-list-components/tsconfig.json +++ b/packages/kbn-securitysolution-exception-list-components/tsconfig.json @@ -19,6 +19,7 @@ "@kbn/securitysolution-autocomplete", "@kbn/ui-theme", "@kbn/i18n", + "@kbn/i18n-react", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index f877683caec1..4cb502c1e921 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -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( diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts index b2ae2d9fbceb..761321ba9497 100644 --- a/packages/kbn-securitysolution-utils/src/path_validations/index.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -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, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_confirm_modal.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_confirm_modal.tsx new file mode 100644 index 000000000000..be44d16e3dc2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_confirm_modal.tsx @@ -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( + ({ + title, + body, + confirmButton, + cancelButton, + onCancel, + onSuccess, + 'data-test-subj': dataTestSubj, + }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + return ( + + + {title} + + + + +

{body}

+
+
+ + + + {cancelButton} + + + + {confirmButton} + + +
+ ); + } +); +ArtifactConfirmModal.displayName = 'ArtifactConfirmModal'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx index 5179bb7b76be..95255eeedd50 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.test.tsx @@ -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', () => { diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx index 1261d01c7af4..06c43626955c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx @@ -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( ..._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(false); const [externalSubmitHandlerError, setExternalSubmitHandlerError] = useState< IHttpFetchError | undefined >(undefined); + const [showConfirmModal, setShowConfirmModal] = useState(false); const isEditFlow = urlParams.show === 'edit'; const formMode: ArtifactFormComponentProps['mode'] = isEditFlow ? 'edit' : 'create'; @@ -270,11 +272,12 @@ export const ArtifactFlyout = memo( }, [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( 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 ( + 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( 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(

{isEditFlow ? labels.flyoutEditTitle : labels.flyoutCreateTitle}

- {!isInitializing && showExpiredLicenseBanner && ( ( {labels.flyoutDowngradedLicenseDocsInfo(securitySolution)} )} - {isInitializing && } @@ -391,7 +424,6 @@ export const ArtifactFlyout = memo( /> )} - {!isInitializing && ( @@ -420,6 +452,7 @@ export const ArtifactFlyout = memo( )} + {showConfirmModal && confirmModal} ); } diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/types.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/types.ts index 51cabeea8ed7..b0496b8315d9 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/types.ts @@ -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; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.test.tsx index df4b06ecc2b9..7babe79513c6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.test.tsx @@ -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 = { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.tsx index 90e1dcc1c0c8..2c17f1d18209 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/form.tsx @@ -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' ?
{resultValue}
: 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', + <> + + + , + true + ); + validation.extraWarning = extraWarning; + } validation.isValid = isValid; return validation; }; @@ -245,6 +286,9 @@ export const TrustedAppsForm = memo( onChange({ item: updatedFormValues, isValid: updatedValidationResult.isValid, + confirmModalLabels: updatedValidationResult.extraWarning + ? CONFIRM_WARNING_MODAL_LABELS + : undefined, }); }, [onChange] diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index 02ada2533f9b..7c4142609cb4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -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', + } + ), };