From f7a1679395396cc2811db0faeb5d8a3837303a99 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 22 Jul 2020 18:21:40 -0400 Subject: [PATCH] [Security Solution][Exceptions] - Update UI exceptions builder nested logic (#72490) ## Summary This PR is meant to update the exception builder logic to handle nested fields. If you're unfamiliar with nested fields, you can read up more on it [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html) and [here](https://github.com/elastic/kibana/issues/44554). It also does a bit of cleanup, so though it may look like a lot of changes, parts of it were just moving some things around. --- .../autocomplete/field_value_lists.test.tsx | 85 +- .../autocomplete/field_value_lists.tsx | 17 +- .../autocomplete/field_value_match.tsx | 28 +- .../autocomplete/field_value_match_any.tsx | 33 +- .../components/autocomplete/helpers.test.ts | 62 +- .../common/components/autocomplete/helpers.ts | 15 +- .../components/autocomplete/operator.test.tsx | 48 +- .../components/autocomplete/operator.tsx | 5 +- .../exceptions/add_exception_modal/index.tsx | 1 + .../builder_button_options.stories.tsx | 44 +- .../builder/builder_button_options.test.tsx | 88 +- .../builder/builder_button_options.tsx | 20 +- ...m.test.tsx => builder_entry_item.test.tsx} | 248 ++-- ...{entry_item.tsx => builder_entry_item.tsx} | 180 +-- .../builder/builder_exception_item.test.tsx | 53 +- .../builder/builder_exception_item.tsx | 134 ++- .../exceptions/builder/helpers.test.tsx | 1014 +++++++++++++++++ .../components/exceptions/builder/helpers.tsx | 549 +++++++++ .../components/exceptions/builder/index.tsx | 265 ++++- .../components/exceptions/builder/reducer.ts | 106 ++ .../exceptions/builder/translations.ts | 71 ++ .../exceptions/edit_exception_modal/index.tsx | 1 + .../components/exceptions/helpers.test.tsx | 206 +--- .../common/components/exceptions/helpers.tsx | 162 +-- .../components/exceptions/translations.ts | 39 +- .../common/components/exceptions/types.ts | 21 +- .../exception_item/exception_details.test.tsx | 2 +- .../exception_item/exception_details.tsx | 2 +- .../viewer/exception_item/index.tsx | 3 +- .../exceptions/viewer/helpers.test.tsx | 224 ++++ .../components/exceptions/viewer/helpers.tsx | 109 ++ 31 files changed, 3027 insertions(+), 808 deletions(-) rename x-pack/plugins/security_solution/public/common/components/exceptions/builder/{entry_item.test.tsx => builder_entry_item.test.tsx} (70%) rename x-pack/plugins/security_solution/public/common/components/exceptions/builder/{entry_item.tsx => builder_entry_item.tsx} (62%) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index 90e195b6e95a0..eca38b9effe1b 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -52,20 +52,18 @@ describe('AutocompleteFieldListsComponent', () => { selectedField={getField('ip')} selectedValue="some-list-id" isLoading={false} - isClearable={false} - isDisabled={true} + isClearable={true} + isDisabled onChange={jest.fn()} /> ); - await waitFor(() => { - expect( - wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) - .prop('disabled') - ).toBeTruthy(); - }); + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); }); test('it renders loading if "isLoading" is true', async () => { @@ -73,9 +71,9 @@ describe('AutocompleteFieldListsComponent', () => { ({ eui: euiLightVars, darkMode: false })}> { ); - await waitFor(() => { + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) - .at(0) - .simulate('click'); - expect( - wrapper - .find( - `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` - ) - .prop('isLoading') - ).toBeTruthy(); - }); + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); }); test('it allows user to clear values if "isClearable" is true', async () => { @@ -104,9 +100,9 @@ describe('AutocompleteFieldListsComponent', () => { @@ -114,9 +110,9 @@ describe('AutocompleteFieldListsComponent', () => { ); expect( wrapper - .find(`[data-test-subj="comboBoxInput"]`) - .hasClass('euiComboBox__inputWrap-isClearable') - ).toBeTruthy(); + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); }); test('it correctly displays lists that match the selected "keyword" field esType', () => { @@ -210,19 +206,24 @@ describe('AutocompleteFieldListsComponent', () => { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange([{ label: 'some name' }]); - expect(mockOnChange).toHaveBeenCalledWith({ - created_at: DATE_NOW, - created_by: 'some user', - description: 'some description', - id: 'some-list-id', - meta: {}, - name: 'some name', - tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', - type: 'ip', - updated_at: DATE_NOW, - updated_by: 'some user', - version: VERSION, - immutable: IMMUTABLE, + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ + created_at: DATE_NOW, + created_by: 'some user', + description: 'some description', + id: 'some-list-id', + meta: {}, + name: 'some name', + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: DATE_NOW, + updated_by: 'some user', + _version: undefined, + version: VERSION, + deserializer: undefined, + serializer: undefined, + immutable: IMMUTABLE, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx index cd90d6eb85623..4349e70594ecb 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -9,7 +9,7 @@ import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { IFieldType } from '../../../../../../../src/plugins/data/common'; import { useFindLists, ListSchema } from '../../../lists_plugin_deps'; import { useKibana } from '../../../common/lib/kibana'; -import { getGenericComboBoxProps } from './helpers'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; interface AutocompleteFieldListsProps { placeholder: string; @@ -75,6 +75,8 @@ export const AutocompleteFieldListsComponent: React.FC setIsTouched(true), [setIsTouched]); + useEffect(() => { if (result != null) { setLists(result.data); @@ -91,17 +93,24 @@ export const AutocompleteFieldListsComponent: React.FC paramIsValid(selectedValue, selectedField, isRequired, touched), + [selectedField, selectedValue, isRequired, touched] + ); + + const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]); + return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} singleSelection={{ asPlainText: true }} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox listsComboxBox" diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx index 992005b3be8bc..137f6803dc54e 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -9,7 +9,7 @@ import { uniq } from 'lodash'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { validateParams, getGenericComboBoxProps } from './helpers'; +import { paramIsValid, getGenericComboBoxProps } from './helpers'; import { OperatorTypeEnum } from '../../../lists_plugin_deps'; import { GetGenericComboBoxPropsReturn } from './types'; import * as i18n from './translations'; @@ -82,16 +82,28 @@ export const AutocompleteFieldMatchComponent: React.FC validateParams(selectedValue, selectedField), [ - selectedField, - selectedValue, + const isValid = useMemo( + (): boolean => paramIsValid(selectedValue, selectedField, isRequired, touched), + [selectedField, selectedValue, isRequired, touched] + ); + + const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, ]); return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox matchComboxBox" style={fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx index 27807a752c141..5a15c1f7238de 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx @@ -9,7 +9,7 @@ import { uniq } from 'lodash'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { getGenericComboBoxProps, validateParams } from './helpers'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; import { OperatorTypeEnum } from '../../../lists_plugin_deps'; import { GetGenericComboBoxPropsReturn } from './types'; import * as i18n from './translations'; @@ -78,16 +78,29 @@ export const AutocompleteFieldMatchAnyComponent: React.FC onChange([...(selectedValue || []), option]); const isValid = useMemo((): boolean => { - const areAnyInvalid = selectedComboOptions.filter( - ({ label }) => !validateParams(label, selectedField) - ); - return areAnyInvalid.length === 0; - }, [selectedComboOptions, selectedField]); + const areAnyInvalid = + selectedComboOptions.filter( + ({ label }) => !paramIsValid(label, selectedField, isRequired, touched) + ).length > 0; + return !areAnyInvalid; + }, [selectedComboOptions, selectedField, isRequired, touched]); + + const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} delimiter=", " data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox" fullWidth diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index b25bb245c6792..289cdd5e87c00 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -14,7 +14,7 @@ import { existsOperator, doesNotExistOperator, } from './operators'; -import { getOperators, validateParams, getGenericComboBoxProps } from './helpers'; +import { getOperators, paramIsValid, getGenericComboBoxProps } from './helpers'; describe('helpers', () => { describe('#getOperators', () => { @@ -53,27 +53,67 @@ describe('helpers', () => { }); }); - describe('#validateParams', () => { - test('returns false if value is undefined', () => { - const isValid = validateParams(undefined, getField('@timestamp')); + describe('#paramIsValid', () => { + test('returns false if value is undefined and "isRequired" nad "touched" are true', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); expect(isValid).toBeFalsy(); }); - test('returns false if value is empty string', () => { - const isValid = validateParams('', getField('@timestamp')); + test('returns true if value is undefined and "isRequired" is true but "touched" is false', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); - expect(isValid).toBeFalsy(); + expect(isValid).toBeTruthy(); + }); + + test('returns true if value is undefined and "isRequired" is false', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), false, false); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if value is empty string when "isRequired" is true and "touched" is false', () => { + const isValid = paramIsValid('', getField('@timestamp'), true, false); + + expect(isValid).toBeTruthy(); + }); + + test('returns true if value is empty string and "isRequired" is false', () => { + const isValid = paramIsValid('', getField('@timestamp'), false, false); + + expect(isValid).toBeTruthy(); }); - test('returns true if type is "date" and value is valid', () => { - const isValid = validateParams('1994-11-05T08:15:30-05:00', getField('@timestamp')); + test('returns true if type is "date" and value is valid and "isRequired" is false', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + false, + false + ); expect(isValid).toBeTruthy(); }); - test('returns false if type is "date" and value is not valid', () => { - const isValid = validateParams('1593478826', getField('@timestamp')); + test('returns true if type is "date" and value is valid and "isRequired" is true', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + true, + false + ); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if type is "date" and value is not valid and "isRequired" is false', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), false, false); + + expect(isValid).toBeFalsy(); + }); + + test('returns false if type is "date" and value is not valid and "isRequired" is true', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), true, true); expect(isValid).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index a65f1fa35d3c2..3dcaf612da649 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -30,21 +30,26 @@ export const getOperators = (field: IFieldType | undefined): OperatorOption[] => } }; -export const validateParams = ( +export const paramIsValid = ( params: string | undefined, - field: IFieldType | undefined + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean ): boolean => { - // Box would show error state if empty otherwise - if (params == null || params === '') { + if (isRequired && touched && (params == null || params === '')) { return false; } + if ((isRequired && !touched) || (!isRequired && (params == null || params === ''))) { + return true; + } + const types = field != null && field.esTypes != null ? field.esTypes : []; return types.reduce((acc, type) => { switch (type) { case 'date': - const moment = dateMath.parse(params); + const moment = dateMath.parse(params ?? ''); return Boolean(moment && moment.isValid()); default: return acc; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx index 45fe6be78ace6..737be199e2481 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx @@ -74,7 +74,7 @@ describe('OperatorComponent', () => { expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); }); - test('it displays "operatorOptions" if param is passed in', () => { + test('it displays "operatorOptions" if param is passed in with items', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ).toEqual([{ label: 'is not' }]); }); + test('it does not display "operatorOptions" if param is passed in with no items', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { + label: 'is', + }, + { + label: 'is not', + }, + { + label: 'is one of', + }, + { + label: 'is not one of', + }, + { + label: 'exists', + }, + { + label: 'does not exist', + }, + { + label: 'is in list', + }, + { + label: 'is not in list', + }, + ]); + }); + test('it correctly displays selected operator', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx index 6d9a684aab2de..cec7d575fc78e 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx @@ -35,7 +35,10 @@ export const OperatorComponent: React.FC = ({ }): JSX.Element => { const getLabel = useCallback(({ message }): string => message, []); const optionsMemo = useMemo( - (): OperatorOption[] => (operatorOptions ? operatorOptions : getOperators(selectedField)), + (): OperatorOption[] => + operatorOptions != null && operatorOptions.length > 0 + ? operatorOptions + : getOperators(selectedField), [operatorOptions, selectedField] ); const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 6e77cd7082d56..2abbaee5187a9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -307,6 +307,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ indexPatterns={indexPatterns} isOrDisabled={false} isAndDisabled={false} + isNestedDisabled={false} data-test-subj="alert-exception-builder" id-aria="alert-exception-builder" onChange={handleBuilderOnChange} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx index 9486008e708ea..5ca2d2b86a527 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx @@ -21,22 +21,43 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) ); }) - .add('nested button', () => { + .add('nested button - isNested false', () => { return ( + ); + }) + .add('nested button - isNested true', () => { + return ( + ); }) @@ -45,10 +66,13 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) ); }) @@ -57,10 +81,28 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) + ); + }) + .add('nested disabled', () => { + return ( + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx index 66968ee95d3fa..6564770196b89 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx @@ -15,10 +15,13 @@ describe('BuilderButtonOptions', () => { ); @@ -37,10 +40,13 @@ describe('BuilderButtonOptions', () => { ); @@ -49,17 +55,20 @@ describe('BuilderButtonOptions', () => { expect(onOrClicked).toHaveBeenCalledTimes(1); }); - test('it invokes "onAndClicked" when "and" button is clicked', () => { + test('it invokes "onAndClicked" when "and" button is clicked and "isNested" is "false"', () => { const onAndClicked = jest.fn(); const wrapper = mount( ); @@ -68,15 +77,40 @@ describe('BuilderButtonOptions', () => { expect(onAndClicked).toHaveBeenCalledTimes(1); }); + test('it invokes "onAddClickWhenNested" when "and" button is clicked and "isNested" is "true"', () => { + const onAddClickWhenNested = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsAndButton"] button').simulate('click'); + + expect(onAddClickWhenNested).toHaveBeenCalledTimes(1); + }); + test('it disables "and" button if "isAndDisabled" is true', () => { const wrapper = mount( ); @@ -85,15 +119,18 @@ describe('BuilderButtonOptions', () => { expect(andButton.prop('disabled')).toBeTruthy(); }); - test('it disables "or" button if "isOrDisabled" is true', () => { + test('it disables "or" button if "isOrDisabled" is "true"', () => { const wrapper = mount( ); @@ -102,17 +139,40 @@ describe('BuilderButtonOptions', () => { expect(orButton.prop('disabled')).toBeTruthy(); }); - test('it invokes "onNestedClicked" when "and" button is clicked', () => { + test('it disables "add nested" button if "isNestedDisabled" is "true"', () => { + const wrapper = mount( + + ); + + const nestedButton = wrapper.find('[data-test-subj="exceptionsNestedButton"] button').at(0); + + expect(nestedButton.prop('disabled')).toBeTruthy(); + }); + + test('it invokes "onNestedClicked" when "isNested" is "false" and "nested" button is clicked', () => { const onNestedClicked = jest.fn(); const wrapper = mount( ); @@ -120,4 +180,26 @@ describe('BuilderButtonOptions', () => { expect(onNestedClicked).toHaveBeenCalledTimes(1); }); + + test('it invokes "onAndClicked" when "isNested" is "true" and "nested" button is clicked', () => { + const onAndClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click'); + + expect(onAndClicked).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx index eb224b82d756f..bef47ce877b93 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx @@ -7,7 +7,8 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import styled from 'styled-components'; -import * as i18n from '../translations'; +import * as i18n from './translations'; +import * as i18nShared from '../translations'; const MyEuiButton = styled(EuiButton)` min-width: 95px; @@ -16,19 +17,25 @@ const MyEuiButton = styled(EuiButton)` interface BuilderButtonOptionsProps { isOrDisabled: boolean; isAndDisabled: boolean; + isNestedDisabled: boolean; + isNested: boolean; showNestedButton: boolean; onAndClicked: () => void; onOrClicked: () => void; onNestedClicked: () => void; + onAddClickWhenNested: () => void; } export const BuilderButtonOptions: React.FC = ({ isOrDisabled = false, isAndDisabled = false, showNestedButton = false, + isNestedDisabled = true, + isNested, onAndClicked, onOrClicked, onNestedClicked, + onAddClickWhenNested, }) => ( @@ -36,11 +43,11 @@ export const BuilderButtonOptions: React.FC = ({ fill size="s" iconType="plusInCircle" - onClick={onAndClicked} + onClick={isNested ? onAddClickWhenNested : onAndClicked} data-test-subj="exceptionsAndButton" isDisabled={isAndDisabled} > - {i18n.AND} + {i18nShared.AND} @@ -52,7 +59,7 @@ export const BuilderButtonOptions: React.FC = ({ isDisabled={isOrDisabled} data-test-subj="exceptionsOrButton" > - {i18n.OR} + {i18nShared.OR} {showNestedButton && ( @@ -60,10 +67,11 @@ export const BuilderButtonOptions: React.FC = ({ - {i18n.ADD_NESTED_DESCRIPTION} + {isNested ? i18n.ADD_NON_NESTED_DESCRIPTION : i18n.ADD_NESTED_DESCRIPTION} )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx similarity index 70% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index 791782b0f0152..b845848bd14d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -8,7 +8,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { EntryItemComponent } from './entry_item'; +import { BuilderEntryItem } from './builder_entry_item'; import { isOperator, isNotOperator, @@ -44,47 +44,26 @@ jest.mock('../../../../lists_plugin_deps', () => { }; }); -describe('EntryItemComponent', () => { - test('it renders fields disabled if "isLoading" is "true"', () => { - const wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryField"] input').props().disabled - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"] input').props().disabled - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatch"] input').props().disabled - ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).toHaveLength(0); - }); - +describe('BuilderEntryItem', () => { test('it renders field labels if "showLabel" is "true"', () => { const wrapper = mount( - ); @@ -94,16 +73,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isOperator"', () => { const wrapper = mount( - ); @@ -117,16 +103,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotOperator"', () => { const wrapper = mount( - ); @@ -142,16 +135,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isOneOfOperator"', () => { const wrapper = mount( - ); @@ -167,16 +167,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotOneOfOperator"', () => { const wrapper = mount( - ); @@ -192,16 +199,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isInListOperator"', () => { const wrapper = mount( - ); @@ -217,16 +231,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotInListOperator"', () => { const wrapper = mount( - ); @@ -242,16 +263,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "existsOperator"', () => { const wrapper = mount( - ); @@ -270,16 +298,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "doesNotExistOperator"', () => { const wrapper = mount( - ); @@ -299,16 +334,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new field is selected and resets operator and value fields', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -318,24 +360,31 @@ describe('EntryItemComponent', () => { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'machine.os', operator: 'included', type: 'match', value: undefined }, + { field: 'machine.os', operator: 'included', type: 'match', value: '' }, 0 ); }); - test('it invokes "onChange" when new operator is selected and resets value field', () => { + test('it invokes "onChange" when new operator is selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -345,7 +394,7 @@ describe('EntryItemComponent', () => { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '' }, + { field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -353,16 +402,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for match operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -380,16 +436,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for match_any operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -407,16 +470,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for list operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 7bf279168a9a0..736e88ee9fe06 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -9,136 +9,135 @@ import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { FieldComponent } from '../../autocomplete/field'; import { OperatorComponent } from '../../autocomplete/operator'; -import { isOperator } from '../../autocomplete/operators'; import { OperatorOption } from '../../autocomplete/types'; import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match'; import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any'; import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists'; import { FormattedBuilderEntry, BuilderEntry } from '../types'; import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists'; -import { ListSchema, OperatorTypeEnum } from '../../../../lists_plugin_deps'; -import { getValueFromOperator } from '../helpers'; +import { ListSchema, OperatorTypeEnum, ExceptionListType } from '../../../../lists_plugin_deps'; import { getEmptyValue } from '../../empty_value'; -import * as i18n from '../translations'; +import * as i18n from './translations'; +import { + getFilteredIndexPatterns, + getOperatorOptions, + getEntryOnFieldChange, + getEntryOnOperatorChange, + getEntryOnMatchChange, + getEntryOnMatchAnyChange, + getEntryOnListChange, +} from './helpers'; interface EntryItemProps { entry: FormattedBuilderEntry; - entryIndex: number; indexPattern: IIndexPattern; - isLoading: boolean; showLabel: boolean; + listType: ExceptionListType; + addNested: boolean; onChange: (arg: BuilderEntry, i: number) => void; } -export const EntryItemComponent: React.FC = ({ +export const BuilderEntryItem: React.FC = ({ entry, - entryIndex, indexPattern, - isLoading, + listType, + addNested, showLabel, onChange, }): JSX.Element => { const handleFieldChange = useCallback( ([newField]: IFieldType[]): void => { - onChange( - { - field: newField.name, - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: undefined, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnFieldChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex] + [onChange, entry] ); const handleOperatorChange = useCallback( ([newOperator]: OperatorOption[]): void => { - const newEntry = getValueFromOperator(entry.field, newOperator); - onChange(newEntry, entryIndex); + const { updatedEntry, index } = getEntryOnOperatorChange(entry, newOperator); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field] + [onChange, entry] ); const handleFieldMatchValueChange = useCallback( (newField: string): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.MATCH, - operator: entry.operator.operator, - value: newField, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnMatchChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); const handleFieldMatchAnyValueChange = useCallback( (newField: string[]): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.MATCH_ANY, - operator: entry.operator.operator, - value: newField, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnMatchAnyChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); const handleFieldListValueChange = useCallback( (newField: ListSchema): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.LIST, - operator: entry.operator.operator, - list: { id: newField.id, type: newField.type }, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnListChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); - const renderFieldInput = (isFirst: boolean): JSX.Element => { - const comboBox = ( - - ); - - if (isFirst) { - return ( - - {comboBox} - + const renderFieldInput = useCallback( + (isFirst: boolean): JSX.Element => { + const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry); + const comboBox = ( + ); - } else { - return comboBox; - } - }; + + if (isFirst) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }, + [handleFieldChange, indexPattern, entry] + ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { + const operatorOptions = getOperatorOptions( + entry, + listType, + entry.field != null && entry.field.type === 'boolean' + ); const comboBox = ( = ({ placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER} selectedField={entry.field} selectedValue={value} - isDisabled={isLoading} - isLoading={isLoading} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } + isLoading={false} isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchValueChange} @@ -182,8 +183,10 @@ export const EntryItemComponent: React.FC = ({ placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER} selectedField={entry.field} selectedValue={values} - isDisabled={isLoading} - isLoading={isLoading} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } + isLoading={false} isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchAnyValueChange} @@ -198,8 +201,10 @@ export const EntryItemComponent: React.FC = ({ selectedField={entry.field} placeholder={i18n.EXCEPTION_FIELD_LISTS_PLACEHOLDER} selectedValue={id} - isLoading={isLoading} - isDisabled={isLoading} + isLoading={false} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } isClearable={false} onChange={handleFieldListValueChange} isRequired @@ -240,9 +245,14 @@ export const EntryItemComponent: React.FC = ({ > {renderFieldInput(showLabel)} {renderOperatorInput(showLabel)} - {renderFieldValueInput(showLabel, entry.operator.type)} + + {renderFieldValueInput( + showLabel, + entry.nested === 'parent' ? OperatorTypeEnum.EXISTS : entry.operator.type + )} + ); }; -EntryItemComponent.displayName = 'EntryItem'; +BuilderEntryItem.displayName = 'BuilderEntryItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx index 0f3b6ec2e94e4..584f0971a4193 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx @@ -18,8 +18,10 @@ import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/typ describe('ExceptionListItemComponent', () => { describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; + const exceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [getEntryMatchMock(), getEntryMatchMock()], + }; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -46,7 +49,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders "and" badge when more than one exception item entry exists and it is not the first exception item', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -59,9 +62,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -72,7 +76,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -85,9 +89,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -100,7 +105,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -113,9 +118,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -134,8 +140,10 @@ describe('ExceptionListItemComponent', () => { describe('delete button logic', () => { test('it renders delete button disabled when it is only entry left in builder', () => { - const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.entries = [getEntryMatchMock()]; + const exceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [{ ...getEntryMatchMock(), field: '' }], + }; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -160,7 +169,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when it is not the only entry left in builder', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( @@ -173,9 +182,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -187,7 +197,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when "exceptionItemIndex" is not "0"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} // if exceptionItemIndex is not 0, wouldn't make sense for // this to be true, but done for testing purposes isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -215,7 +226,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when more than one entry exists', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -243,7 +255,7 @@ describe('ExceptionListItemComponent', () => { test('it invokes "onChangeExceptionItem" when delete button clicked', () => { const mockOnDeleteExceptionItem = jest.fn(); - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={mockOnDeleteExceptionItem} onChangeExceptionItem={jest.fn()} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx index 8e57e83d0e7e4..dce78f3cb9ceb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx @@ -10,9 +10,10 @@ import styled from 'styled-components'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { AndOrBadge } from '../../and_or_badge'; -import { EntryItemComponent } from './entry_item'; -import { getFormattedBuilderEntries } from '../helpers'; +import { BuilderEntryItem } from './builder_entry_item'; +import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; +import { ExceptionListType } from '../../../../../public/lists_plugin_deps'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; @@ -22,14 +23,25 @@ const MyFirstRowContainer = styled(EuiFlexItem)` padding-top: 20px; `; +const MyBeautifulLine = styled(EuiFlexItem)` + &:after { + background: ${({ theme }) => theme.eui.euiColorLightShade}; + content: ''; + width: 2px; + height: 40px; + margin: 0 15px; + } +`; + interface ExceptionListItemProps { exceptionItem: ExceptionsBuilderExceptionItem; exceptionId: string; exceptionItemIndex: number; - isLoading: boolean; indexPattern: IIndexPattern; andLogicIncluded: boolean; isOnlyItem: boolean; + listType: ExceptionListType; + addNested: boolean; onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; } @@ -40,8 +52,9 @@ export const ExceptionListItemComponent = React.memo( exceptionId, exceptionItemIndex, indexPattern, - isLoading, isOnlyItem, + listType, + addNested, andLogicIncluded, onDeleteExceptionItem, onChangeExceptionItem, @@ -63,15 +76,12 @@ export const ExceptionListItemComponent = React.memo( ); const handleDeleteEntry = useCallback( - (entryIndex: number): void => { - const updatedEntries: BuilderEntry[] = [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ]; - const updatedExceptionItem: ExceptionsBuilderExceptionItem = { - ...exceptionItem, - entries: updatedEntries, - }; + (entryIndex: number, parentIndex: number | null): void => { + const updatedExceptionItem = getUpdatedEntriesOnDelete( + exceptionItem, + parentIndex ? parentIndex : entryIndex, + parentIndex ? entryIndex : null + ); onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex); }, @@ -80,80 +90,98 @@ export const ExceptionListItemComponent = React.memo( const entries = useMemo( (): FormattedBuilderEntry[] => - indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [], - [indexPattern, exceptionItem] + indexPattern != null && exceptionItem.entries.length > 0 + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + : [], + [exceptionItem.entries, indexPattern] ); - const andBadge = useMemo((): JSX.Element => { + const getAndBadge = useCallback((): JSX.Element => { const badge = ; - if (entries.length > 1 && exceptionItemIndex === 0) { + + if (andLogicIncluded && exceptionItem.entries.length > 1 && exceptionItemIndex === 0) { return ( {badge} ); - } else if (entries.length > 1) { + } else if (andLogicIncluded && exceptionItem.entries.length <= 1) { return ( - + {badge} - + ); - } else { + } else if (andLogicIncluded && exceptionItem.entries.length > 1) { return ( - + {badge} - + ); + } else { + return <>; } - }, [entries.length, exceptionItemIndex]); + }, [exceptionItem.entries.length, exceptionItemIndex, andLogicIncluded]); const getDeleteButton = useCallback( - (index: number): JSX.Element => { + (entryIndex: number, parentIndex: number | null): JSX.Element => { const button = ( handleDeleteEntry(index)} - isDisabled={isOnlyItem && entries.length === 1 && exceptionItemIndex === 0} + onClick={() => handleDeleteEntry(entryIndex, parentIndex)} + isDisabled={ + isOnlyItem && + exceptionItem.entries.length === 1 && + exceptionItemIndex === 0 && + (exceptionItem.entries[0].field == null || exceptionItem.entries[0].field === '') + } aria-label="entryDeleteButton" className="exceptionItemEntryDeleteButton" data-test-subj="exceptionItemEntryDeleteButton" /> ); - if (index === 0 && exceptionItemIndex === 0) { + if (entryIndex === 0 && exceptionItemIndex === 0 && parentIndex == null) { return {button}; } else { return {button}; } }, - [entries.length, exceptionItemIndex, handleDeleteEntry, isOnlyItem] + [exceptionItemIndex, exceptionItem.entries, handleDeleteEntry, isOnlyItem] ); return ( - - {andLogicIncluded && andBadge} - - - {entries.map((item, index) => ( - - - - - - {getDeleteButton(index)} - - - ))} - - - + + + {getAndBadge()} + + + {entries.map((item, index) => ( + + + {item.nested === 'child' && } + + + + {getDeleteButton( + item.entryIndex, + item.parent != null ? item.parent.parentIndex : null + )} + + + ))} + + + + ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx new file mode 100644 index 0000000000000..8b74d44f29a18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -0,0 +1,1014 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + fields, + getField, +} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { getEntryNestedMock } from '../../../../../../lists/common/schemas/types/entry_nested.mock'; +import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock'; +import { + isOperator, + isOneOfOperator, + isNotOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, + isInListOperator, + EXCEPTION_OPERATORS, +} from '../../autocomplete/operators'; +import { FormattedBuilderEntry, BuilderEntry, ExceptionsBuilderExceptionItem } from '../types'; +import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; +import { EntryNested, Entry } from '../../../../lists_plugin_deps'; + +import { + getFilteredIndexPatterns, + getFormattedBuilderEntry, + isEntryNested, + getFormattedBuilderEntries, + getUpdatedEntriesOnDelete, + getEntryFromOperator, + getOperatorOptions, + getEntryOnFieldChange, + getEntryOnOperatorChange, + getEntryOnMatchChange, + getEntryOnMatchAnyChange, + getEntryOnListChange, +} from './helpers'; +import { OperatorOption } from '../../autocomplete/types'; + +const getMockIndexPattern = (): IIndexPattern => ({ + id: '1234', + title: 'logstash-*', + fields, +}); + +const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + field: getField('ip'), + operator: isOperator, + value: 'some value', + nested: undefined, + parent: undefined, + entryIndex: 0, +}); + +const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + field: getField('nestedField.child'), + operator: isOperator, + value: 'some value', + nested: 'child', + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }, + parentIndex: 0, + }, + entryIndex: 0, +}); + +const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, + operator: isOperator, + value: undefined, + nested: 'parent', + parent: undefined, + entryIndex: 0, +}); + +describe('Exception builder helpers', () => { + describe('#getFilteredIndexPatterns', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntry', () => { + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; + const payloadParent: EntryNested = { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + payloadParent, + 1 + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField.child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [{ ...payloadItem }], + field: 'nestedField', + type: 'nested', + }, + parentIndex: 1, + }, + value: 'some host name', + }; + expect(output).toEqual(expected); + }); + + test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#isEntryNested', () => { + test('it returns "false" if payload is not of type EntryNested', () => { + const payload: BuilderEntry = { ...getEntryMatchMock() }; + const output = isEntryNested(payload); + const expected = false; + expect(output).toEqual(expected); + }); + + test('it returns "true if payload is of type EntryNested', () => { + const payload: EntryNested = getEntryNestedMock(); + const output = isEntryNested(payload); + const expected = true; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntries', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [{ ...getEntryMatchMock() }]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: undefined, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when no nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] }, + ]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + entryIndex: 1, + field: { + aggregatable: true, + count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'string', + }, + nested: undefined, + operator: isOneOfOperator, + parent: undefined, + value: ['some extension'], + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadParent: EntryNested = { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }; + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...payloadParent }, + ]; + + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + entryIndex: 1, + field: { + aggregatable: false, + esTypes: ['nested'], + name: 'nestedField', + searchable: false, + type: 'string', + }, + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }, + { + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField.child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match', + value: 'some host name', + }, + ], + field: 'nestedField', + type: 'nested', + }, + parentIndex: 1, + }, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + }); + + describe('#getUpdatedEntriesOnDelete', () => { + test('it removes entry corresponding to "entryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes entry corresponding to "nestedEntryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedMock(), + entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 1); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryExistsMock() }] }], + }; + expect(output).toEqual(expected); + }); + + test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedMock(), + entries: [{ ...getEntryExistsMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [], + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryFromOperator', () => { + test('it returns current value when switching from "is" to "is not"', () => { + const payloadOperator: OperatorOption = isNotOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'match', + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not" to "is"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match', + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match', + value: '', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is one of" to "is not one of"', () => { + const payloadOperator: OperatorOption = isNotOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not one of" to "is one of"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match_any"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match_any', + value: [], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "exists" to "does not exist"', () => { + const payloadOperator: OperatorOption = doesNotExistOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: existsOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "does not exist" to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: doesNotExistOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "list"', () => { + const payloadOperator: OperatorOption = isInListOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'list', + list: { id: '', type: 'ip' }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getOperatorOptions', () => { + test('it returns "isOperator" if "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if no field selected', () => { + const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', true); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns all operator options if "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = EXCEPTION_OPERATORS; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnFieldChange', () => { + test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()], + }, + parentIndex: 0, + }, + }; + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { field: 'child', operator: 'included', type: 'match', value: '' }, + getEntryMatchAnyMock(), + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns field of type "match" with updated field if not a nested entry', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadIFieldType: IFieldType = getField('ip'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + operator: 'included', + type: 'match', + value: '', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnOperatorChange', () => { + test('it returns updated subentry preserving its value when entry is not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'excluded', + type: 'match', + value: 'some value', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match_any', + value: [], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match', + value: 'jibber jabber', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + operator: 'included', + type: 'match', + value: 'jibber jabber', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchAnyChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: 'ip', + type: 'match_any', + value: ['jibber jabber'], + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + field: undefined, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: '', + type: 'match_any', + value: ['jibber jabber'], + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match_any', + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + field: undefined, + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + operator: 'included', + type: 'match_any', + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnListChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: 'ip', + type: 'list', + list: { id: 'some-list-id', type: 'ip' }, + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + field: undefined, + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: '', + type: 'list', + list: { id: 'some-list-id', type: 'ip' }, + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx new file mode 100644 index 0000000000000..2fe2c68941ae6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -0,0 +1,549 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; +import { + Entry, + OperatorTypeEnum, + EntryNested, + ExceptionListType, + EntryMatch, + EntryMatchAny, + EntryExists, + entriesList, + ListSchema, + OperatorEnum, +} from '../../../../lists_plugin_deps'; +import { + isOperator, + existsOperator, + isOneOfOperator, + EXCEPTION_OPERATORS, +} from '../../autocomplete/operators'; +import { OperatorOption } from '../../autocomplete/types'; +import { + BuilderEntry, + FormattedBuilderEntry, + ExceptionsBuilderExceptionItem, + EmptyEntry, + EmptyNestedEntry, +} from '../types'; +import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; + +/** + * Returns filtered index patterns based on the field - if a user selects to + * add nested entry, should only show nested fields, if item is the parent + * field of a nested entry, we only display the parent field + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + */ +export const getFilteredIndexPatterns = ( + patterns: IIndexPattern, + item: FormattedBuilderEntry +): IIndexPattern => { + if (item.nested === 'child' && item.parent != null) { + // when user has selected a nested entry, only fields with the common parent are shown + return { + ...patterns, + fields: patterns.fields.filter( + (field) => + field.subType != null && + field.subType.nested != null && + item.parent != null && + field.subType.nested.path.startsWith(item.parent.parent.field) + ), + }; + } else if (item.nested === 'parent' && item.field != null) { + // when user has selected a nested entry, right above it we show the common parent + return { ...patterns, fields: [item.field] }; + } else if (item.nested === 'parent' && item.field == null) { + // when user selects to add a nested entry, only nested fields are shown as options + return { + ...patterns, + fields: patterns.fields.filter( + (field) => field.subType != null && field.subType.nested != null + ), + }; + } else { + return patterns; + } +}; + +/** + * Formats the entry into one that is easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param itemIndex entry index + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntry = ( + indexPattern: IIndexPattern, + item: BuilderEntry, + itemIndex: number, + parent: EntryNested | undefined, + parentIndex: number | undefined +): FormattedBuilderEntry => { + const { fields } = indexPattern; + const field = parent != null ? `${parent.field}.${item.field}` : item.field; + const [selectedField] = fields.filter(({ name }) => field != null && field === name); + + if (parent != null && parentIndex != null) { + return { + field: selectedField, + operator: getExceptionOperatorSelect(item), + value: getEntryValue(item), + nested: 'child', + parent: { parent, parentIndex }, + entryIndex: itemIndex, + }; + } else { + return { + field: selectedField, + operator: getExceptionOperatorSelect(item), + value: getEntryValue(item), + nested: undefined, + parent: undefined, + entryIndex: itemIndex, + }; + } +}; + +export const isEntryNested = (item: BuilderEntry): item is EntryNested => { + return (item as EntryNested).entries != null; +}; + +/** + * Formats the entries to be easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param entries exception item entries + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntries = ( + indexPattern: IIndexPattern, + entries: BuilderEntry[], + parent?: EntryNested, + parentIndex?: number +): FormattedBuilderEntry[] => { + return entries.reduce((acc, item, index) => { + const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; + if (item.type !== 'nested' && !isNewNestedEntry) { + const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( + indexPattern, + item, + index, + parent, + parentIndex + ); + return [...acc, newItemEntry]; + } else { + const parentEntry: FormattedBuilderEntry = { + operator: isOperator, + nested: 'parent', + field: isNewNestedEntry + ? undefined + : { + name: item.field ?? '', + aggregatable: false, + searchable: false, + type: 'string', + esTypes: ['nested'], + }, + value: undefined, + entryIndex: index, + parent: undefined, + }; + + // User has selected to add a nested field, but not yet selected the field + if (isNewNestedEntry) { + return [...acc, parentEntry]; + } + + if (isEntryNested(item)) { + const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + + return [...acc, parentEntry, ...nestedItems]; + } + + return [...acc]; + } + }, []); +}; + +/** + * Determines whether an entire entry, exception item, or entry within a nested + * entry needs to be removed + * + * @param exceptionItem + * @param entryIndex index of given entry, for nested entries, this will correspond + * to their parent index + * @param nestedEntryIndex index of nested entry + * + */ +export const getUpdatedEntriesOnDelete = ( + exceptionItem: ExceptionsBuilderExceptionItem, + entryIndex: number, + nestedEntryIndex: number | null +): ExceptionsBuilderExceptionItem => { + const itemOfInterest: BuilderEntry = exceptionItem.entries[entryIndex]; + + if (nestedEntryIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { + const updatedEntryEntries: Array = [ + ...itemOfInterest.entries.slice(0, nestedEntryIndex), + ...itemOfInterest.entries.slice(nestedEntryIndex + 1), + ]; + + if (updatedEntryEntries.length === 0) { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } else { + const { field } = itemOfInterest; + const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { + field, + type: OperatorTypeEnum.NESTED, + entries: updatedEntryEntries, + }; + + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + updatedItemOfInterest, + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } + } else { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } +}; + +/** + * On operator change, determines whether value needs to be cleared or not + * + * @param field + * @param selectedOperator + * @param currentEntry + * + */ +export const getEntryFromOperator = ( + selectedOperator: OperatorOption, + currentEntry: FormattedBuilderEntry +): Entry => { + const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; + const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; + switch (selectedOperator.type) { + case 'match': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH, + operator: selectedOperator.operator, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; + case 'match_any': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH_ANY, + operator: selectedOperator.operator, + value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], + }; + case 'list': + return { + field: fieldValue, + type: OperatorTypeEnum.LIST, + operator: selectedOperator.operator, + list: { id: '', type: 'ip' }, + }; + default: + return { + field: fieldValue, + type: OperatorTypeEnum.EXISTS, + operator: selectedOperator.operator, + }; + } +}; + +/** + * Determines which operators to make available + * + * @param item + * @param listType + * + */ +export const getOperatorOptions = ( + item: FormattedBuilderEntry, + listType: ExceptionListType, + isBoolean: boolean +): OperatorOption[] => { + if (item.nested === 'parent' || item.field == null) { + return [isOperator]; + } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { + return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; + } else if (item.nested != null && listType === 'detection') { + return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; + } else { + return isBoolean ? [isOperator, existsOperator] : EXCEPTION_OPERATORS; + } +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current exception item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnFieldChange = ( + item: FormattedBuilderEntry, + newField: IFieldType +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, nested } = item; + const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; + + if (nested === 'parent') { + // For nested entries, when user first selects to add a nested + // entry, they first see a row similiar to what is shown for when + // a user selects "exists", as soon as they make a selection + // we can now identify the 'parent' and 'child' this is where + // we first convert the entry into type "nested" + const newParentFieldValue = + newField.subType != null && newField.subType.nested != null + ? newField.subType.nested.path + : ''; + + return { + updatedEntry: { + field: newParentFieldValue, + type: OperatorTypeEnum.NESTED, + entries: [ + { + field: newChildFieldValue ?? '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + ], + }, + index: entryIndex, + }; + } else if (nested === 'child' && parent != null) { + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: newChildFieldValue ?? '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: newField != null ? newField.name : '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user selects new operator + * + * @param item - current exception item entry values + * @param newOperator - newly selected operator + * + */ +export const getEntryOnOperatorChange = ( + item: FormattedBuilderEntry, + newOperator: OperatorOption +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, field, nested } = item; + const newEntry = getEntryFromOperator(newOperator, item); + + if (!entriesList.is(newEntry) && nested != null && parent != null) { + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + ...newEntry, + field: field != null ? field.name.split('.').slice(-1)[0] : '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { updatedEntry: newEntry, index: entryIndex }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchChange = ( + item: FormattedBuilderEntry, + newField: string +): { updatedEntry: BuilderEntry; index: number } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + type: OperatorTypeEnum.MATCH, + operator: operator.operator, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.MATCH, + operator: operator.operator, + value: newField, + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match_any" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchAnyChange = ( + item: FormattedBuilderEntry, + newField: string[] +): { updatedEntry: BuilderEntry; index: number } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + type: OperatorTypeEnum.MATCH_ANY, + operator: operator.operator, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.MATCH_ANY, + operator: operator.operator, + value: newField, + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "list" + * + * @param item - current exception item entry values + * @param newField - newly selected list + * + */ +export const getEntryOnListChange = ( + item: FormattedBuilderEntry, + newField: ListSchema +): { updatedEntry: BuilderEntry; index: number } => { + const { entryIndex, field, operator } = item; + const { id, type } = newField; + + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.LIST, + operator: operator.operator, + list: { id, type }, + }, + index: entryIndex, + }; +}; + +export const getDefaultEmptyEntry = (): EmptyEntry => ({ + field: '', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: '', +}); + +export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ + field: '', + type: OperatorTypeEnum.NESTED, + entries: [], +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index f6feca591dc6d..141429f152790 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; @@ -17,11 +17,14 @@ import { OperatorEnum, CreateExceptionListItemSchema, ExceptionListType, + entriesNested, } from '../../../../../public/lists_plugin_deps'; import { AndOrBadge } from '../../and_or_badge'; import { BuilderButtonOptions } from './builder_button_options'; import { getNewExceptionItem, filterExceptionItems } from '../helpers'; import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types'; +import { State, exceptionsBuilderReducer } from './reducer'; +import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry } from './helpers'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import exceptionableFields from '../exceptionable_fields.json'; @@ -39,6 +42,15 @@ const MyButtonsContainer = styled(EuiFlexItem)` margin: 16px 0; `; +const initialState: State = { + disableAnd: false, + disableOr: false, + andLogicIncluded: false, + addNested: false, + exceptions: [], + exceptionsToDelete: [], +}; + interface OnChangeProps { exceptionItems: Array; exceptionsToDelete: ExceptionListItemSchema[]; @@ -53,6 +65,7 @@ interface ExceptionBuilderProps { indexPatterns: IIndexPattern; isOrDisabled: boolean; isAndDisabled: boolean; + isNestedDisabled: boolean; onChange: (arg: OnChangeProps) => void; } @@ -65,74 +78,144 @@ export const ExceptionBuilder = ({ indexPatterns, isOrDisabled, isAndDisabled, + isNestedDisabled, onChange, }: ExceptionBuilderProps) => { - const [andLogicIncluded, setAndLogicIncluded] = useState(false); - const [exceptions, setExceptions] = useState( - exceptionListItems + const [ + { exceptions, exceptionsToDelete, andLogicIncluded, disableAnd, disableOr, addNested }, + dispatch, + ] = useReducer(exceptionsBuilderReducer(), { + ...initialState, + disableAnd: isAndDisabled, + disableOr: isOrDisabled, + }); + + const setUpdateExceptions = useCallback( + (items: ExceptionsBuilderExceptionItem[]): void => { + dispatch({ + type: 'setExceptions', + exceptions: items, + }); + }, + [dispatch] ); - const [exceptionsToDelete, setExceptionsToDelete] = useState([]); - const handleCheckAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => { - setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0); - }; + const setDefaultExceptions = useCallback( + (item: ExceptionsBuilderExceptionItem): void => { + dispatch({ + type: 'setDefault', + initialState, + lastException: item, + }); + }, + [dispatch] + ); - const handleDeleteExceptionItem = ( - item: ExceptionsBuilderExceptionItem, - itemIndex: number - ): void => { - if (item.entries.length === 0) { - if (exceptionListItemSchema.is(item)) { - setExceptionsToDelete((items) => [...items, item]); - } + const setUpdateExceptionsToDelete = useCallback( + (items: ExceptionListItemSchema[]): void => { + dispatch({ + type: 'setExceptionsToDelete', + exceptions: items, + }); + }, + [dispatch] + ); - setExceptions((existingExceptions) => { - const updatedExceptions = [ - ...existingExceptions.slice(0, itemIndex), - ...existingExceptions.slice(itemIndex + 1), - ]; - handleCheckAndLogic(updatedExceptions); + const setUpdateAndDisabled = useCallback( + (shouldDisable: boolean): void => { + dispatch({ + type: 'setDisableAnd', + shouldDisable, + }); + }, + [dispatch] + ); - return updatedExceptions; + const setUpdateOrDisabled = useCallback( + (shouldDisable: boolean): void => { + dispatch({ + type: 'setDisableOr', + shouldDisable, }); - } else { - handleExceptionItemChange(item, itemIndex); - } - }; + }, + [dispatch] + ); - const handleExceptionItemChange = (item: ExceptionsBuilderExceptionItem, index: number): void => { - const updatedExceptions = [ - ...exceptions.slice(0, index), - { - ...item, - }, - ...exceptions.slice(index + 1), - ]; - - handleCheckAndLogic(updatedExceptions); - setExceptions(updatedExceptions); - }; + const setUpdateAddNested = useCallback( + (shouldAddNested: boolean): void => { + dispatch({ + type: 'setAddNested', + addNested: shouldAddNested, + }); + }, + [dispatch] + ); + + const handleExceptionItemChange = useCallback( + (item: ExceptionsBuilderExceptionItem, index: number): void => { + const updatedExceptions = [ + ...exceptions.slice(0, index), + { + ...item, + }, + ...exceptions.slice(index + 1), + ]; + + setUpdateExceptions(updatedExceptions); + }, + [setUpdateExceptions, exceptions] + ); + + const handleDeleteExceptionItem = useCallback( + (item: ExceptionsBuilderExceptionItem, itemIndex: number): void => { + if (item.entries.length === 0) { + const updatedExceptions = [ + ...exceptions.slice(0, itemIndex), + ...exceptions.slice(itemIndex + 1), + ]; - const handleAddNewExceptionItemEntry = useCallback((): void => { - setExceptions((existingExceptions): ExceptionsBuilderExceptionItem[] => { - const lastException = existingExceptions[existingExceptions.length - 1]; + // if it's the only exception item left, don't delete it + // just add a default entry to it + if (updatedExceptions.length === 0) { + setDefaultExceptions(item); + } else if (updatedExceptions.length > 0 && exceptionListItemSchema.is(item)) { + setUpdateExceptionsToDelete([...exceptionsToDelete, item]); + } else { + setUpdateExceptions([ + ...exceptions.slice(0, itemIndex), + ...exceptions.slice(itemIndex + 1), + ]); + } + } else { + handleExceptionItemChange(item, itemIndex); + } + }, + [ + handleExceptionItemChange, + setUpdateExceptions, + setUpdateExceptionsToDelete, + exceptions, + exceptionsToDelete, + setDefaultExceptions, + ] + ); + + const handleAddNewExceptionItemEntry = useCallback( + (isNested = false): void => { + const lastException = exceptions[exceptions.length - 1]; const { entries } = lastException; + const updatedException: ExceptionsBuilderExceptionItem = { ...lastException, - entries: [ - ...entries, - { field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '' }, - ], + entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - setAndLogicIncluded(updatedException.entries.length > 1); + // setAndLogicIncluded(updatedException.entries.length > 1); - return [ - ...existingExceptions.slice(0, existingExceptions.length - 1), - { ...updatedException }, - ]; - }); - }, [setExceptions, setAndLogicIncluded]); + setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); + }, + [setUpdateExceptions, exceptions] + ); const handleAddNewExceptionItem = useCallback((): void => { // There is a case where there are numerous exception list items, all with @@ -144,8 +227,8 @@ export const ExceptionBuilder = ({ namespaceType: listNamespaceType, ruleName, }); - setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]); - }, [setExceptions, listType, listId, listNamespaceType, ruleName]); + setUpdateExceptions([...exceptions, { ...newException }]); + }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]); // Filters index pattern fields by exceptionable fields if list type is endpoint const filterIndexPatterns = useMemo((): IIndexPattern => { @@ -172,6 +255,55 @@ export const ExceptionBuilder = ({ } }; + const handleAddNestedExceptionItemEntry = useCallback((): void => { + const lastException = exceptions[exceptions.length - 1]; + const { entries } = lastException; + const lastEntry = entries[entries.length - 1]; + + if (entriesNested.is(lastEntry)) { + const updatedException: ExceptionsBuilderExceptionItem = { + ...lastException, + entries: [ + ...entries.slice(0, entries.length - 1), + { + ...lastEntry, + entries: [ + ...lastEntry.entries, + { + field: '', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: '', + }, + ], + }, + ], + }; + + setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); + } else { + setUpdateExceptions(exceptions); + } + }, [setUpdateExceptions, exceptions]); + + const handleAddNestedClick = useCallback((): void => { + setUpdateAddNested(true); + setUpdateOrDisabled(true); + setUpdateAndDisabled(true); + handleAddNewExceptionItemEntry(true); + }, [ + handleAddNewExceptionItemEntry, + setUpdateAndDisabled, + setUpdateOrDisabled, + setUpdateAddNested, + ]); + + const handleAddClick = useCallback((): void => { + setUpdateAddNested(false); + setUpdateOrDisabled(false); + handleAddNewExceptionItemEntry(); + }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]); + // Bubble up changes to parent useEffect(() => { onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete }); @@ -188,6 +320,13 @@ export const ExceptionBuilder = ({ } }, [exceptions, handleAddNewExceptionItem]); + useEffect(() => { + if (exceptionListItems.length > 0) { + setUpdateExceptions(exceptionListItems); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( {exceptions.map((exceptionListItem, index) => ( @@ -216,7 +355,8 @@ export const ExceptionBuilder = ({ exceptionItem={exceptionListItem} exceptionId={getExceptionListItemId(exceptionListItem, index)} indexPattern={filterIndexPatterns} - isLoading={indexPatterns.fields.length === 0} + listType={listType} + addNested={addNested} exceptionItemIndex={index} andLogicIncluded={andLogicIncluded} isOnlyItem={exceptions.length === 1} @@ -237,12 +377,15 @@ export const ExceptionBuilder = ({ )} {}} + onAndClicked={handleAddClick} + onNestedClicked={handleAddNestedClick} + onAddClickWhenNested={handleAddNestedExceptionItemEntry} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts new file mode 100644 index 0000000000000..045ff458755b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ExceptionsBuilderExceptionItem } from '../types'; +import { ExceptionListItemSchema } from '../../../../../public/lists_plugin_deps'; +import { getDefaultEmptyEntry } from './helpers'; + +export type ViewerModalName = 'addModal' | 'editModal' | null; + +export interface State { + disableAnd: boolean; + disableOr: boolean; + andLogicIncluded: boolean; + addNested: boolean; + exceptions: ExceptionsBuilderExceptionItem[]; + exceptionsToDelete: ExceptionListItemSchema[]; +} + +export type Action = + | { + type: 'setExceptions'; + exceptions: ExceptionsBuilderExceptionItem[]; + } + | { + type: 'setExceptionsToDelete'; + exceptions: ExceptionListItemSchema[]; + } + | { + type: 'setDefault'; + initialState: State; + lastException: ExceptionsBuilderExceptionItem; + } + | { + type: 'setDisableAnd'; + shouldDisable: boolean; + } + | { + type: 'setDisableOr'; + shouldDisable: boolean; + } + | { + type: 'setAddNested'; + addNested: boolean; + }; + +export const exceptionsBuilderReducer = () => (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptions': { + const isAndLogicIncluded = + action.exceptions.filter(({ entries }) => entries.length > 1).length > 0; + const lastExceptionItem = action.exceptions.slice(-1)[0]; + const isAddNested = + lastExceptionItem != null + ? lastExceptionItem.entries.slice(-1).filter(({ type }) => type === 'nested').length > 0 + : false; + const lastEntry = lastExceptionItem != null ? lastExceptionItem.entries.slice(-1)[0] : null; + const isAndDisabled = + lastEntry != null && lastEntry.type === 'nested' && lastEntry.entries.length === 0; + const isOrDisabled = lastEntry != null && lastEntry.type === 'nested'; + + return { + ...state, + andLogicIncluded: isAndLogicIncluded, + exceptions: action.exceptions, + addNested: isAddNested, + disableAnd: isAndDisabled, + disableOr: isOrDisabled, + }; + } + case 'setDefault': { + return { + ...state, + ...action.initialState, + exceptions: [{ ...action.lastException, entries: [getDefaultEmptyEntry()] }], + }; + } + case 'setExceptionsToDelete': { + return { + ...state, + exceptionsToDelete: action.exceptions, + }; + } + case 'setDisableAnd': { + return { + ...state, + disableAnd: action.shouldDisable, + }; + } + case 'setDisableOr': { + return { + ...state, + disableOr: action.shouldDisable, + }; + } + case 'setAddNested': { + return { + ...state, + addNested: action.addNested, + }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts new file mode 100644 index 0000000000000..82cca2596da61 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const FIELD = i18n.translate('xpack.securitySolution.exceptions.builder.fieldDescription', { + defaultMessage: 'Field', +}); + +export const OPERATOR = i18n.translate( + 'xpack.securitySolution.exceptions.builder.operatorDescription', + { + defaultMessage: 'Operator', + } +); + +export const VALUE = i18n.translate('xpack.securitySolution.exceptions.builder.valueDescription', { + defaultMessage: 'Value', +}); + +export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldPlaceholderDescription', + { + defaultMessage: 'Search', + } +); + +export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldNestedPlaceholderDescription', + { + defaultMessage: 'Search nested field', + } +); + +export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionOperatorPlaceholderDescription', + { + defaultMessage: 'Operator', + } +); + +export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldValuePlaceholderDescription', + { + defaultMessage: 'Search field value...', + } +); + +export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionListsPlaceholderDescription', + { + defaultMessage: 'Search for list...', + } +); + +export const ADD_NESTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.builder.addNestedDescription', + { + defaultMessage: 'Add nested condition', + } +); + +export const ADD_NON_NESTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.builder.addNonNestedDescription', + { + defaultMessage: 'Add non-nested condition', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 2d12cfbec160a..4ad077edf66ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -219,6 +219,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ruleName={ruleName} isOrDisabled={false} isAndDisabled={false} + isNestedDisabled={false} data-test-subj="edit-exception-modal-builder" id-aria="edit-exception-modal-builder" onChange={handleBuilderOnChange} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index dace2eb5f0672..78936d5d0da6f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -10,11 +10,8 @@ import moment from 'moment-timezone'; import { getOperatorType, getExceptionOperatorSelect, - getFormattedEntries, - formatEntry, getOperatingSystems, getTagsInclude, - getDescriptionListContent, getFormattedComments, filterExceptionItems, getNewExceptionItem, @@ -27,7 +24,7 @@ import { entryHasNonEcsType, prepareExceptionItemsForBulkClose, } from './helpers'; -import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types'; +import { EmptyEntry } from './types'; import { isOperator, isNotOperator, @@ -45,7 +42,6 @@ import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/ import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock'; import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock'; -import { getEntriesArrayMock } from '../../../../../lists/common/schemas/types/entries.mock'; import { ENTRIES } from '../../../../../lists/common/constants.mock'; import { CreateExceptionListItemSchema, @@ -155,112 +151,6 @@ describe('Exception helpers', () => { }); }); - describe('#getFormattedEntries', () => { - test('it returns empty array if no entries passed', () => { - const result = getFormattedEntries([]); - - expect(result).toEqual([]); - }); - - test('it formats nested entries as expected', () => { - const payload = [getEntryMatchMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats "exists" entries as expected', () => { - const payload = [getEntryExistsMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'exists', - value: undefined, - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats non-nested entries as expected', () => { - const payload = [getEntryMatchAnyMock(), getEntryMatchMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is one of', - value: ['some host name'], - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats a mix of nested and non-nested entries as expected', () => { - const payload = getEntriesArrayMock(); - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is one of', - value: ['some host name'], - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is in list', - value: 'some-list-id', - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'exists', - value: undefined, - }, - { - fieldName: 'host.name', - isNested: false, - operator: undefined, - value: undefined, - }, - { - fieldName: 'host.name.host.name', - isNested: true, - operator: 'is', - value: 'some host name', - }, - { - fieldName: 'host.name.host.name', - isNested: true, - operator: 'is one of', - value: ['some host name'], - }, - ]; - expect(result).toEqual(expected); - }); - }); - describe('#getEntryValue', () => { it('returns "match" entry value', () => { const payload = getEntryMatchMock(); @@ -291,34 +181,6 @@ describe('Exception helpers', () => { }); }); - describe('#formatEntry', () => { - test('it formats an entry', () => { - const payload = getEntryMatchMock(); - const formattedEntry = formatEntry({ isNested: false, item: payload }); - const expected: FormattedEntry = { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }; - - expect(formattedEntry).toEqual(expected); - }); - - test('it formats as expected when "isNested" is "true"', () => { - const payload = getEntryMatchMock(); - const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); - const expected: FormattedEntry = { - fieldName: 'parent.host.name', - isNested: true, - operator: 'is', - value: 'some host name', - }; - - expect(formattedEntry).toEqual(expected); - }); - }); - describe('#getOperatingSystems', () => { test('it returns null if no operating system tag specified', () => { const result = getOperatingSystems(['some tag', 'some other tag']); @@ -389,72 +251,6 @@ describe('Exception helpers', () => { }); }); - describe('#getDescriptionListContent', () => { - test('it returns formatted description list with os if one is specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload.description = ''; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'Linux', - title: 'OS', - }, - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - ]; - - expect(result).toEqual(expected); - }); - - test('it returns formatted description list with a description if one specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload._tags = []; - payload.description = 'Im a description'; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - { - description: 'Im a description', - title: 'Comment', - }, - ]; - - expect(result).toEqual(expected); - }); - - test('it returns just user and date created if no other fields specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload._tags = []; - payload.description = ''; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - ]; - - expect(result).toEqual(expected); - }); - }); - describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { const payload = getCommentsArrayMock(); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 4d8fc5f68870b..384badefc34aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -12,10 +12,7 @@ import uuid from 'uuid'; import * as i18n from './translations'; import { - FormattedEntry, BuilderEntry, - DescriptionListItem, - FormattedBuilderEntry, CreateExceptionListItemBuilderSchema, ExceptionsBuilderExceptionItem, } from './types'; @@ -38,7 +35,7 @@ import { ExceptionListType, EntryNested, } from '../../../lists_plugin_deps'; -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { validate } from '../../../../common/validate'; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -68,7 +65,7 @@ export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { * @param item a single ExceptionItem entry */ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => { - if (entriesNested.is(item)) { + if (item.type === 'nested') { return isOperator; } else { const operatorType = getOperatorType(item); @@ -81,39 +78,10 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption = }; /** - * Formats ExceptionItem entries into simple field, operator, value - * for use in rendering items in table + * Returns the fields corresponding value for an entry * - * @param entries an ExceptionItem's entries + * @param item a single ExceptionItem entry */ -export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => { - const formattedEntries = entries.map((item) => { - if (entriesNested.is(item)) { - const parent = { - fieldName: item.field, - operator: undefined, - value: undefined, - isNested: false, - }; - return item.entries.reduce( - (acc, nestedEntry) => { - const formattedEntry = formatEntry({ - isNested: true, - parent: item.field, - item: nestedEntry, - }); - return [...acc, { ...formattedEntry }]; - }, - [parent] - ); - } else { - return formatEntry({ isNested: false, item }); - } - }); - - return formattedEntries.flat(); -}; - export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => { switch (item.type) { case OperatorTypeEnum.MATCH: @@ -128,29 +96,6 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined } }; -/** - * Helper method for `getFormattedEntries` - */ -export const formatEntry = ({ - isNested, - parent, - item, -}: { - isNested: boolean; - parent?: string; - item: BuilderEntry; -}): FormattedEntry => { - const operator = getExceptionOperatorSelect(item); - const value = getEntryValue(item); - - return { - fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '', - operator: operator.message, - value, - isNested, - }; -}; - /** * Retrieves the values of tags marked as os * @@ -189,42 +134,6 @@ export const getTagsInclude = ({ return [matches != null, match]; }; -/** - * Formats ExceptionItem information for description list component - * - * @param exceptionItem an ExceptionItem - */ -export const getDescriptionListContent = ( - exceptionItem: ExceptionListItemSchema -): DescriptionListItem[] => { - const details = [ - { - title: i18n.OPERATING_SYSTEM, - value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), - }, - { - title: i18n.DATE_CREATED, - value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'), - }, - { - title: i18n.CREATED_BY, - value: exceptionItem.created_by, - }, - { - title: i18n.COMMENT, - value: exceptionItem.description, - }, - ]; - - return details.reduce((acc, { value, title }) => { - if (value != null && value.trim() !== '') { - return [...acc, { title, description: value }]; - } else { - return acc; - } - }, []); -}; - /** * Formats ExceptionItem.comments into EuiCommentList format * @@ -246,69 +155,6 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] ), })); -export const getFormattedBuilderEntries = ( - indexPattern: IIndexPattern, - entries: BuilderEntry[] -): FormattedBuilderEntry[] => { - const { fields } = indexPattern; - return entries.map((item) => { - if (entriesNested.is(item)) { - return { - parent: item.field, - operator: isOperator, - nested: getFormattedBuilderEntries(indexPattern, item.entries), - field: undefined, - value: undefined, - }; - } else { - const [selectedField] = fields.filter( - ({ name }) => item.field != null && item.field === name - ); - return { - field: selectedField, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - }; - } - }); -}; - -export const getValueFromOperator = ( - field: IFieldType | undefined, - selectedOperator: OperatorOption -): Entry => { - const fieldValue = field != null ? field.name : ''; - switch (selectedOperator.type) { - case 'match': - return { - field: fieldValue, - type: OperatorTypeEnum.MATCH, - operator: selectedOperator.operator, - value: '', - }; - case 'match_any': - return { - field: fieldValue, - type: OperatorTypeEnum.MATCH_ANY, - operator: selectedOperator.operator, - value: [], - }; - case 'list': - return { - field: fieldValue, - type: OperatorTypeEnum.LIST, - operator: selectedOperator.operator, - list: { id: '', type: 'ip' }, - }; - default: - return { - field: fieldValue, - type: OperatorTypeEnum.EXISTS, - operator: selectedOperator.operator, - }; - } -}; - export const getNewExceptionItem = ({ listType, listId, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 870f98f63ee2c..87d2f9dcda935 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -151,34 +151,6 @@ export const VALUE = i18n.translate('xpack.securitySolution.exceptions.valueDesc defaultMessage: 'Value', }); -export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionFieldPlaceholderDescription', - { - defaultMessage: 'Search', - } -); - -export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionOperatorPlaceholderDescription', - { - defaultMessage: 'Operator', - } -); - -export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionFieldValuePlaceholderDescription', - { - defaultMessage: 'Search field value...', - } -); - -export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionListsPlaceholderDescription', - { - defaultMessage: 'Search for list...', - } -); - export const AND = i18n.translate('xpack.securitySolution.exceptions.andDescription', { defaultMessage: 'AND', }); @@ -187,13 +159,6 @@ export const OR = i18n.translate('xpack.securitySolution.exceptions.orDescriptio defaultMessage: 'OR', }); -export const ADD_NESTED_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addNestedDescription', - { - defaultMessage: 'Add nested condition', - } -); - export const ADD_COMMENT_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder', { @@ -207,3 +172,7 @@ export const ADD_TO_CLIPBOARD = i18n.translate( defaultMessage: 'Add to clipboard', } ); + +export const DESCRIPTION = i18n.translate('xpack.securitySolution.exceptions.descriptionLabel', { + defaultMessage: 'Description', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 994aed3952cf0..54caab03e615a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -9,6 +9,9 @@ import { OperatorOption } from '../autocomplete/types'; import { EntryNested, Entry, + EntryMatch, + EntryMatchAny, + EntryExists, ExceptionListItemSchema, CreateExceptionListItemSchema, NamespaceType, @@ -52,15 +55,13 @@ export interface ExceptionsPagination { pageSizeOptions: number[]; } -export interface FormattedBuilderEntryBase { +export interface FormattedBuilderEntry { field: IFieldType | undefined; operator: OperatorOption; value: string | string[] | undefined; -} - -export interface FormattedBuilderEntry extends FormattedBuilderEntryBase { - parent?: string; - nested?: FormattedBuilderEntryBase[]; + nested: 'parent' | 'child' | undefined; + entryIndex: number; + parent: { parent: EntryNested; parentIndex: number } | undefined; } export interface EmptyEntry { @@ -77,7 +78,13 @@ export interface EmptyListEntry { list: { id: string | undefined; type: string | undefined }; } -export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested; +export interface EmptyNestedEntry { + field: string | undefined; + type: OperatorTypeEnum.NESTED; + entries: Array; +} + +export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry; export type ExceptionListItemBuilderSchema = Omit & { entries: BuilderEntry[]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index 4fc744c2c9d01..8df7b51bb9d31 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -211,7 +211,7 @@ describe('ExceptionDetails', () => { ); - expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Comment'); + expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Description'); expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual('some description'); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 44632236ea7a0..cca7d76899a19 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -16,7 +16,7 @@ import React, { useMemo, Fragment } from 'react'; import styled, { css } from 'styled-components'; import { DescriptionListItem } from '../../types'; -import { getDescriptionListContent } from '../../helpers'; +import { getDescriptionListContent } from '../helpers'; import * as i18n from '../../translations'; import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx index 3b85c6741a480..13a90091ba4c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx @@ -17,7 +17,8 @@ import styled from 'styled-components'; import { ExceptionDetails } from './exception_details'; import { ExceptionEntries } from './exception_entries'; -import { getFormattedEntries, getFormattedComments } from '../../helpers'; +import { getFormattedComments } from '../../helpers'; +import { getFormattedEntries } from '../helpers'; import { FormattedEntry, ExceptionListItemIdentifiers } from '../../types'; import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx new file mode 100644 index 0000000000000..fe00e3530fa83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment-timezone'; + +import { getFormattedEntries, formatEntry, getDescriptionListContent } from './helpers'; +import { FormattedEntry, DescriptionListItem } from '../types'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getEntriesArrayMock } from '../../../../../../lists/common/schemas/types/entries.mock'; +import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock'; + +describe('Exception viewer helpers', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + describe('#getFormattedEntries', () => { + test('it returns empty array if no entries passed', () => { + const result = getFormattedEntries([]); + + expect(result).toEqual([]); + }); + + test('it formats nested entries as expected', () => { + const payload = [getEntryMatchMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats "exists" entries as expected', () => { + const payload = [getEntryExistsMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'exists', + value: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats non-nested entries as expected', () => { + const payload = [getEntryMatchAnyMock(), getEntryMatchMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is one of', + value: ['some host name'], + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats a mix of nested and non-nested entries as expected', () => { + const payload = getEntriesArrayMock(); + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is one of', + value: ['some host name'], + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is in list', + value: 'some-list-id', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'exists', + value: undefined, + }, + { + fieldName: 'host.name', + isNested: false, + operator: undefined, + value: undefined, + }, + { + fieldName: 'host.name.host.name', + isNested: true, + operator: 'is', + value: 'some host name', + }, + { + fieldName: 'host.name.host.name', + isNested: true, + operator: 'is one of', + value: ['some host name'], + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#formatEntry', () => { + test('it formats an entry', () => { + const payload = getEntryMatchMock(); + const formattedEntry = formatEntry({ isNested: false, item: payload }); + const expected: FormattedEntry = { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }; + + expect(formattedEntry).toEqual(expected); + }); + + test('it formats as expected when "isNested" is "true"', () => { + const payload = getEntryMatchMock(); + const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); + const expected: FormattedEntry = { + fieldName: 'parent.host.name', + isNested: true, + operator: 'is', + value: 'some host name', + }; + + expect(formattedEntry).toEqual(expected); + }); + }); + + describe('#getDescriptionListContent', () => { + test('it returns formatted description list with os if one is specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'Linux', + title: 'OS', + }, + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns formatted description list with a description if one specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload._tags = []; + payload.description = 'Im a description'; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + { + description: 'Im a description', + title: 'Description', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns just user and date created if no other fields specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload._tags = []; + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx new file mode 100644 index 0000000000000..345db5bf1e75e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment'; + +import { entriesNested, ExceptionListItemSchema } from '../../../../lists_plugin_deps'; +import { + getEntryValue, + getExceptionOperatorSelect, + formatOperatingSystems, + getOperatingSystems, +} from '../helpers'; +import { FormattedEntry, BuilderEntry, DescriptionListItem } from '../types'; +import * as i18n from '../translations'; + +/** + * Helper method for `getFormattedEntries` + */ +export const formatEntry = ({ + isNested, + parent, + item, +}: { + isNested: boolean; + parent?: string; + item: BuilderEntry; +}): FormattedEntry => { + const operator = getExceptionOperatorSelect(item); + const value = getEntryValue(item); + + return { + fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '', + operator: operator.message, + value, + isNested, + }; +}; + +/** + * Formats ExceptionItem entries into simple field, operator, value + * for use in rendering items in table + * + * @param entries an ExceptionItem's entries + */ +export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => { + const formattedEntries = entries.map((item) => { + if (entriesNested.is(item)) { + const parent = { + fieldName: item.field, + operator: undefined, + value: undefined, + isNested: false, + }; + return item.entries.reduce( + (acc, nestedEntry) => { + const formattedEntry = formatEntry({ + isNested: true, + parent: item.field, + item: nestedEntry, + }); + return [...acc, { ...formattedEntry }]; + }, + [parent] + ); + } else { + return formatEntry({ isNested: false, item }); + } + }); + + return formattedEntries.flat(); +}; + +/** + * Formats ExceptionItem details for description list component + * + * @param exceptionItem an ExceptionItem + */ +export const getDescriptionListContent = ( + exceptionItem: ExceptionListItemSchema +): DescriptionListItem[] => { + const details = [ + { + title: i18n.OPERATING_SYSTEM, + value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), + }, + { + title: i18n.DATE_CREATED, + value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'), + }, + { + title: i18n.CREATED_BY, + value: exceptionItem.created_by, + }, + { + title: i18n.DESCRIPTION, + value: exceptionItem.description, + }, + ]; + + return details.reduce((acc, { value, title }) => { + if (value != null && value.trim() !== '') { + return [...acc, { title, description: value }]; + } else { + return acc; + } + }, []); +};