diff --git a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx index dcdba80c268d7..59ff70f60d594 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/index.test.tsx @@ -114,4 +114,30 @@ describe('FieldComponent', () => { expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('_source') ); }); + + it('it allows custom user input if "acceptsCustomOptions" is "true"', async () => { + const mockOnChange = jest.fn(); + const wrapper = render( + + ); + + const fieldAutocompleteComboBox = wrapper.getByTestId('comboBoxSearchInput'); + fireEvent.change(fieldAutocompleteComboBox, { target: { value: 'custom' } }); + await waitFor(() => + expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('custom') + ); + }); }); diff --git a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts index 68748bf82a20f..d060f585e9118 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/field/__tests__/use_field.test.ts @@ -346,6 +346,18 @@ describe('useField', () => { ]); }); }); + it('should invoke onChange with custom option if one is sent', () => { + const { result } = renderHook(() => useField({ indexPattern, onChange: onChangeMock })); + act(() => { + result.current.handleCreateCustomOption('madeUpField'); + expect(onChangeMock).toHaveBeenCalledWith([ + { + name: 'madeUpField', + type: 'text', + }, + ]); + }); + }); }); describe('fieldWidth', () => { diff --git a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx index dad13434779e7..704433b79560b 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx @@ -25,6 +25,7 @@ export const FieldComponent: React.FC = ({ onChange, placeholder, selectedField, + acceptsCustomOptions = false, }): JSX.Element => { const { isInvalid, @@ -35,6 +36,7 @@ export const FieldComponent: React.FC = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, } = useField({ indexPattern, fieldTypeFilter, @@ -43,6 +45,29 @@ export const FieldComponent: React.FC = ({ fieldInputWidth, onChange, }); + + if (acceptsCustomOptions) { + return ( + + ); + } + return ( { const [touched, setIsTouched] = useState(false); - const { availableFields, selectedFields } = useMemo( - () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), - [indexPattern, fieldTypeFilter, selectedField] - ); + const [customOption, setCustomOption] = useState(null); + + const { availableFields, selectedFields } = useMemo(() => { + const indexPatternsToUse = + customOption != null && indexPattern != null + ? { ...indexPattern, fields: [...indexPattern?.fields, customOption] } + : indexPattern; + return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter); + }, [indexPattern, fieldTypeFilter, selectedField, customOption]); const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo( () => getComboBoxProps({ availableFields, selectedFields }), @@ -117,6 +122,19 @@ export const useField = ({ [availableFields, labels, onChange] ); + const handleCreateCustomOption = useCallback( + (val: string) => { + const normalizedSearchValue = val.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + setCustomOption({ name: val, type: 'text' }); + onChange([{ name: val, type: 'text' }]); + }, + [onChange] + ); + const handleTouch = useCallback((): void => { setIsTouched(true); }, [setIsTouched]); @@ -161,5 +179,6 @@ export const useField = ({ renderFields, handleTouch, handleValuesChange, + handleCreateCustomOption, }; }; diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 6f4bc7d51052f..945afbbc8604e 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -14,7 +14,6 @@ import { EntriesArray, Entry, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListSchema, NamespaceType, @@ -27,6 +26,8 @@ import { entry, exceptionListItemSchema, nestedEntryItem, + CreateRuleExceptionListItemSchema, + createRuleExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { DataViewBase, @@ -55,6 +56,7 @@ import { EmptyEntry, EmptyNestedEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, } from '../types'; @@ -65,59 +67,60 @@ export const isEntryNested = (item: BuilderEntry): item is EntryNested => { export const filterExceptionItems = ( exceptions: ExceptionsBuilderExceptionItem[] -): Array => { - return exceptions.reduce>( - (acc, exception) => { - const entries = exception.entries.reduce((nestedAcc, singleEntry) => { - const strippedSingleEntry = removeIdFromItem(singleEntry); - - if (entriesNested.is(strippedSingleEntry)) { - const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { - const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); - const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); - return validatedNestedEntry != null; - }); - const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => - removeIdFromItem(singleNestedEntry) - ); - - const [validatedNestedEntry] = validate( - { ...strippedSingleEntry, entries: noIdNestedEntries }, - entriesNested - ); - - if (validatedNestedEntry != null) { - return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; - } - return nestedAcc; - } else { - const [validatedEntry] = validate(strippedSingleEntry, entry); - - if (validatedEntry != null) { - return [...nestedAcc, singleEntry]; - } - return nestedAcc; +): ExceptionsBuilderReturnExceptionItem[] => { + return exceptions.reduce((acc, exception) => { + const entries = exception.entries.reduce((nestedAcc, singleEntry) => { + const strippedSingleEntry = removeIdFromItem(singleEntry); + if (entriesNested.is(strippedSingleEntry)) { + const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => { + const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry); + const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem); + return validatedNestedEntry != null; + }); + const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) => + removeIdFromItem(singleNestedEntry) + ); + + const [validatedNestedEntry] = validate( + { ...strippedSingleEntry, entries: noIdNestedEntries }, + entriesNested + ); + + if (validatedNestedEntry != null) { + return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }]; } - }, []); - - if (entries.length === 0) { - return acc; + return nestedAcc; + } else { + const [validatedEntry] = validate(strippedSingleEntry, entry); + if (validatedEntry != null) { + return [...nestedAcc, singleEntry]; + } + return nestedAcc; } + }, []); - const item = { ...exception, entries }; + if (entries.length === 0) { + return acc; + } - if (exceptionListItemSchema.is(item)) { - return [...acc, item]; - } else if (createExceptionListItemSchema.is(item)) { - const { meta, ...rest } = item; - const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined }; - return [...acc, itemSansMetaId]; - } else { - return acc; - } - }, - [] - ); + const item = { ...exception, entries }; + + if (exceptionListItemSchema.is(item)) { + return [...acc, item]; + } else if ( + createExceptionListItemSchema.is(item) || + createRuleExceptionListItemSchema.is(item) + ) { + const { meta, ...rest } = item; + const itemSansMetaId: CreateExceptionListItemSchema | CreateRuleExceptionListItemSchema = { + ...rest, + meta: undefined, + }; + return [...acc, itemSansMetaId]; + } else { + return acc; + } + }, []); }; export const addIdToEntries = (entries: EntriesArray): EntriesArray => { @@ -136,15 +139,15 @@ export const addIdToEntries = (entries: EntriesArray): EntriesArray => { export const getNewExceptionItem = ({ listId, namespaceType, - ruleName, + name, }: { listId: string | undefined; namespaceType: NamespaceType | undefined; - ruleName: string; + name: string; }): CreateExceptionListItemBuilderSchema => { return { comments: [], - description: 'Exception list item', + description: `Exception list item`, entries: addIdToEntries([ { field: '', @@ -158,7 +161,7 @@ export const getNewExceptionItem = ({ meta: { temporaryUuid: uuid.v4(), }, - name: `${ruleName} - exception list item`, + name, namespace_type: namespaceType, tags: [], type: 'simple', @@ -769,13 +772,15 @@ export const getCorrespondingKeywordField = ({ * @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 + * @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not */ export const getFormattedBuilderEntry = ( indexPattern: DataViewBase, item: BuilderEntry, itemIndex: number, parent: EntryNested | undefined, - parentIndex: number | undefined + parentIndex: number | undefined, + allowCustomFieldOptions: boolean ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; @@ -800,10 +805,14 @@ export const getFormattedBuilderEntry = ( value: getEntryValue(item), }; } else { + const fieldToUse = allowCustomFieldOptions + ? foundField ?? { name: item.field, type: 'keyword' } + : foundField; + return { correspondingKeywordField, entryIndex: itemIndex, - field: foundField, + field: fieldToUse, id: item.id != null ? item.id : `${itemIndex}`, nested: undefined, operator: getExceptionOperatorSelect(item), @@ -819,8 +828,7 @@ export const getFormattedBuilderEntry = ( * * @param patterns DataViewBase 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 allowCustomFieldOptions determines if field must be found to match in indexPattern or not * @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 @@ -828,6 +836,7 @@ export const getFormattedBuilderEntry = ( export const getFormattedBuilderEntries = ( indexPattern: DataViewBase, entries: BuilderEntry[], + allowCustomFieldOptions: boolean, parent?: EntryNested, parentIndex?: number ): FormattedBuilderEntry[] => { @@ -839,7 +848,8 @@ export const getFormattedBuilderEntries = ( item, index, parent, - parentIndex + parentIndex, + allowCustomFieldOptions ); return [...acc, newItemEntry]; } else { @@ -869,7 +879,13 @@ export const getFormattedBuilderEntries = ( } if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + const nestedItems = getFormattedBuilderEntries( + indexPattern, + item.entries, + allowCustomFieldOptions, + item, + index + ); return [...acc, parentEntry, ...nestedItems]; } diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 6d6fed5181864..b272b5ec3e362 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -31,6 +31,7 @@ import { ReactWrapper, mount } from 'enzyme'; import { getFoundListsBySizeSchemaMock } from '../../../../common/schemas/response/found_lists_by_size_schema.mock'; import { BuilderEntryItem } from './entry_renderer'; +import * as i18n from './translations'; jest.mock('@kbn/securitysolution-list-hooks'); jest.mock('@kbn/securitysolution-utils'); @@ -81,11 +82,78 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).not.toEqual(0); + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); + }); + + test('it renders custom option text if "allowCustomOptions" is "true" and it is not a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').at(0).text()).toEqual( + i18n.CUSTOM_COMBOBOX_OPTION_TEXT + ); + }); + + test('it does not render custom option text when "allowCustomOptions" is "true" and it is a nested entry', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); }); test('it renders field values correctly when operator is "isOperator"', () => { @@ -259,7 +327,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); @@ -297,7 +365,7 @@ describe('BuilderEntryItem', () => { onChange={jest.fn()} setErrorsExist={jest.fn()} setWarningsExist={jest.fn()} - showLabel={true} + showLabel /> ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 8f0bc15bd7da6..2df0b4b41a2f1 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -75,6 +75,7 @@ export interface EntryItemProps { setWarningsExist: (arg: boolean) => void; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderEntryItem: React.FC = ({ @@ -93,6 +94,7 @@ export const BuilderEntryItem: React.FC = ({ showLabel, isDisabled = false, operatorsList, + allowCustomOptions = false, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -163,9 +165,9 @@ export const BuilderEntryItem: React.FC = ({ const isFieldComponentDisabled = useMemo( (): boolean => isDisabled || - indexPattern == null || - (indexPattern != null && indexPattern.fields.length === 0), - [isDisabled, indexPattern] + (!allowCustomOptions && + (indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0))), + [isDisabled, indexPattern, allowCustomOptions] ); const renderFieldInput = useCallback( @@ -190,6 +192,7 @@ export const BuilderEntryItem: React.FC = ({ isLoading={false} isDisabled={isDisabled || indexPattern == null} onChange={handleFieldChange} + acceptsCustomOptions={entry.nested == null} data-test-subj="exceptionBuilderEntryField" /> ); @@ -199,6 +202,11 @@ export const BuilderEntryItem: React.FC = ({ {comboBox} @@ -206,7 +214,16 @@ export const BuilderEntryItem: React.FC = ({ ); } else { return ( - + {comboBox} ); @@ -220,6 +237,7 @@ export const BuilderEntryItem: React.FC = ({ handleFieldChange, osTypes, isDisabled, + allowCustomOptions, ] ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 84c18baf51569..d891c1a5eea08 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -63,6 +63,7 @@ interface BuilderExceptionListItemProps { onlyShowListOperators?: boolean; isDisabled?: boolean; operatorsList?: OperatorOption[]; + allowCustomOptions?: boolean; } export const BuilderExceptionListItemComponent = React.memo( @@ -85,6 +86,7 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -117,9 +119,9 @@ export const BuilderExceptionListItemComponent = React.memo { const hasIndexPatternAndEntries = indexPattern != null && exceptionItem.entries.length > 0; return hasIndexPatternAndEntries - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries, allowCustomOptions) : []; - }, [exceptionItem.entries, indexPattern]); + }, [exceptionItem.entries, indexPattern, allowCustomOptions]); return ( @@ -157,6 +159,7 @@ export const BuilderExceptionListItemComponent = React.memo DataViewBase; onChange: (arg: OnChangeProps) => void; - exceptionItemName?: string; ruleName?: string; isDisabled?: boolean; operatorsList?: OperatorOption[]; + exceptionItemName?: string; + allowCustomFieldOptions?: boolean; } export const ExceptionBuilderComponent = ({ @@ -118,6 +119,7 @@ export const ExceptionBuilderComponent = ({ isDisabled = false, osTypes, operatorsList, + allowCustomFieldOptions = false, }: ExceptionBuilderProps): JSX.Element => { const [ { @@ -229,7 +231,6 @@ export const ExceptionBuilderComponent = ({ }, ...exceptions.slice(index + 1), ]; - setUpdateExceptions(updatedExceptions); }, [setUpdateExceptions, exceptions] @@ -278,7 +279,6 @@ export const ExceptionBuilderComponent = ({ ...lastException, entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); }, [setUpdateExceptions, exceptions] @@ -290,11 +290,12 @@ export const ExceptionBuilderComponent = ({ // would then be arbitrary, decided to just create a new exception list item const newException = getNewExceptionItem({ listId, + name: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, namespaceType: listNamespaceType, - ruleName: exceptionItemName ?? `${ruleName ?? 'Rule'} - Exception item`, }); + setUpdateExceptions([...exceptions, { ...newException }]); - }, [listId, listNamespaceType, exceptionItemName, ruleName, setUpdateExceptions, exceptions]); + }, [setUpdateExceptions, exceptions, listId, listNamespaceType, ruleName, exceptionItemName]); // The builder can have existing exception items, or new exception items that have yet // to be created (and thus lack an id), this was creating some React bugs with relying @@ -334,7 +335,6 @@ export const ExceptionBuilderComponent = ({ }, ], }; - setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); } else { setUpdateExceptions(exceptions); @@ -359,19 +359,23 @@ export const ExceptionBuilderComponent = ({ handleAddNewExceptionItemEntry(); }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]); + const memoExceptionItems = useMemo(() => { + return filterExceptionItems(exceptions); + }, [exceptions]); + + // useEffect(() => { + // setUpdateExceptions([]); + // }, [osTypes, setUpdateExceptions]); + // Bubble up changes to parent useEffect(() => { onChange({ errorExists: errorExists > 0, - exceptionItems: filterExceptionItems(exceptions), + exceptionItems: memoExceptionItems, exceptionsToDelete, warningExists: warningExists > 0, }); - }, [onChange, exceptionsToDelete, exceptions, errorExists, warningExists]); - - useEffect(() => { - setUpdateExceptions([]); - }, [osTypes, setUpdateExceptions]); + }, [onChange, exceptionsToDelete, memoExceptionItems, errorExists, warningExists]); // Defaults builder to never be sans entry, instead // always falls back to an empty entry if user deletes all @@ -436,6 +440,7 @@ export const ExceptionBuilderComponent = ({ osTypes={osTypes} isDisabled={isDisabled} operatorsList={operatorsList} + allowCustomOptions={allowCustomFieldOptions} /> diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts index 73bf42e767dd6..38323fcf88cbf 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.test.ts @@ -6,13 +6,11 @@ */ import { - CreateExceptionListItemSchema, EntryExists, EntryList, EntryMatch, EntryMatchAny, EntryNested, - ExceptionListItemSchema, ExceptionListType, ListOperatorEnum as OperatorEnum, ListOperatorTypeEnum as OperatorTypeEnum, @@ -24,6 +22,7 @@ import { EXCEPTION_OPERATORS_SANS_LISTS, EmptyEntry, ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, FormattedBuilderEntry, OperatorOption, doesNotExistOperator, @@ -1056,10 +1055,10 @@ describe('Exception builder helpers', () => { }); describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field and "allowCustomFieldOptions" is "false"', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const expected: FormattedBuilderEntry[] = [ { correspondingKeywordField: undefined, @@ -1075,13 +1074,35 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns formatted entry with field even if it is unable to find a matching index pattern field and "allowCustomFieldOptions" is "true"', () => { + const payloadIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, true); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'host.name', + type: 'keyword', + }, + id: '123', + 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 = getMockIndexPattern(); const payloadItems: BuilderEntry[] = [ { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1139,7 +1160,7 @@ describe('Exception builder helpers', () => { { ...payloadParent }, ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems, false); const field1: FieldSpec = { aggregatable: true, count: 0, @@ -1313,7 +1334,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: false, @@ -1338,6 +1360,95 @@ describe('Exception builder helpers', () => { expect(output).toEqual(expected); }); + test('it returns entry with field value undefined if "allowCustomFieldOptions" is "false" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + false + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with custom field value if "allowCustomFieldOptions" is "true" and no matching field found', () => { + const payloadIndexPattern: DataViewBase = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'custom.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined, + true + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + name: 'custom.text', + type: 'keyword', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern = getMockIndexPattern(); const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; @@ -1351,7 +1462,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, payloadParent, - 1 + 1, + false ); const field: FieldSpec = { aggregatable: false, @@ -1401,7 +1513,8 @@ describe('Exception builder helpers', () => { payloadItem, 0, undefined, - undefined + undefined, + false ); const field: FieldSpec = { aggregatable: true, @@ -1577,8 +1690,9 @@ describe('Exception builder helpers', () => { // Please see `x-pack/plugins/lists/public/exceptions/transforms.ts` doc notes // for context around the temporary `id` test('it correctly validates entries that include a temporary `id`', () => { - const output: Array = - filterExceptionItems([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock(), entries: ENTRIES_WITH_IDS }]); }); @@ -1611,13 +1725,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: '', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1631,13 +1744,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH, value: 'some value', }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1651,13 +1763,12 @@ describe('Exception builder helpers', () => { type: OperatorTypeEnum.MATCH_ANY, value: ['some value'], }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1669,13 +1780,12 @@ describe('Exception builder helpers', () => { field: '', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); @@ -1687,13 +1797,12 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([ { @@ -1713,27 +1822,134 @@ describe('Exception builder helpers', () => { field: 'host.name', type: OperatorTypeEnum.NESTED, }; - const output: Array = - filterExceptionItems([ - { - ...rest, - entries: [...entries, mockEmptyException], - }, - ]); + const output: ExceptionsBuilderReturnExceptionItem[] = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); }); - test('it removes `temporaryId` from items', () => { + test('it removes `temporaryId` from "createExceptionListItemSchema" items', () => { const { meta, ...rest } = getNewExceptionItem({ listId: '123', + name: 'rule name', namespaceType: 'single', - ruleName: 'rule name', }); const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); }); + + test('it removes `temporaryId` from "createRuleExceptionListItemSchema" items', () => { + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'rule name', + namespaceType: undefined, + }); + const exceptions = filterExceptionItems([{ ...rest, entries: [getEntryMatchMock()], meta }]); + + expect(exceptions).toEqual([{ ...rest, entries: [getEntryMatchMock()], meta: undefined }]); + }); + }); + + describe('#getNewExceptionItem', () => { + it('returns new item with updated name', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest.name).toEqual('My Item Name'); + }); + + it('returns new item with list_id if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without list_id if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: undefined, + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: undefined, + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item with namespace_type if one is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: 'single', + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: 'single', + tags: [], + type: 'simple', + }); + }); + + it('returns new item without namespace_type if none is passed in', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { meta, ...rest } = getNewExceptionItem({ + listId: '123', + name: 'My Item Name', + namespaceType: undefined, + }); + + expect(rest).toEqual({ + comments: [], + description: 'Exception list item', + entries: [{ field: '', id: '123', operator: 'included', type: 'match', value: '' }], + item_id: undefined, + list_id: '123', + name: 'My Item Name', + namespace_type: undefined, + tags: [], + type: 'simple', + }); + }); }); describe('#getEntryValue', () => { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts index 291ef7a420f0f..ee7971e69c83a 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts @@ -75,3 +75,11 @@ export const AND = i18n.translate('xpack.lists.exceptions.andDescription', { export const OR = i18n.translate('xpack.lists.exceptions.orDescription', { defaultMessage: 'OR', }); + +export const CUSTOM_COMBOBOX_OPTION_TEXT = i18n.translate( + 'xpack.lists.exceptions.comboBoxCustomOptionText', + { + defaultMessage: + 'Select a field from the list. If your field is not available, create a custom one.', + } +); diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index 073bb7db3a3e8..c52a9253122dc 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -17,6 +17,7 @@ export interface RuleEcs { risk_score?: string[]; output_index?: string[]; description?: string[]; + exceptions_list?: string[]; from?: string[]; immutable?: boolean[]; index?: string[]; diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts similarity index 83% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts index 952325ab01559..fcde59d0bd79a 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_flyout.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts @@ -5,27 +5,32 @@ * 2.0. */ -import { getNewRule } from '../../objects/rule'; +import { getNewRule } from '../../../objects/rule'; -import { RULE_STATUS } from '../../screens/create_new_rule'; +import { RULE_STATUS } from '../../../screens/create_new_rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; -import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; import { openExceptionFlyoutFromEmptyViewerPrompt, goToExceptionsTab, openEditException, -} from '../../tasks/rule_details'; +} from '../../../tasks/rule_details'; import { addExceptionEntryFieldMatchAnyValue, addExceptionEntryFieldValue, addExceptionEntryFieldValueOfItemX, addExceptionEntryFieldValueValue, addExceptionEntryOperatorValue, + addExceptionFlyoutItemName, closeExceptionBuilderFlyout, -} from '../../tasks/exceptions'; +} from '../../../tasks/exceptions'; import { ADD_AND_BTN, ADD_OR_BTN, @@ -34,7 +39,6 @@ import { FIELD_INPUT, LOADING_SPINNER, EXCEPTION_ITEM_CONTAINER, - ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, EXCEPTION_FIELD_LIST, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, EXCEPTION_FLYOUT_VERSION_CONFLICT, @@ -42,17 +46,17 @@ import { CONFIRM_BTN, VALUES_INPUT, EXCEPTION_FLYOUT_TITLE, -} from '../../screens/exceptions'; +} from '../../../screens/exceptions'; -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; -import { reload } from '../../tasks/common'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { reload } from '../../../tasks/common'; import { createExceptionList, createExceptionListItem, updateExceptionListItem, deleteExceptionList, -} from '../../tasks/api_calls/exceptions'; -import { getExceptionList } from '../../objects/exception'; +} from '../../../tasks/api_calls/exceptions'; +import { getExceptionList } from '../../../objects/exception'; // NOTE: You might look at these tests and feel they're overkill, // but the exceptions flyout has a lot of logic making it difficult @@ -92,12 +96,11 @@ describe('Exceptions flyout', () => { }); it('Validates empty entry values correctly', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add an entry with a value and submit button should enable addExceptionEntryFieldValue('agent.name', 0); @@ -120,13 +123,27 @@ describe('Exceptions flyout', () => { closeExceptionBuilderFlyout(); }); + it('Validates custom fields correctly', () => { + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // add an entry with a value and submit button should enable + addExceptionEntryFieldValue('blooberty', 0); + addExceptionEntryFieldValueValue('blah', 0); + cy.get(CONFIRM_BTN).should('be.enabled'); + + closeExceptionBuilderFlyout(); + }); + it('Does not overwrite values and-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); // add multiple entries with invalid field values addExceptionEntryFieldValue('agent.name', 0); @@ -144,12 +161,12 @@ describe('Exceptions flyout', () => { }); it('Does not overwrite values or-ed together', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + // exception item 1 addExceptionEntryFieldValueOfItemX('agent.name', 0, 0); cy.get(ADD_AND_BTN).click(); @@ -265,19 +282,17 @@ describe('Exceptions flyout', () => { }); it('Contains custom index fields', () => { - cy.root() - .pipe(($el) => { - $el.find(ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN).trigger('click'); - return $el.find(ADD_AND_BTN); - }) - .should('be.visible'); + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + cy.get(FIELD_INPUT).eq(0).click({ force: true }); cy.get(EXCEPTION_FIELD_LIST).contains('unique_value.test'); closeExceptionBuilderFlyout(); }); - describe('flyout errors', () => { + // TODO - Add back in error states into modal + describe.skip('flyout errors', () => { beforeEach(() => { // create exception item via api createExceptionListItem(getExceptionList().list_id, { diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts index f1d6d2f1cc063..213ea64fc4ceb 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/alerts_table_flow/add_exception.cy.ts @@ -5,98 +5,190 @@ * 2.0. */ -import { getException } from '../../../objects/exception'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; import { getNewRule } from '../../../objects/rule'; -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { - addExceptionFromFirstAlert, - goToClosedAlerts, - goToOpenedAlerts, -} from '../../../tasks/alerts'; -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + deleteExceptionListWithRuleReference, + deleteExceptionListWithoutRuleReference, + exportExceptionList, + searchForExceptionList, + waitForExceptionsTableToBeLoaded, + clearSearchSelection, +} from '../../../tasks/exceptions_table'; import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addsException, - goToAlertsTab, - goToExceptionsTab, - removeException, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; + EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_TABLE_LIST_NAME, + EXCEPTIONS_TABLE_SHOWING_LISTS, +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; -describe('Adds rule exception from alerts flow', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; +const getExceptionList1 = () => ({ + ...getExceptionList(), + name: 'Test a new list 1', + list_id: 'exception_list_1', +}); +const getExceptionList2 = () => ({ + ...getExceptionList(), + name: 'Test list 2', + list_id: 'exception_list_2', +}); +describe('Exceptions Table', () => { before(() => { esArchiverResetKibana(); - esArchiverLoad('exceptions'); login(); - }); - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { + // Create exception list associated with a rule + createExceptionList(getExceptionList2(), getExceptionList2().list_id).then((response) => + createCustomRule({ ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' + exceptionLists: [ + { + id: response.body.id, + list_id: getExceptionList2().list_id, + type: getExceptionList2().type, + namespace_type: getExceptionList2().namespace_type, + }, + ], + }) ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); + + // Create exception list not used by any rules + createExceptionList(getExceptionList1(), getExceptionList1().list_id).as( + 'exceptionListResponse' + ); + + visitWithoutDateRange(EXCEPTIONS_URL); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Exports exception list', function () { + cy.intercept(/(\/api\/exception_lists\/_export)/).as('export'); + + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + exportExceptionList(); + + cy.wait('@export').then(({ response }) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + + cy.get(TOASTER).should('have.text', 'Exception list export success'); + }); + }); + + it('Filters exception lists on search', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + // Single word search + searchForExceptionList('Endpoint'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + // Multi word search + clearSearchSelection(); + searchForExceptionList('test'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'Test list 2'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test a new list 1'); + + // Exact phrase search + clearSearchSelection(); + searchForExceptionList(`"${getExceptionList1().name}"`); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', getExceptionList1().name); + + // Field search + clearSearchSelection(); + searchForExceptionList('list_id:endpoint_list'); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + clearSearchSelection(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + }); + + it('Deletes exception list without rule reference', () => { + visitWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '3'); + + deleteExceptionListWithoutRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); }); - afterEach(() => { - esArchiverUnload('exceptions_2'); + it('Deletes exception list with rule reference', () => { + waitForPageWithoutDateRange(EXCEPTIONS_URL); + waitForExceptionsTableToBeLoaded(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '2'); + + deleteExceptionListWithRuleReference(); + + // Using cy.contains because we do not care about the exact text, + // just checking number of lists shown + cy.contains(EXCEPTIONS_TABLE_SHOWING_LISTS, '1'); }); +}); + +describe('Exceptions Table - read only', () => { + before(() => { + // First we login as a privileged user to create exception list + esArchiverResetKibana(); + login(ROLES.platform_engineer); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.platform_engineer); + createExceptionList(getExceptionList(), getExceptionList().list_id); + + // Then we login as read-only user to test. + login(ROLES.reader); + visitWithoutDateRange(EXCEPTIONS_URL, ROLES.reader); + waitForExceptionsTableToBeLoaded(); - after(() => { - esArchiverUnload('exceptions'); + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); }); - it('Creates an exception from an alert and deletes it', () => { - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS); - // Create an exception from the alerts actions menu that matches - // the existing alert - addExceptionFromFirstAlert(); - addsException(getException()); - - // Alerts table should now be empty from having added exception and closed - // matching alert - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - removeException(); - esArchiverLoad('exceptions_2'); - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + it('Delete icon is not shown', () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts similarity index 91% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts index b037c4f6d62ce..213ea64fc4ceb 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_table.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ROLES } from '../../../common/test'; -import { getExceptionList, expectedExportedExceptionList } from '../../objects/exception'; -import { getNewRule } from '../../objects/rule'; +import { ROLES } from '../../../../common/test'; +import { getExceptionList, expectedExportedExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; -import { createCustomRule } from '../../tasks/api_calls/rules'; -import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../tasks/login'; +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { login, visitWithoutDateRange, waitForPageWithoutDateRange } from '../../../tasks/login'; -import { EXCEPTIONS_URL } from '../../urls/navigation'; +import { EXCEPTIONS_URL } from '../../../urls/navigation'; import { deleteExceptionListWithRuleReference, deleteExceptionListWithoutRuleReference, @@ -20,15 +20,15 @@ import { searchForExceptionList, waitForExceptionsTableToBeLoaded, clearSearchSelection, -} from '../../tasks/exceptions_table'; +} from '../../../tasks/exceptions_table'; import { EXCEPTIONS_TABLE_DELETE_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, -} from '../../screens/exceptions'; -import { createExceptionList } from '../../tasks/api_calls/exceptions'; -import { esArchiverResetKibana } from '../../tasks/es_archiver'; -import { TOASTER } from '../../screens/alerts_detection_rules'; +} from '../../../screens/exceptions'; +import { createExceptionList } from '../../../tasks/api_calls/exceptions'; +import { esArchiverResetKibana } from '../../../tasks/es_archiver'; +import { TOASTER } from '../../../screens/alerts_detection_rules'; const getExceptionList1 = () => ({ ...getExceptionList(), diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts new file mode 100644 index 0000000000000..da975710c7f39 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNewRule } from '../../../objects/rule'; + +import { createCustomRule } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { + esArchiverLoad, + esArchiverResetKibana, + esArchiverUnload, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + goToEndpointExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + searchForExceptionItem, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectOs, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; + +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_RULE_OR_LIST_SECTION, + CLOSE_SINGLE_ALERT_CHECKBOX, + EXCEPTION_ITEM_CONTAINER, + VALUES_INPUT, + FIELD_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { createEndpointExceptionList } from '../../../tasks/api_calls/exceptions'; + +describe('Add endpoint exception from rule details', () => { + const ITEM_NAME = 'Sample Exception List Item'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('auditbeat'); + login(); + }); + + before(() => { + deleteAlertsAndRules(); + // create rule with exception + createEndpointExceptionList().then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'event.code:*', + dataSource: { index: ['auditbeat*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: response.body.list_id, + type: response.body.type, + namespace_type: response.body.namespace_type, + }, + ], + }, + '2' + ); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToEndpointExceptionsTab(); + }); + + after(() => { + esArchiverUnload('auditbeat'); + }); + + it('creates an exception item', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // for endpoint exceptions, must specify OS + selectOs('windows'); + + // add exception item conditions + addExceptionConditions({ + field: 'event.code', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName(ITEM_NAME); + + // Option to add to rule or add to list should NOT appear + cy.get(ADD_TO_RULE_OR_LIST_SECTION).should('not.exist'); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).should('not.exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + }); + + it('edits an endpoint exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'event.code'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ` ${ITEM_FIELD}IS foo`); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + it('allows user to search for items', () => { + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts new file mode 100644 index 0000000000000..64a2e14bbf61f --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts @@ -0,0 +1,336 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getException, getExceptionList } from '../../../objects/exception'; +import { getNewRule } from '../../../objects/rule'; + +import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; +import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; +import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + esArchiverLoad, + esArchiverUnload, + esArchiverResetKibana, +} from '../../../tasks/es_archiver'; +import { login, visitWithoutDateRange } from '../../../tasks/login'; +import { + addExceptionFlyoutFromViewerHeader, + goToAlertsTab, + goToExceptionsTab, + openEditException, + openExceptionFlyoutFromEmptyViewerPrompt, + removeException, + searchForExceptionItem, + waitForTheRuleToBeExecuted, +} from '../../../tasks/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + editException, + editExceptionFlyoutItemName, + selectAddToRuleRadio, + selectBulkCloseAlerts, + selectSharedListToAddExceptionTo, + submitEditedExceptionItem, + submitNewExceptionItem, +} from '../../../tasks/exceptions'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; +import { deleteAlertsAndRules } from '../../../tasks/common'; +import { + NO_EXCEPTIONS_EXIST_PROMPT, + EXCEPTION_ITEM_VIEWER_CONTAINER, + NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + ADD_TO_SHARED_LIST_RADIO_INPUT, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_MATCH_ANY_INPUT, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, +} from '../../../screens/exceptions'; +import { + createExceptionList, + createExceptionListItem, + deleteExceptionList, +} from '../../../tasks/api_calls/exceptions'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + +describe('Add/edit exception from rule details', () => { + const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + const ITEM_FIELD = 'unique_value.test'; + + before(() => { + esArchiverResetKibana(); + esArchiverLoad('exceptions'); + login(); + }); + + after(() => { + esArchiverUnload('exceptions'); + }); + + describe('existing list and items', () => { + const exceptionList = getExceptionList(); + beforeEach(() => { + deleteAlertsAndRules(); + deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); + // create rule with exceptions + createExceptionList(exceptionList, exceptionList.list_id).then((response) => { + createCustomRule( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + exceptionLists: [ + { + id: response.body.id, + list_id: exceptionList.list_id, + type: exceptionList.type, + namespace_type: exceptionList.namespace_type, + }, + ], + }, + '2' + ); + createExceptionListItem(exceptionList.list_id, { + list_id: exceptionList.list_id, + item_id: 'simple_list_item', + tags: [], + type: 'simple', + description: 'Test exception item 2', + name: 'Sample Exception List Item 2', + namespace_type: 'single', + entries: [ + { + field: ITEM_FIELD, + operator: 'included', + type: 'match_any', + value: ['foo'], + }, + ], + }); + }); + + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_NAME = 'Sample Exception List Item 2'; + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testis one of foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', ITEM_FIELD); + cy.get(VALUES_MATCH_ANY_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); + + describe('rule with existing shared exceptions', () => { + it('Creates an exception item to add to shared list', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to a shared list + selectSharedListToAddExceptionTo(1); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + it('Creates an exception item to add to rule only', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // open add exception modal + addExceptionFlyoutFromViewerHeader(); + + // add exception item conditions + addExceptionConditions(getException()); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // not testing close alert functionality here, just ensuring that the options appear as expected + cy.get(CLOSE_ALERTS_CHECKBOX).should('exist'); + cy.get(CLOSE_ALERTS_CHECKBOX).should('not.have.attr', 'disabled'); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); + }); + + // Trying to figure out with EUI why the search won't trigger + it('Can search for items', () => { + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + + // can search for an exception value + searchForExceptionItem('foo'); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // displays empty search result view if no matches found + searchForExceptionItem('abc'); + + // new exception item displays + cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); + }); + }); + }); + + describe('rule without existing exceptions', () => { + beforeEach(() => { + deleteAlertsAndRules(); + createCustomRuleEnabled( + { + ...getNewRule(), + customQuery: 'agent.name:*', + dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, + }, + 'rule_testing', + '1s' + ); + visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + goToExceptionsTab(); + }); + + afterEach(() => { + esArchiverUnload('exceptions_2'); + }); + + it('Cannot create an item to add to rule but not shared list as rule has no lists attached', () => { + // when no exceptions exist, empty component shows with action to add exception + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // open add exception modal + openExceptionFlyoutFromEmptyViewerPrompt(); + + // add exception item conditions + addExceptionConditions({ + field: 'agent.name', + operator: 'is', + values: ['foo'], + }); + + // Name is required so want to check that submit is still disabled + cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); + + // add exception item name + addExceptionFlyoutItemName('My item name'); + + // select to add exception item to rule only + selectAddToRuleRadio(); + + // Check that add to shared list is disabled, should be unless + // rule has shared lists attached to it already + cy.get(ADD_TO_SHARED_LIST_RADIO_INPUT).should('have.attr', 'disabled'); + + // Close matching alerts + selectBulkCloseAlerts(); + + // submit + submitNewExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // Alerts table should now be empty from having added exception and closed + // matching alert + goToAlertsTab(); + cy.get(EMPTY_ALERT_TABLE).should('exist'); + + // Closed alert should appear in table + goToClosedAlerts(); + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + + // Remove the exception and load an event that would have matched that exception + // to show that said exception now starts to show up again + goToExceptionsTab(); + + // when removing exception and again, no more exist, empty screen shows again + removeException(); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); + + // load more docs + esArchiverLoad('exceptions_2'); + + // now that there are no more exceptions, the docs should match and populate alerts + goToAlertsTab(); + goToOpenedAlerts(); + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(ALERTS_COUNT).should('exist'); + cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts similarity index 63% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts index 05b21abe52565..f17a5e4221a89 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception_data_view.spect.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts @@ -10,6 +10,11 @@ import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../scre import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + editException, + editExceptionFlyoutItemName, + submitEditedExceptionItem, +} from '../../../tasks/exceptions'; import { esArchiverLoad, esArchiverUnload, @@ -20,6 +25,7 @@ import { addFirstExceptionFromRuleDetails, goToAlertsTab, goToExceptionsTab, + openEditException, removeException, waitForTheRuleToBeExecuted, } from '../../../tasks/rule_details'; @@ -29,11 +35,17 @@ import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; import { NO_EXCEPTIONS_EXIST_PROMPT, EXCEPTION_ITEM_VIEWER_CONTAINER, + EXCEPTION_CARD_ITEM_NAME, + EXCEPTION_CARD_ITEM_CONDITIONS, + EXCEPTION_ITEM_CONTAINER, + FIELD_INPUT, + VALUES_INPUT, } from '../../../screens/exceptions'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; describe('Add exception using data views from rule details', () => { const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; + const ITEM_NAME = 'Sample Exception List Item'; before(() => { esArchiverResetKibana(); @@ -66,17 +78,20 @@ describe('Add exception using data views from rule details', () => { esArchiverUnload('exceptions_2'); }); - it('Creates an exception item when none exist', () => { + it('Creates an exception item', () => { // when no exceptions exist, empty component shows with action to add exception cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); // clicks prompt button to add first exception that will also select to close // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); + addFirstExceptionFromRuleDetails( + { + field: 'agent.name', + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); // new exception item displays cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); @@ -111,4 +126,49 @@ describe('Add exception using data views from rule details', () => { cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); }); + + it('Edits an exception item', () => { + const NEW_ITEM_NAME = 'Exception item-EDITED'; + const ITEM_FIELD = 'unique_value.test'; + const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; + + // add item to edit + addFirstExceptionFromRuleDetails( + { + field: ITEM_FIELD, + operator: 'is', + values: ['foo'], + }, + ITEM_NAME + ); + + // displays existing exception items + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' unique_value.testIS foo'); + + // open edit exception modal + openEditException(); + + // edit exception item name + editExceptionFlyoutItemName(NEW_ITEM_NAME); + + // check that the existing item's field is being populated + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(VALUES_INPUT).should('have.text', 'foo'); + + // edit conditions + editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); + + // submit + submitEditedExceptionItem(); + + // new exception item displays + cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); + + // check that updates stuck + cy.get(EXCEPTION_CARD_ITEM_NAME).should('have.text', NEW_ITEM_NAME); + cy.get(EXCEPTION_CARD_ITEM_CONDITIONS).should('have.text', ' agent.nameIS foo'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts deleted file mode 100644 index 3ea14d8b3ffd4..0000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_exception.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getException, getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; -import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - addExceptionFromRuleDetails, - addFirstExceptionFromRuleDetails, - goToAlertsTab, - goToExceptionsTab, - removeException, - searchForExceptionItem, - waitForTheRuleToBeExecuted, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - NO_EXCEPTIONS_EXIST_PROMPT, - EXCEPTION_ITEM_VIEWER_CONTAINER, - NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; - -describe('Add exception from rule details', () => { - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - describe('rule with existing exceptions', () => { - const exceptionList = getExceptionList(); - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRule( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: 'user.name', - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item_2', - tags: [], - type: 'simple', - description: 'Test exception item 2', - name: 'Sample Exception List Item 2', - namespace_type: 'single', - entries: [ - { - field: 'unique_value.test', - operator: 'included', - type: 'match_any', - value: ['foo'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - it('Creates an exception item', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // clicks prompt button to add a new exception item - addExceptionFromRuleDetails(getException()); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 3); - }); - - // Trying to figure out with EUI why the search won't trigger - it('Can search for items', () => { - // displays existing exception items - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 2); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('not.exist'); - - // can search for an exception value - searchForExceptionItem('foo'); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // displays empty search result view if no matches found - searchForExceptionItem('abc'); - - // new exception item displays - cy.get(NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT).should('exist'); - }); - }); - - describe('rule without existing exceptions', () => { - beforeEach(() => { - deleteAlertsAndRules(); - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - }, - 'rule_testing', - '1s' - ); - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Creates an exception item when none exist', () => { - // when no exceptions exist, empty component shows with action to add exception - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // clicks prompt button to add first exception that will also select to close - // all matching alerts - addFirstExceptionFromRuleDetails({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); - - // new exception item displays - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should now be empty from having added exception and closed - // matching alert - goToAlertsTab(); - cy.get(EMPTY_ALERT_TABLE).should('exist'); - - // Closed alert should appear in table - goToClosedAlerts(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // Remove the exception and load an event that would have matched that exception - // to show that said exception now starts to show up again - goToExceptionsTab(); - - // when removing exception and again, no more exist, empty screen shows again - removeException(); - cy.get(NO_EXCEPTIONS_EXIST_PROMPT).should('exist'); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that there are no more exceptions, the docs should match and populate alerts - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts deleted file mode 100644 index ec44e76d09edb..0000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { index: ['exceptions*'], type: 'indexPatterns' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts deleted file mode 100644 index 587486d99a068..0000000000000 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/edit_exception_data_view.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getExceptionList } from '../../../objects/exception'; -import { getNewRule } from '../../../objects/rule'; - -import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; -import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToOpenedAlerts } from '../../../tasks/alerts'; -import { - esArchiverLoad, - esArchiverUnload, - esArchiverResetKibana, -} from '../../../tasks/es_archiver'; -import { login, visitWithoutDateRange } from '../../../tasks/login'; -import { - goToExceptionsTab, - waitForTheRuleToBeExecuted, - openEditException, - goToAlertsTab, -} from '../../../tasks/rule_details'; - -import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; -import { postDataView, deleteAlertsAndRules } from '../../../tasks/common'; -import { - EXCEPTION_ITEM_VIEWER_CONTAINER, - EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, -} from '../../../screens/exceptions'; -import { - createExceptionList, - createExceptionListItem, - deleteExceptionList, -} from '../../../tasks/api_calls/exceptions'; -import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; -import { editException } from '../../../tasks/exceptions'; -import { ALERTS_COUNT, NUMBER_OF_ALERTS } from '../../../screens/alerts'; - -describe('Edit exception using data views from rule details', () => { - const exceptionList = getExceptionList(); - const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert'; - const ITEM_FIELD = 'unique_value.test'; - const FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD = 'agent.name'; - - before(() => { - esArchiverResetKibana(); - esArchiverLoad('exceptions'); - login(); - postDataView('exceptions-*'); - }); - - after(() => { - esArchiverUnload('exceptions'); - }); - - beforeEach(() => { - deleteAlertsAndRules(); - deleteExceptionList(exceptionList.list_id, exceptionList.namespace_type); - // create rule with exceptions - createExceptionList(exceptionList, exceptionList.list_id).then((response) => { - createCustomRuleEnabled( - { - ...getNewRule(), - customQuery: 'agent.name:*', - dataSource: { dataView: 'exceptions-*', type: 'dataView' }, - exceptionLists: [ - { - id: response.body.id, - list_id: exceptionList.list_id, - type: exceptionList.type, - namespace_type: exceptionList.namespace_type, - }, - ], - }, - '2', - '2s' - ); - createExceptionListItem(exceptionList.list_id, { - list_id: exceptionList.list_id, - item_id: 'simple_list_item', - tags: [], - type: 'simple', - description: 'Test exception item', - name: 'Sample Exception List Item', - namespace_type: 'single', - entries: [ - { - field: ITEM_FIELD, - operator: 'included', - type: 'match_any', - value: ['bar'], - }, - ], - }); - }); - - visitWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); - goToRuleDetails(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - goToExceptionsTab(); - }); - - afterEach(() => { - esArchiverUnload('exceptions_2'); - }); - - it('Edits an exception item', () => { - // displays existing exception item - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - openEditException(); - - // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); - - // check that you can select a different field - editException(FIELD_DIFFERENT_FROM_EXISTING_ITEM_FIELD, 0, 0); - cy.get(EXCEPTION_ITEM_VIEWER_CONTAINER).should('have.length', 1); - - // Alerts table should still show single alert - goToAlertsTab(); - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); - - // load more docs - esArchiverLoad('exceptions_2'); - - // now that 2 more docs have been added, one should match the edited exception - goToAlertsTab(); - goToOpenedAlerts(); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(2); - - // there should be 2 alerts, one is the original alert and the second is for the newly - // matching doc - cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts similarity index 100% rename from x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.spect.ts rename to x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/read_only_view.cy.ts diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 2ceeaac0e8ca7..2434b713a6452 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -7,6 +7,8 @@ export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]'; +export const ADD_ENDPOINT_EXCEPTION_BTN = '[data-test-subj="add-endpoint-exception-menu-item"]'; + export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; @@ -22,6 +24,8 @@ export const ALERT_SEVERITY = '[data-test-subj="formatted-field-kibana.alert.sev export const ALERT_DATA_GRID = '[data-test-subj="euiDataGridBody"]'; +export const ALERTS = '[data-test-subj="events-viewer-panel"][data-test-subj="event"]'; + export const ALERTS_COUNT = '[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]'; @@ -42,6 +46,10 @@ export const EMPTY_ALERT_TABLE = '[data-test-subj="tGridEmptyState"]'; export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; +export const TAKE_ACTION_BTN = '[data-test-subj="take-action-dropdown-btn"]'; + +export const TAKE_ACTION_MENU = '[data-test-subj="takeActionPanelMenu"]'; + export const CLOSE_FLYOUT = '[data-test-subj="euiFlyoutCloseButton"]'; export const GROUP_BY_TOP_INPUT = '[data-test-subj="groupByTop"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 1ca8ded946300..bf97d3e2e2039 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -5,10 +5,11 @@ * 2.0. */ -export const CLOSE_ALERTS_CHECKBOX = - '[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]'; +export const CLOSE_ALERTS_CHECKBOX = '[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]'; -export const CONFIRM_BTN = '[data-test-subj="add-exception-confirm-button"]'; +export const CLOSE_SINGLE_ALERT_CHECKBOX = '[data-test-subj="closeAlertOnAddExceptionCheckbox"]'; + +export const CONFIRM_BTN = '[data-test-subj="addExceptionConfirmButton"]'; export const FIELD_INPUT = '[data-test-subj="fieldAutocompleteComboBox"] [data-test-subj="comboBoxInput"]'; @@ -57,9 +58,9 @@ export const EXCEPTION_ITEM_CONTAINER = '[data-test-subj="exceptionEntriesContai export const EXCEPTION_FIELD_LIST = '[data-test-subj="comboBoxOptionsList fieldAutocompleteComboBox-optionsList"]'; -export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exception-flyout-title"]'; +export const EXCEPTION_FLYOUT_TITLE = '[data-test-subj="exceptionFlyoutTitle"]'; -export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="edit-exception-confirm-button"]'; +export const EXCEPTION_EDIT_FLYOUT_SAVE_BTN = '[data-test-subj="editExceptionConfirmButton"]'; export const EXCEPTION_FLYOUT_VERSION_CONFLICT = '[data-test-subj="exceptionsFlyoutVersionConflict"]'; @@ -68,7 +69,7 @@ export const EXCEPTION_FLYOUT_LIST_DELETED_ERROR = '[data-test-subj="errorCallou // Exceptions all items view export const NO_EXCEPTIONS_EXIST_PROMPT = - '[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]'; + '[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]'; export const ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN = '[data-test-subj="exceptionsEmptyPromptButton"]'; @@ -82,3 +83,25 @@ export const NO_EXCEPTIONS_SEARCH_RESULTS_PROMPT = '[data-test-subj="exceptionItemViewerEmptyPrompts-emptySearch"]'; export const EXCEPTION_ITEM_VIEWER_SEARCH = 'input[data-test-subj="exceptionsViewerSearchBar"]'; + +export const EXCEPTION_CARD_ITEM_NAME = '[data-test-subj="exceptionItemCardHeader-title"]'; + +export const EXCEPTION_CARD_ITEM_CONDITIONS = + '[data-test-subj="exceptionItemCardConditions-condition"]'; + +// Exception flyout components +export const EXCEPTION_ITEM_NAME_INPUT = 'input[data-test-subj="exceptionFlyoutNameInput"]'; + +export const ADD_TO_SHARED_LIST_RADIO_LABEL = '[data-test-subj="addToListsRadioOption"] label'; + +export const ADD_TO_SHARED_LIST_RADIO_INPUT = 'input[id="add_to_lists"]'; + +export const SHARED_LIST_CHECKBOX = '.euiTableRow .euiCheckbox__input'; + +export const ADD_TO_RULE_RADIO_LABEL = 'label [data-test-subj="addToRuleRadioOption"]'; + +export const ADD_TO_RULE_OR_LIST_SECTION = '[data-test-subj="exceptionItemAddToRuleOrListSection"]'; + +export const OS_SELECTION_SECTION = '[data-test-subj="osSelectionDropdown"]'; + +export const OS_INPUT = '[data-test-subj="osSelectionDropdown"] [data-test-subj="comboBoxInput"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index bad0770ffd763..606ee4ae7a043 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -43,6 +43,8 @@ export const FALSE_POSITIVES_DETAILS = 'False positive examples'; export const INDEX_PATTERNS_DETAILS = 'Index patterns'; +export const ENDPOINT_EXCEPTIONS_TAB = 'a[data-test-subj="navigation-endpoint_exceptions"]'; + export const INDICATOR_INDEX_PATTERNS = 'Indicator index patterns'; export const INDICATOR_INDEX_QUERY = 'Indicator index query'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 9df0fb86f218c..8003f1ba3c304 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -16,6 +16,7 @@ import { GROUP_BY_TOP_INPUT, ACKNOWLEDGED_ALERTS_FILTER_BTN, LOADING_ALERTS_PANEL, + MANAGE_ALERT_DETECTION_RULES_BTN, MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, OPENED_ALERTS_FILTER_BTN, @@ -25,6 +26,9 @@ import { TIMELINE_CONTEXT_MENU_BTN, CLOSE_FLYOUT, OPEN_ANALYZER_BTN, + TAKE_ACTION_BTN, + TAKE_ACTION_MENU, + ADD_ENDPOINT_EXCEPTION_BTN, } from '../screens/alerts'; import { REFRESH_BUTTON } from '../screens/security_header'; import { @@ -41,10 +45,44 @@ import { CELL_EXPANSION_POPOVER, USER_DETAILS_LINK, } from '../screens/alerts_details'; +import { FIELD_INPUT } from '../screens/exceptions'; export const addExceptionFromFirstAlert = () => { cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); - cy.get(ADD_EXCEPTION_BTN).click(); + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddEndpointExceptionFromFirstAlert = () => { + cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + cy.root() + .pipe(($el) => { + $el.find(ADD_ENDPOINT_EXCEPTION_BTN).trigger('click'); + return $el.find(FIELD_INPUT); + }) + .should('be.visible'); +}; + +export const openAddExceptionFromAlertDetails = () => { + cy.get(EXPAND_ALERT_BTN).first().click({ force: true }); + + cy.root() + .pipe(($el) => { + $el.find(TAKE_ACTION_BTN).trigger('click'); + return $el.find(TAKE_ACTION_MENU); + }) + .should('be.visible'); + + cy.root() + .pipe(($el) => { + $el.find(ADD_EXCEPTION_BTN).trigger('click'); + return $el.find(ADD_EXCEPTION_BTN); + }) + .should('not.be.visible'); }; export const closeFirstAlert = () => { @@ -106,6 +144,10 @@ export const goToClosedAlerts = () => { cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; +export const goToManageAlertsDetectionRules = () => { + cy.get(MANAGE_ALERT_DETECTION_RULES_BTN).should('exist').click({ force: true }); +}; + export const goToOpenedAlerts = () => { cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts index 68909d37dd1e4..fd070cfcda55e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -7,6 +7,14 @@ import type { ExceptionList, ExceptionListItem } from '../../objects/exception'; +export const createEndpointExceptionList = () => + cy.request({ + method: 'POST', + url: '/api/endpoint_list', + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); + export const createExceptionList = ( exceptionList: ExceptionList, exceptionListId = 'exception_list_testing' diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts index 5525fdcca8fcf..1e89e14c1280e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Exception } from '../objects/exception'; import { FIELD_INPUT, OPERATOR_INPUT, @@ -14,6 +15,15 @@ import { VALUES_INPUT, VALUES_MATCH_ANY_INPUT, EXCEPTION_EDIT_FLYOUT_SAVE_BTN, + CLOSE_ALERTS_CHECKBOX, + CONFIRM_BTN, + EXCEPTION_ITEM_NAME_INPUT, + CLOSE_SINGLE_ALERT_CHECKBOX, + ADD_TO_RULE_RADIO_LABEL, + ADD_TO_SHARED_LIST_RADIO_LABEL, + SHARED_LIST_CHECKBOX, + OS_SELECTION_SECTION, + OS_INPUT, } from '../screens/exceptions'; export const addExceptionEntryFieldValueOfItemX = ( @@ -56,8 +66,72 @@ export const closeExceptionBuilderFlyout = () => { export const editException = (updatedField: string, itemIndex = 0, fieldIndex = 0) => { addExceptionEntryFieldValueOfItemX(`${updatedField}{downarrow}{enter}`, itemIndex, fieldIndex); addExceptionEntryFieldValueValue('foo', itemIndex); +}; + +export const addExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .type(`${name}{enter}`) + .should('have.value', name); +}; + +export const editExceptionFlyoutItemName = (name: string) => { + cy.root() + .pipe(($el) => { + return $el.find(EXCEPTION_ITEM_NAME_INPUT); + }) + .clear() + .type(`${name}{enter}`) + .should('have.value', name); +}; +export const selectBulkCloseAlerts = () => { + cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); +}; + +export const selectCloseSingleAlerts = () => { + cy.get(CLOSE_SINGLE_ALERT_CHECKBOX).click({ force: true }); +}; + +export const addExceptionConditions = (exception: Exception) => { + cy.root() + .pipe(($el) => { + return $el.find(FIELD_INPUT); + }) + .type(`${exception.field}{downArrow}{enter}`); + cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); + exception.values.forEach((value) => { + cy.get(VALUES_INPUT).type(`${value}{enter}`); + }); +}; + +export const submitNewExceptionItem = () => { + cy.get(CONFIRM_BTN).click(); + cy.get(CONFIRM_BTN).should('not.exist'); +}; + +export const submitEditedExceptionItem = () => { cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).click(); - cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('have.attr', 'disabled'); cy.get(EXCEPTION_EDIT_FLYOUT_SAVE_BTN).should('not.exist'); }; + +export const selectAddToRuleRadio = () => { + cy.get(ADD_TO_RULE_RADIO_LABEL).click(); +}; + +export const selectSharedListToAddExceptionTo = (numListsToCheck = 1) => { + cy.get(ADD_TO_SHARED_LIST_RADIO_LABEL).click(); + for (let i = 0; i < numListsToCheck; i++) { + cy.get(SHARED_LIST_CHECKBOX) + .eq(i) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); + } +}; + +export const selectOs = (os: string) => { + cy.get(OS_SELECTION_SECTION).should('exist'); + cy.get(OS_INPUT).type(`${os}{downArrow}{enter}`); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 3f7d31f060473..bbfadc337f5e2 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -10,13 +10,8 @@ import { RULE_STATUS } from '../screens/create_new_rule'; import { ADD_EXCEPTIONS_BTN_FROM_EMPTY_PROMPT_BTN, ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER, - CLOSE_ALERTS_CHECKBOX, - CONFIRM_BTN, EXCEPTION_ITEM_VIEWER_SEARCH, FIELD_INPUT, - LOADING_SPINNER, - OPERATOR_INPUT, - VALUES_INPUT, } from '../screens/exceptions'; import { ALERTS_TAB, @@ -32,8 +27,15 @@ import { DETAILS_DESCRIPTION, EXCEPTION_ITEM_ACTIONS_BUTTON, EDIT_EXCEPTION_BTN, + ENDPOINT_EXCEPTIONS_TAB, EDIT_RULE_SETTINGS_LINK, } from '../screens/rule_details'; +import { + addExceptionConditions, + addExceptionFlyoutItemName, + selectBulkCloseAlerts, + submitNewExceptionItem, +} from './exceptions'; import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser'; export const enablesRule = () => { @@ -46,21 +48,6 @@ export const enablesRule = () => { }); }; -export const addsException = (exception: Exception) => { - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); - cy.get(FIELD_INPUT).should('exist'); - cy.get(FIELD_INPUT).type(`${exception.field}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); -}; - export const addsFieldsToTimeline = (search: string, fields: string[]) => { cy.get(FIELDS_BROWSER_BTN).click(); filterFieldsBrowser(search); @@ -86,7 +73,7 @@ export const searchForExceptionItem = (query: string) => { }); }; -const addExceptionFlyoutFromViewerHeader = () => { +export const addExceptionFlyoutFromViewerHeader = () => { cy.root() .pipe(($el) => { $el.find(ADD_EXCEPTIONS_BTN_FROM_VIEWER_HEADER).trigger('click'); @@ -97,28 +84,16 @@ const addExceptionFlyoutFromViewerHeader = () => { export const addExceptionFromRuleDetails = (exception: Exception) => { addExceptionFlyoutFromViewerHeader(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionConditions(exception); + submitNewExceptionItem(); }; -export const addFirstExceptionFromRuleDetails = (exception: Exception) => { +export const addFirstExceptionFromRuleDetails = (exception: Exception, name: string) => { openExceptionFlyoutFromEmptyViewerPrompt(); - cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`); - cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`); - exception.values.forEach((value) => { - cy.get(VALUES_INPUT).type(`${value}{enter}`); - }); - cy.get(CLOSE_ALERTS_CHECKBOX).click({ force: true }); - cy.get(CONFIRM_BTN).click(); - cy.get(CONFIRM_BTN).should('have.attr', 'disabled'); - cy.get(CONFIRM_BTN).should('not.exist'); + addExceptionFlyoutItemName(name); + addExceptionConditions(exception); + selectBulkCloseAlerts(); + submitNewExceptionItem(); }; export const goToAlertsTab = () => { @@ -130,9 +105,13 @@ export const goToExceptionsTab = () => { cy.get(EXCEPTIONS_TAB).click(); }; +export const goToEndpointExceptionsTab = () => { + cy.get(ENDPOINT_EXCEPTIONS_TAB).should('exist'); + cy.get(ENDPOINT_EXCEPTIONS_TAB).click(); +}; + export const openEditException = (index = 0) => { cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).eq(index).click({ force: true }); - cy.get(EDIT_EXCEPTION_BTN).eq(index).click({ force: true }); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx index de5eca78aaffb..0613f08b7f572 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.test.tsx @@ -7,21 +7,24 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; -import { mount } from 'enzyme'; +import { mount, shallow } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { AddExceptionFlyout } from '.'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import { useAsync } from '@kbn/securitysolution-hook-utils'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; +import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { createStubIndexPattern, stubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; + +import { AddExceptionFlyout } from '.'; +import { useFetchIndex } from '../../../../common/containers/source'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import * as helpers from '../../utils/helpers'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import type { EntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import * as i18n from './translations'; import { TestProviders } from '../../../../common/mock'; @@ -29,59 +32,61 @@ import { getRulesEqlSchemaMock, getRulesSchemaMock, } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; import type { AlertData } from '../../utils/types'; +import { useFindRules } from '../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index'); jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/containers/source'); jest.mock('../../../../detections/containers/detection_engine/rules'); -jest.mock('../../logic/use_add_exception'); -jest.mock('../../logic/use_fetch_or_create_rule_exception_list'); +jest.mock('../../logic/use_create_update_exception'); +jest.mock('../../logic/use_exception_flyout_data'); +jest.mock('../../logic/use_find_references'); jest.mock('@kbn/securitysolution-hook-utils', () => ({ ...jest.requireActual('@kbn/securitysolution-hook-utils'), useAsync: jest.fn(), })); jest.mock('../../../../detections/containers/detection_engine/rules/use_rule_async'); jest.mock('@kbn/lists-plugin/public'); +jest.mock('../../../../detections/pages/detection_engine/rules/all/rules_table/use_find_rules'); const mockGetExceptionBuilderComponentLazy = getExceptionBuilderComponentLazy as jest.Mock< ReturnType >; -const mockUseAsync = useAsync as jest.Mock>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType >; -const mockUseFetchOrCreateRuleExceptionList = useFetchOrCreateRuleExceptionList as jest.Mock< - ReturnType +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; const mockUseFetchIndex = useFetchIndex as jest.Mock; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockUseFindRules = useFindRules as jest.Mock; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; + +const alertDataMock: AlertData = { + '@timestamp': '1234567890', + _id: 'test-id', + file: { path: 'test/path' }, +}; describe('When the add exception modal is opened', () => { - const ruleName = 'test rule'; let defaultEndpointItems: jest.SpyInstance< ReturnType >; beforeEach(() => { mockGetExceptionBuilderComponentLazy.mockReturnValue( - + ); defaultEndpointItems = jest.spyOn(helpers, 'defaultEndpointExceptionItems'); - mockUseAsync.mockImplementation(() => ({ - start: jest.fn(), - loading: false, - error: {}, - result: true, + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); - mockUseFetchOrCreateRuleExceptionList.mockImplementation(() => [ - false, - getExceptionListSchemaMock(), - ]); mockUseSignalIndex.mockImplementation(() => ({ loading: false, signalIndexName: 'mock-siem-signals-index', @@ -92,9 +97,48 @@ describe('When the add exception modal is opened', () => { indexPatterns: stubIndexPattern, }, ]); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockUseFindRules.mockImplementation(() => ({ + data: { + rules: [ + { + ...getRulesSchemaMock(), + exceptions_list: [], + } as Rule, + ], + total: 1, + }, + isFetched: true, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -106,87 +150,705 @@ describe('When the add exception modal is opened', () => { let wrapper: ReactWrapper; beforeEach(() => { // Mocks one of the hooks as loading - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + wrapper = mount( ); }); + it('should show the loading spinner', () => { expect(wrapper.find('[data-test-subj="loadingAddExceptionFlyout"]').exists()).toBeTruthy(); }); }); - describe('when there is no alert data passed to an endpoint list exception', () => { + describe('exception list type of "endpoint"', () => { + describe('common functionality to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.ADD_ENDPOINT_EXCEPTION + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('does NOT render options to add exception to a rule or shared list', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close alerts checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeTruthy(); + }); + }); + }); + + describe('exception list type is NOT "endpoint" ("rule_default" or "detection")', () => { + describe('common features to test regardless of alert input', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + expect(wrapper.find('[data-test-subj="addExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.CREATE_RULE_EXCEPTION + ); + }); + + it('should NOT prepopulate items', () => { + expect(defaultEndpointItems).not.toHaveBeenCalled(); + }); + + // button is disabled until there are exceptions, a name, and selection made on + // add to rule or lists section + it('has the add exception button disabled', () => { + expect( + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should NOT render the os selection dropdown', () => { + expect(wrapper.find('[data-test-subj="osSelectionDropdown"]').exists()).toBeFalsy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); + }); + + it('renders options to add exception to a rule or shared list and has "add to rule" selected by default', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + + describe('bulk closeable alert data is passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + mockUseFetchIndex.mockImplementation(() => [ + false, + { + indexPatterns: createStubIndexPattern({ + spec: { + id: '1234', + title: 'filebeat-*', + fields: { + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', + aggregatable: true, + searchable: true, + }, + }, + }, + }), + }, + ]); + wrapper = mount( + + + + ); + + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + { + ...getExceptionListItemSchemaMock(), + entries: [{ field: 'file.hash.sha256', operator: 'included', type: 'match' }], + }, + ], + }) + ); + }); + + it('should render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('input[data-test-subj="closeAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).not.toBeDisabled(); + }); + + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', async () => { + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + + await waitFor(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + describe('alert data NOT passed in', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('should NOT render the close single alert checkbox', () => { + expect( + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() + ).toBeFalsy(); + }); + + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() + ).toBeDisabled(); + }); + }); + }); + + /* Say for example, from the lists management or lists details page */ + describe('when no rules are passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [] })); - }); - it('has the add exception button disabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + ); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('allows large value lists', () => { + expect(wrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should not render the close on add exception checkbox', () => { + + it('defaults to selecting add to rule option, displaying rules selection table', () => { + expect(wrapper.find('[data-test-subj="addExceptionToRulesTable"]').exists()).toBeTruthy(); expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeFalsy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="selectRulesToAddToOptionRadio"] input').getDOMNode() + ).toHaveAttribute('checked'); }); }); - describe('when there is alert data passed to an endpoint list exception', () => { + /* Say for example, from the rule details page, exceptions tab, or from an alert */ + describe('when a single rule is passed in', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -195,119 +857,304 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + it('does not allow large value list selection for query rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + + it('does not allow large value list selection if EQL rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); + + it('does not allow large value list selection if threshold rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); }); - it('should render the close on add exception checkbox', () => { + + it('does not allow large value list selection if new trems rule', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeFalsy(); + }); + + it('defaults to selecting add to rule radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRuleOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rule has no shared exception lists attached already', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); - }); - it('should not render the os selection dropdown', () => { - expect(wrapper.find('[data-test-subj="os-selection-dropdown"]').exists()).toBeFalsy(); + + it('enables add to shared lists option if rule has shared list', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); - describe('when there is alert data passed to a detection list exception', () => { + /* Say for example, add exception item from rules bulk action */ + describe('when multiple rules are passed in - bulk action', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; await waitFor(() => - callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should not prepopulate endpoint items', () => { - expect(defaultEndpointItems).not.toHaveBeenCalled(); + + it('allows large value lists', () => { + const shallowWrapper = shallow( + + ); + + expect(shallowWrapper.find('ExceptionsConditions').prop('allowLargeValueLists')).toBeTruthy(); }); - it('should render the close on add exception checkbox', () => { + + it('defaults to selecting add to rules radio option', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="exceptionItemAddToRuleOrListSection"]').exists() ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="addToRulesOptionsRadio"] input').getDOMNode() + ).toBeChecked(); }); - it('should have the bulk close checkbox disabled', () => { + + it('disables add to shared lists option if rules have no shared lists in common', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() ).toBeDisabled(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('enables add to shared lists option if rules have at least one shared list in common', () => { + wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="addToListsRadioOption"] input').getDOMNode() + ).toBeEnabled(); }); }); describe('when there is an exception being created on a sequence eql rule type', () => { let wrapper: ReactWrapper; beforeEach(async () => { - mockUseRuleAsync.mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; wrapper = mount( ); @@ -316,177 +1163,58 @@ describe('When the add exception modal is opened', () => { callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] }) ); }); - it('has the add exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="alertExceptionBuilder"]').exists()).toBeTruthy(); }); + it('should not prepopulate endpoint items', () => { expect(defaultEndpointItems).not.toHaveBeenCalled(); }); - it('should render the close on add exception checkbox', () => { + + it('should render the close single alert checkbox', () => { expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + wrapper.find('[data-test-subj="closeAlertOnAddExceptionCheckbox"]').exists() ).toBeTruthy(); }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); + it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); }); - describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => { - let wrapper: ReactWrapper; - - beforeEach(async () => { - mockUseFetchIndex.mockImplementation(() => [ - false, - { - indexPatterns: createStubIndexPattern({ - spec: { - id: '1234', - title: 'filebeat-*', - fields: { - 'event.code': { - name: 'event.code', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.path.caseless': { - name: 'file.path.caseless', - type: 'string', - aggregatable: true, - searchable: true, - }, - subject_name: { - name: 'subject_name', - type: 'string', - aggregatable: true, - searchable: true, - }, - trusted: { - name: 'trusted', - type: 'string', - aggregatable: true, - searchable: true, - }, - 'file.hash.sha256': { - name: 'file.hash.sha256', - type: 'string', - aggregatable: true, - searchable: true, - }, - }, - }, - }), - }, - ]); - - const alertDataMock: AlertData = { - '@timestamp': '1234567890', - _id: 'test-id', - file: { path: 'test/path' }, - }; - wrapper = mount( + describe('error states', () => { + test('when there are exception builder errors submit button is disabled', async () => { + const wrapper = mount( ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - return callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the add exception button enabled', async () => { + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); - it('should render the exception builder', () => { - expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); - }); - it('should prepopulate endpoint items', () => { - expect(defaultEndpointItems).toHaveBeenCalled(); - }); - it('should render the close on add exception checkbox', () => { - expect( - wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() - ).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); - }); - it('should have the bulk close checkbox enabled', () => { - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).not.toBeDisabled(); - }); - describe('when a "is in list" entry is added', () => { - it('should have the bulk close checkbox disabled', async () => { - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - - await waitFor(() => - callProps.onChange({ - exceptionItems: [ - ...callProps.exceptionListItems, - { - ...getExceptionListItemSchemaMock(), - entries: [ - { field: 'event.code', operator: 'included', type: 'list' }, - ] as EntriesArray, - }, - ], - }) - ); - - expect( - wrapper - .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); - }); + wrapper.find('button[data-test-subj="addExceptionConfirmButton"]').getDOMNode() + ).toBeDisabled(); }); }); - - test('when there are exception builder errors submit button is disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - expect( - wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index c7b7050f23ffe..6d190913e358d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -5,13 +5,10 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint complexity: ["error", 35]*/ - -import React, { memo, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { memo, useEffect, useCallback, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; -import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + import { EuiFlyout, EuiFlyoutHeader, @@ -21,62 +18,52 @@ import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, - EuiComboBox, EuiFlexGroup, + EuiLoadingContent, + EuiCallOut, + EuiText, } from '@elastic/eui'; -import type { - CreateExceptionListItemSchema, - ExceptionListItemSchema, - ExceptionListType, - OsTypeArray, -} from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { OsTypeArray, ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; + import type { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import * as i18nCommon from '../../../../common/translations'; import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { Loader } from '../../../../common/components/loader'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; -import { useFetchOrCreateRuleExceptionList } from '../../logic/use_fetch_or_create_rule_exception_list'; -import { ExceptionItemComments } from '../item_comments'; import { - enrichNewExceptionItemsWithComments, - enrichExceptionItemsWithOS, - lowercaseHashValues, defaultEndpointExceptionItems, - entryHasListType, - entryHasNonEcsType, retrieveAlertOsTypes, filterIndexPatterns, } from '../../utils/helpers'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; import type { AlertData } from '../../utils/types'; -import { useFetchIndex } from '../../../../common/containers/source'; +import { initialState, createExceptionItemsReducer } from './reducer'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsAddToRulesOrLists } from '../flyout_components/add_exception_to_rule_or_list'; +import { useAddNewExceptionItems } from './use_add_new_exceptions'; +import { entrichNewExceptionItems } from '../flyout_components/utils'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; +import { ExceptionItemComments } from '../item_comments'; + +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; + `} +`; export interface AddExceptionFlyoutProps { - ruleName: string; - ruleId: string; - exceptionListType: ExceptionListType; - ruleIndices: string[]; - dataViewId?: string; + rules: Rule[] | null; + isBulkAction: boolean; + showAlertCloseOptions: boolean; + isEndpointItem: boolean; alertData?: AlertData; /** * The components that use this may or may not define `alertData` @@ -86,40 +73,22 @@ export interface AddExceptionFlyoutProps { */ isAlertDataLoading?: boolean; alertStatus?: Status; - onCancel: () => void; - onConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - onRuleChange?: () => void; + onCancel: (didRuleChange: boolean) => void; + onConfirm: (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; } -const FlyoutHeader = styled(EuiFlyoutHeader)` - ${({ theme }) => css` - border-bottom: 1px solid ${theme.eui.euiColorLightShade}; - `} -`; - -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; - `} -`; - -const FlyoutBodySection = styled.section` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` &.builder-section { overflow-y: scroll; } `} `; -const FlyoutCheckboxesSection = styled(EuiFlyoutBody)` - overflow-y: inherit; - height: auto; - - .euiFlyoutBody__overflowContent { - padding-top: 0; - } +const FlyoutHeader = styled(EuiFlyoutHeader)` + ${({ theme }) => css` + border-bottom: 1px solid ${theme.eui.euiColorLightShade}; + `} `; const FlyoutFooterGroup = styled(EuiFlexGroup)` @@ -129,487 +98,430 @@ const FlyoutFooterGroup = styled(EuiFlexGroup)` `; export const AddExceptionFlyout = memo(function AddExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionListType, + rules, + isBulkAction, + isEndpointItem, alertData, + showAlertCloseOptions, isAlertDataLoading, + alertStatus, onCancel, onConfirm, - onRuleChange, - alertStatus, }: AddExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [errorsExist, setErrorExists] = useState(false); - const [comment, setComment] = useState(''); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [shouldCloseAlert, setShouldCloseAlert] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); - const { addError, addSuccess, addWarning } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitNewExceptionItems] = useAddNewExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); + + const allowLargeValueLists = useMemo((): boolean => { + if (rules != null && rules.length === 1) { + // We'll only block this when we know what rule we're dealing with. + // When dealing with numerous rules that can be a mix of those that do and + // don't work with large value lists we'll need to communicate that to the + // user but not block. + return ruleTypesThatAllowLargeValueLists.includes(rules[0].type); + } else { + return true; + } + }, [rules]); - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); - - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; + const [ + { + exceptionItemMeta: { name: exceptionItemName }, + listType, + selectedOs, + initialItems, + exceptionItems, + disableBulkClose, + bulkCloseAlerts, + closeSingleAlert, + bulkCloseIndex, + addExceptionToRadioSelection, + selectedRulesToAddTo, + exceptionListsToAddTo, + newComment, + itemConditionValidationErrorExists, + errorSubmitting, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + ...initialState, + addExceptionToRadioSelection: isBulkAction + ? 'add_to_rules' + : rules != null && rules.length === 1 + ? 'add_to_rule' + : 'select_rules_to_add_to', + listType: isEndpointItem ? ExceptionListTypeEnum.ENDPOINT : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: rules != null ? rules : [], + }); - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); + const hasAlertData = useMemo((): boolean => { + return alertData != null; + }, [alertData]); - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices + /** + * Reducer action dispatchers + * */ + const setInitialExceptionItems = useCallback( + (items: ExceptionsBuilderExceptionItem[]): void => { + dispatch({ + type: 'setInitialExceptionItems', + items, + }); + }, + [dispatch] ); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setRadioOption = useCallback( + (option: string): void => { + dispatch({ + type: 'setListOrRuleRadioOption', + option, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const handleRuleChange = useCallback( - (ruleChanged: boolean): void => { - if (ruleChanged && onRuleChange) { - onRuleChange(); - } + const setSelectedRules = useCallback( + (rulesSelectedToAdd: Rule[]): void => { + dispatch({ + type: 'setSelectedRulesToAddTo', + rules: rulesSelectedToAdd, + }); }, - [onRuleChange] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - handleRuleChange(true); - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - onCancel(); + const setListsToAddExceptionTo = useCallback( + (lists: ExceptionListSchema[]): void => { + dispatch({ + type: 'setAddExceptionToLists', + listsToAddTo: lists, + }); }, - [handleRuleChange, addSuccess, onCancel] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addError, onCancel] + [dispatch] ); - const onError = useCallback( - (error: Error): void => { - addError(error, { title: i18n.ADD_EXCEPTION_ERROR }); - onCancel(); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [addError, onCancel] + [dispatch] ); - const onSuccess = useCallback( - (updated: number, conflicts: number): void => { - handleRuleChange(true); - addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert, shouldBulkCloseAlert); - if (conflicts > 0) { - addWarning({ - title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } + const setSelectedOs = useCallback( + (os: OsTypeArray | undefined): void => { + dispatch({ + type: 'setSelectedOsOptions', + selectedOs: os, + }); }, - [addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert, handleRuleChange] + [dispatch] ); - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess, - onError, - } + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); + }, + [dispatch] ); - const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null): void => { - setFetchOrCreateListError({ - reason: error.message, - code: statusCode, - details: message, - listListId: null, + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, }); }, - [setFetchOrCreateListError] + [dispatch] ); - const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({ - http, - ruleId, - exceptionListType, - onError: handleFetchOrCreateExceptionListError, - onSuccess: handleRuleChange, - }); - - const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { - if (exceptionListType === 'endpoint' && alertData != null && ruleExceptionList) { - return defaultEndpointExceptionItems(ruleExceptionList.list_id, ruleName, alertData); - } else { - return []; - } - }, [exceptionListType, ruleExceptionList, ruleName, alertData]); - - useEffect((): void => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect((): void => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const onCommentChange = useCallback( - (value: string): void => { - setComment(value); + const setCloseSingleAlert = useCallback( + (close: boolean): void => { + dispatch({ + type: 'setCloseSingleAlert', + close, + }); }, - [setComment] + [dispatch] ); - const onCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldCloseAlert(event.currentTarget.checked); + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); }, - [setShouldCloseAlert] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent): void => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); }, - [setShouldBulkCloseAlert] + [dispatch] ); - const hasAlertData = useMemo((): boolean => { - return alertData !== undefined; - }, [alertData]); + const setErrorSubmitting = useCallback( + (err: Error | null): void => { + dispatch({ + type: 'setErrorSubmitting', + err, + }); + }, + [dispatch] + ); - const [selectedOs, setSelectedOs] = useState(); + useEffect((): void => { + if (listType === ExceptionListTypeEnum.ENDPOINT && alertData != null) { + setInitialExceptionItems( + defaultEndpointExceptionItems(ENDPOINT_LIST_ID, exceptionItemName, alertData) + ); + } + }, [listType, exceptionItemName, alertData, setInitialExceptionItems]); const osTypesSelection = useMemo((): OsTypeArray => { return hasAlertData ? retrieveAlertOsTypes(alertData) : selectedOs ? [...selectedOs] : []; }, [hasAlertData, alertData, selectedOs]); - const enrichExceptionItems = useCallback((): ExceptionsBuilderReturnExceptionItem[] => { - let enriched: ExceptionsBuilderReturnExceptionItem[] = []; - enriched = - comment !== '' - ? enrichNewExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) - : exceptionItemsToAdd; - if (exceptionListType === 'endpoint') { - const osTypes = osTypesSelection; - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); - } - return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, osTypesSelection]); - - const onAddExceptionConfirm = useCallback((): void => { - if (addOrUpdateExceptionItems != null) { - const alertIdToClose = shouldCloseAlert && alertData ? alertData._id : undefined; - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName != null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - alertIdToClose, - bulkCloseIndex - ); + const handleOnSubmit = useCallback(async (): Promise => { + if (submitNewExceptionItems == null) return; + + try { + const ruleDefaultOptions = ['add_to_rule', 'add_to_rules', 'select_rules_to_add_to']; + const addToRules = ruleDefaultOptions.includes(addExceptionToRadioSelection); + const addToSharedLists = addExceptionToRadioSelection === 'add_to_lists'; + + const items = entrichNewExceptionItems({ + itemName: exceptionItemName, + commentToAdd: newComment, + addToRules, + addToSharedLists, + sharedLists: exceptionListsToAddTo, + listType, + selectedOs: osTypesSelection, + items: exceptionItems, + }); + + const addedItems = await submitNewExceptionItems({ + itemsToAdd: items, + selectedRulesToAddTo, + listType, + addToRules: addToRules && !isEmpty(selectedRulesToAddTo), + addToSharedLists: addToSharedLists && !isEmpty(exceptionListsToAddTo), + sharedLists: exceptionListsToAddTo, + }); + + const alertIdToClose = closeSingleAlert && alertData ? alertData._id : undefined; + const ruleStaticIds = addToRules + ? selectedRulesToAddTo.map(({ rule_id: ruleId }) => ruleId) + : (rules ?? []).map(({ rule_id: ruleId }) => ruleId); + + if (closeAlerts != null && !isEmpty(ruleStaticIds) && (bulkCloseAlerts || closeSingleAlert)) { + await closeAlerts(ruleStaticIds, addedItems, alertIdToClose, bulkCloseIndex); + } + + // Rule only would have been updated if we had to create a rule default list + // to attach to it, all shared lists would already be referenced on the rule + onConfirm(true, closeSingleAlert, bulkCloseAlerts); + } catch (e) { + setErrorSubmitting(e); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldCloseAlert, - shouldBulkCloseAlert, + submitNewExceptionItems, + addExceptionToRadioSelection, + exceptionItemName, + newComment, + exceptionListsToAddTo, + listType, + osTypesSelection, + exceptionItems, + selectedRulesToAddTo, + closeSingleAlert, alertData, - signalIndexName, + rules, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + setErrorSubmitting, ]); const isSubmitButtonDisabled = useMemo( (): boolean => - fetchOrCreateListError != null || - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - errorsExist, - [fetchOrCreateListError, exceptionItemsToAdd, errorsExist] - ); - - const addExceptionMessage = - exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION; - - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const OsOptions: Array> = useMemo((): Array< - EuiComboBoxOptionOption - > => { - return [ - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS, - value: ['windows'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_MAC, - value: ['macos'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_LINUX, - value: ['linux'], - }, - { - label: sharedI18n.OPERATING_SYSTEM_WINDOWS_AND_MAC, - value: ['windows', 'macos'], - }, - ]; - }, []); - - const handleOSSelectionChange = useCallback( - (selectedOptions): void => { - setSelectedOs(selectedOptions[0].value); - }, - [setSelectedOs] + isSubmitting || + isClosingAlerts || + errorSubmitting != null || + exceptionItemName.trim() === '' || + exceptionItems.every((item) => item.entries.length === 0) || + itemConditionValidationErrorExists || + (addExceptionToRadioSelection === 'add_to_lists' && isEmpty(exceptionListsToAddTo)), + [ + isSubmitting, + isClosingAlerts, + errorSubmitting, + exceptionItemName, + exceptionItems, + itemConditionValidationErrorExists, + addExceptionToRadioSelection, + exceptionListsToAddTo, + ] ); - const selectedOStoOptions = useMemo((): Array> => { - return OsOptions.filter((option) => { - return selectedOs === option.value; - }); - }, [selectedOs, OsOptions]); - - const singleSelectionOptions = useMemo(() => { - return { asPlainText: true }; - }, []); + const handleDismissError = useCallback((): void => { + setErrorSubmitting(null); + }, [setErrorSubmitting]); - const hasOsSelection = useMemo(() => { - return exceptionListType === 'endpoint' && !hasAlertData; - }, [exceptionListType, hasAlertData]); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); - const isExceptionBuilderFormDisabled = useMemo(() => { - return hasOsSelection && selectedOs === undefined; - }, [hasOsSelection, selectedOs]); - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] - ); + const addExceptionMessage = useMemo(() => { + return listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.ADD_ENDPOINT_EXCEPTION + : i18n.CREATE_RULE_EXCEPTION; + }, [listType]); return ( -

{addExceptionMessage}

+

{addExceptionMessage}

- - - {ruleName} -
- {fetchOrCreateListError != null && ( - - } + {!isLoading && ( + + {errorSubmitting != null && ( + <> + + {i18n.SUBMIT_ERROR_DISMISS_MESSAGE} + + + {i18n.SUBMIT_ERROR_DISMISS_BUTTON} + + + + + )} + - - )} - {fetchOrCreateListError == null && - (isLoadingExceptionList || - isIndexPatternLoading || - isSignalIndexLoading || - isAlertDataLoading || - isSignalIndexPatternLoading) && ( - - )} - {fetchOrCreateListError == null && - indexPattern != null && - !isSignalIndexLoading && - !isSignalIndexPatternLoading && - !isLoadingExceptionList && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && - !isAlertDataLoading && - ruleExceptionList && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && !hasAlertData && ( - <> - - - - - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: initialExceptionItems, - listType: exceptionListType, - osTypes: osTypesSelection, - listId: ruleExceptionList.list_id, - listNamespaceType: ruleExceptionList.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - indexPatterns: indexPattern, - isOrDisabled: isExceptionBuilderFormDisabled, - isAndDisabled: isExceptionBuilderFormDisabled, - isNestedDisabled: isExceptionBuilderFormDisabled, - dataTestSubj: 'alert-exception-builder', - idAria: 'alert-exception-builder', - onChange: handleBuilderOnChange, - isDisabled: isExceptionBuilderFormDisabled, - })} - - - - + + + {listType !== ExceptionListTypeEnum.ENDPOINT && ( + <> + + + + )} + + +

{i18n.COMMENTS_SECTION_TITLE(0)}

+ + } + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( + <> + + -
- - - {alertData != null && alertStatus !== 'closed' && ( - - - - )} - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {fetchOrCreateListError == null && ( - - - - {i18n.CANCEL} - - - - {addExceptionMessage} - - - + + )} + )} + + + + {i18n.CANCEL} + + + + {addExceptionMessage} + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts new file mode 100644 index 0000000000000..3e5f8afd19626 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/reducer.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExceptionListSchema, OsTypeArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ExceptionsBuilderExceptionItem, + ExceptionsBuilderReturnExceptionItem, +} from '@kbn/securitysolution-list-utils'; + +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; + +export interface State { + exceptionItemMeta: { name: string }; + listType: ExceptionListTypeEnum; + initialItems: ExceptionsBuilderExceptionItem[]; + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + newComment: string; + addExceptionToRadioSelection: string; + itemConditionValidationErrorExists: boolean; + closeSingleAlert: boolean; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + selectedOs: OsTypeArray | undefined; + exceptionListsToAddTo: ExceptionListSchema[]; + selectedRulesToAddTo: Rule[]; + errorSubmitting: Error | null; +} + +export const initialState: State = { + initialItems: [], + exceptionItems: [], + exceptionItemMeta: { name: '' }, + newComment: '', + itemConditionValidationErrorExists: false, + closeSingleAlert: false, + bulkCloseAlerts: false, + disableBulkClose: false, + bulkCloseIndex: undefined, + selectedOs: undefined, + exceptionListsToAddTo: [], + addExceptionToRadioSelection: 'add_to_rule', + selectedRulesToAddTo: [], + listType: ExceptionListTypeEnum.RULE_DEFAULT, + errorSubmitting: null, +}; + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setInitialExceptionItems'; + items: ExceptionsBuilderExceptionItem[]; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setCloseSingleAlert'; + close: boolean; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setSelectedOsOptions'; + selectedOs: OsTypeArray | undefined; + } + | { + type: 'setAddExceptionToLists'; + listsToAddTo: ExceptionListSchema[]; + } + | { + type: 'setListOrRuleRadioOption'; + option: string; + } + | { + type: 'setSelectedRulesToAddTo'; + rules: Rule[]; + } + | { + type: 'setListType'; + listType: ExceptionListTypeEnum; + } + | { + type: 'setErrorSubmitting'; + err: Error | null; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setInitialExceptionItems': { + const { items } = action; + + return { + ...state, + initialItems: items, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + itemConditionValidationErrorExists: errorExists, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setCloseSingleAlert': { + const { close } = action; + + return { + ...state, + closeSingleAlert: close, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setSelectedOsOptions': { + const { selectedOs } = action; + + return { + ...state, + selectedOs, + }; + } + case 'setAddExceptionToLists': { + const { listsToAddTo } = action; + + return { + ...state, + exceptionListsToAddTo: listsToAddTo, + }; + } + case 'setListOrRuleRadioOption': { + const { option } = action; + + return { + ...state, + addExceptionToRadioSelection: option, + listType: + option === 'add_to_lists' + ? ExceptionListTypeEnum.DETECTION + : ExceptionListTypeEnum.RULE_DEFAULT, + selectedRulesToAddTo: option === 'add_to_lists' ? [] : state.selectedRulesToAddTo, + }; + } + case 'setSelectedRulesToAddTo': { + const { rules } = action; + + return { + ...state, + selectedRulesToAddTo: rules, + }; + } + case 'setListType': { + const { listType } = action; + + return { + ...state, + listType, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setErrorSubmitting': { + const { err } = action; + + return { + ...state, + errorSubmitting: err, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts index fe0b316648214..eea7f90d07e5c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/translations.ts @@ -7,79 +7,79 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.addException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.addException.cancel', { defaultMessage: 'Cancel', }); -export const ADD_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addException', +export const CREATE_RULE_EXCEPTION = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.createRuleExceptionLabel', { - defaultMessage: 'Add Rule Exception', + defaultMessage: 'Add rule exception', } ); export const ADD_ENDPOINT_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addException.addEndpointException', + 'xpack.securitySolution.ruleExceptions.addException.addEndpointException', { defaultMessage: 'Add Endpoint Exception', } ); -export const ADD_EXCEPTION_ERROR = i18n.translate( - 'xpack.securitySolution.exceptions.addException.error', +export const SUBMIT_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.title', { - defaultMessage: 'Failed to add exception', + defaultMessage: 'An error occured submitting exception', } ); -export const ADD_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.addException.success', +export const SUBMIT_ERROR_DISMISS_BUTTON = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.dismissButton', { - defaultMessage: 'Successfully added exception', + defaultMessage: 'Dismiss', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.addException.endpointQuarantineText', +export const SUBMIT_ERROR_DISMISS_MESSAGE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.submitError.message', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'View toast for error details.', } ); -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel', +export const ADD_EXCEPTION_SUCCESS = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addException.success', { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', + defaultMessage: 'Rule exception added to shared exception list', } ); -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled', - { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', - } -); +export const ADD_EXCEPTION_SUCCESS_DETAILS = (listNames: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.closeAlerts.successDetails', + { + values: { listNames }, + defaultMessage: 'Rule exception has been added to shared lists: {listNames}.', + } + ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.addException.infoLabel', +export const ADD_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessTitle', { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", + defaultMessage: 'Rule exception added', } ); -export const ADD_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.addException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception created will apply to all events in the sequence.", - } -); +export const ADD_RULE_EXCEPTION_SUCCESS_TEXT = (ruleName: string) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.addExceptionFlyout.addRuleExceptionToastSuccessText', + { + values: { ruleName }, + defaultMessage: 'Exception has been added to rules - {ruleName}.', + } + ); -export const OPERATING_SYSTEM_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder', - { - defaultMessage: 'Select an operating system', - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.addExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts new file mode 100644 index 0000000000000..909d8e8580472 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/use_add_new_exceptions.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + createExceptionListItemSchema, + exceptionListItemSchema, + ExceptionListTypeEnum, + createRuleExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; +import { useAddRuleDefaultException } from '../../logic/use_add_rule_exception'; + +export interface AddNewExceptionItemHookProps { + itemsToAdd: ExceptionsBuilderReturnExceptionItem[]; + listType: ExceptionListTypeEnum; + selectedRulesToAddTo: Rule[]; + addToSharedLists: boolean; + addToRules: boolean; + sharedLists: ExceptionListSchema[]; +} + +export type AddNewExceptionItemHookFuncProps = ( + arg: AddNewExceptionItemHookProps +) => Promise; + +export type ReturnUseAddNewExceptionItems = [boolean, AddNewExceptionItemHookFuncProps | null]; + +/** + * Hook for adding new exception items from flyout + * + */ +export const useAddNewExceptionItems = (): ReturnUseAddNewExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddRuleExceptionLoading, addRuleExceptions] = useAddRuleDefaultException(); + const [isAddingExceptions, addSharedExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const addNewExceptionsRef = useRef(null); + + const areRuleDefaultItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is CreateRuleExceptionListItemSchema[] => { + return items.every((item) => createRuleExceptionListItemSchema.is(item)); + }, + [] + ); + + const areSharedListItems = useCallback( + ( + items: ExceptionsBuilderReturnExceptionItem[] + ): items is Array => { + return items.every( + (item) => exceptionListItemSchema.is(item) || createExceptionListItemSchema.is(item) + ); + }, + [] + ); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addNewExceptions = async ({ + itemsToAdd, + listType, + selectedRulesToAddTo, + addToRules, + addToSharedLists, + sharedLists, + }: AddNewExceptionItemHookProps): Promise => { + try { + let result: ExceptionListItemSchema[] = []; + setIsLoading(true); + + if ( + addToRules && + addRuleExceptions != null && + listType !== ExceptionListTypeEnum.ENDPOINT && + areRuleDefaultItems(itemsToAdd) + ) { + result = await addRuleExceptions(itemsToAdd, selectedRulesToAddTo); + + const ruleNames = selectedRulesToAddTo.map(({ name }) => name).join(', '); + + addSuccess({ + title: i18n.ADD_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.ADD_RULE_EXCEPTION_SUCCESS_TEXT(ruleNames), + }); + } else if ( + (listType === ExceptionListTypeEnum.ENDPOINT || addToSharedLists) && + addSharedExceptions != null && + areSharedListItems(itemsToAdd) + ) { + result = await addSharedExceptions(itemsToAdd); + + const sharedListNames = sharedLists.map(({ name }) => name); + + addSuccess({ + title: i18n.ADD_EXCEPTION_SUCCESS, + text: i18n.ADD_EXCEPTION_SUCCESS_DETAILS(sharedListNames.join(',')), + }); + } + + setIsLoading(false); + + return result; + } catch (e) { + setIsLoading(false); + addError(e, { title: i18n.SUBMIT_ERROR_TITLE }); + throw e; + } + }; + + addNewExceptionsRef.current = addNewExceptions; + return (): void => { + abortCtrl.abort(); + }; + }, [ + addSuccess, + addError, + addWarning, + addRuleExceptions, + addSharedExceptions, + areRuleDefaultItems, + areSharedListItems, + ]); + + return [ + isLoading || isAddingExceptions || isAddRuleExceptionLoading, + addNewExceptionsRef.current, + ]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx index 0df9fad55a14d..2caf7d352a733 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/all_items.test.tsx @@ -10,7 +10,6 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionsViewerItems } from './all_items'; import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; @@ -31,7 +30,7 @@ describe('ExceptionsViewerItems', () => { { ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy(); }); @@ -55,7 +54,7 @@ describe('ExceptionsViewerItems', () => { { void; @@ -42,7 +39,7 @@ interface ExceptionItemsViewerProps { const ExceptionItemsViewerComponent: React.FC = ({ isReadOnly, exceptions, - listType, + isEndpoint, disableActions, ruleReferences, viewerState, @@ -55,7 +52,7 @@ const ExceptionItemsViewerComponent: React.FC = ({ {viewerState != null && viewerState !== 'deleting' ? ( @@ -68,8 +65,8 @@ const ExceptionItemsViewerComponent: React.FC = ({ { const wrapper = mount( @@ -33,7 +31,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { const wrapper = mount( @@ -44,11 +42,11 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "isEndpoint" is "true"', () => { const wrapper = mount( @@ -61,15 +59,15 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "isEndpoint" is "false"', () => { const wrapper = mount( @@ -82,7 +80,7 @@ describe('ExeptionItemsViewerEmptyPrompts', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx index 2be1860f138d3..b00bf7dd75134 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/empty_viewer_state.tsx @@ -15,21 +15,20 @@ import { EuiPanel, } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import * as i18n from './translations'; import type { ViewerState } from './reducer'; import illustration from '../../../../common/images/illustration_product_no_results_magnifying_glass.svg'; interface ExeptionItemsViewerEmptyPromptsComponentProps { isReadOnly: boolean; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; currentState: ViewerState; onCreateExceptionListItem: () => void; } const ExeptionItemsViewerEmptyPromptsComponent = ({ isReadOnly, - listType, + isEndpoint, currentState, onCreateExceptionListItem, }: ExeptionItemsViewerEmptyPromptsComponentProps): JSX.Element => { @@ -60,7 +59,7 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ } body={

- {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY : i18n.EXCEPTION_EMPTY_PROMPT_BODY}

@@ -74,12 +73,12 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ isDisabled={isReadOnly} fill > - {listType === ExceptionListTypeEnum.ENDPOINT + {isEndpoint ? i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON : i18n.EXCEPTION_EMPTY_PROMPT_BUTTON} , ]} - data-test-subj={`exceptionItemViewerEmptyPrompts-empty-${listType}`} + data-test-subj="exceptionItemViewerEmptyPrompts-empty" /> ); case 'empty_search': @@ -100,7 +99,13 @@ const ExeptionItemsViewerEmptyPromptsComponent = ({ ); } - }, [currentState, euiTheme.colors.darkestShade, isReadOnly, listType, onCreateExceptionListItem]); + }, [ + currentState, + euiTheme.colors.darkestShade, + isReadOnly, + isEndpoint, + onCreateExceptionListItem, + ]); return ( { }, }); - (useFindExceptionListReferences as jest.Mock).mockReturnValue([false, null]); + (useFindExceptionListReferences as jest.Mock).mockReturnValue([ + false, + false, + { + list_id: { + _version: 'WzEzNjMzLDFd', + created_at: '2022-09-26T19:41:43.338Z', + created_by: 'elastic', + description: + 'Exception list containing exceptions for rule with id: 178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + immutable: false, + list_id: 'list_id', + name: 'Exceptions for rule - My really good rule', + namespace_type: 'single', + os_types: [], + tags: ['default_rule_exception_list'], + tie_breaker_id: '83395c3e-76a0-466e-ba58-2f5a4b8b5444', + type: 'rule_default', + updated_at: '2022-09-26T19:41:43.342Z', + updated_by: 'elastic', + version: 1, + referenced_rules: [ + { + name: 'My really good rule', + id: '178c2e10-3dd3-11ed-81d7-37f31b5b97f6', + rule_id: 'cc604877-838b-438d-866b-8bce5237aa07', + exception_lists: [ + { + id: '3fa2c8a0-3dd3-11ed-81d7-37f31b5b97f6', + list_id: 'list_id', + type: 'rule_default', + namespace_type: 'single', + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); it('it renders loading screen when "currentState" is "loading"', () => { @@ -108,7 +148,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -146,7 +186,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -157,7 +197,7 @@ describe('ExceptionsViewer', () => { ).toBeTruthy(); }); - it('it renders no endpoint items screen when "currentState" is "empty" and "listType" is "endpoint"', () => { + it('it renders no endpoint items screen when "currentState" is "empty" and "listTypes" includes only "endpoint"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -184,7 +224,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.ENDPOINT} + listTypes={[ExceptionListTypeEnum.ENDPOINT]} isViewReadOnly={false} /> @@ -197,11 +237,11 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-endpoint"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); - it('it renders no exception items screen when "currentState" is "empty" and "listType" is "detection"', () => { + it('it renders no exception items screen when "currentState" is "empty" and "listTypes" includes "detection"', () => { (useReducer as jest.Mock).mockReturnValue([ { exceptions: [], @@ -228,7 +268,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> @@ -241,7 +281,7 @@ describe('ExceptionsViewer', () => { i18n.EXCEPTION_EMPTY_PROMPT_BUTTON ); expect( - wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty-detection"]').exists() + wrapper.find('[data-test-subj="exceptionItemViewerEmptyPrompts-empty"]').exists() ).toBeTruthy(); }); @@ -271,7 +311,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); @@ -305,7 +345,7 @@ describe('ExceptionsViewer', () => { }, ], }} - listType={ExceptionListTypeEnum.DETECTION} + listTypes={[ExceptionListTypeEnum.DETECTION]} isViewReadOnly={false} /> ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index 97de081738ffd..6de23a76981b8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -6,22 +6,24 @@ */ import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import styled from 'styled-components'; + import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionListItemSchema, UseExceptionListItemsSuccess, Pagination, + ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { transformInput } from '@kbn/securitysolution-list-hooks'; import { deleteExceptionListItemById, fetchExceptionListsItemsByListIds, } from '@kbn/securitysolution-list-api'; -import styled from 'styled-components'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; + import { useUserData } from '../../../../detections/components/user_info'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { ExceptionsViewerSearchBar } from './search_bar'; @@ -74,7 +76,7 @@ export interface GetExceptionItemProps { interface ExceptionsViewerProps { rule: Rule | null; - listType: ExceptionListTypeEnum; + listTypes: ExceptionListTypeEnum[]; /* Used for when displaying exceptions for a rule that has since been deleted, forcing read only view */ isViewReadOnly: boolean; onRuleChange?: () => void; @@ -82,7 +84,7 @@ interface ExceptionsViewerProps { const ExceptionsViewerComponent = ({ rule, - listType, + listTypes, isViewReadOnly, onRuleChange, }: ExceptionsViewerProps): JSX.Element => { @@ -92,9 +94,24 @@ const ExceptionsViewerComponent = ({ const exceptionListsToQuery = useMemo( () => rule != null && rule.exceptions_list != null - ? rule.exceptions_list.filter((list) => list.type === listType) + ? rule.exceptions_list.filter(({ type }) => + listTypes.includes(type as ExceptionListTypeEnum) + ) : [], - [listType, rule] + [listTypes, rule] + ); + const exceptionListsFormattedForReferenceQuery = useMemo( + () => + exceptionListsToQuery.map(({ id, list_id: listId, namespace_type: namespaceType }) => ({ + id, + listId, + namespaceType, + })), + [exceptionListsToQuery] + ); + const isEndpointSpecified = useMemo( + () => listTypes.length === 1 && listTypes[0] === ExceptionListTypeEnum.ENDPOINT, + [listTypes] ); // Reducer state @@ -166,13 +183,10 @@ const ExceptionsViewerComponent = ({ useFindExceptionListReferences(); useEffect(() => { - if (fetchReferences != null && exceptionListsToQuery.length) { - const listsToQuery = exceptionListsToQuery.map( - ({ id, list_id: listId, namespace_type: namespaceType }) => ({ id, listId, namespaceType }) - ); - fetchReferences(listsToQuery); + if (fetchReferences != null && exceptionListsFormattedForReferenceQuery.length) { + fetchReferences(exceptionListsFormattedForReferenceQuery); } - }, [exceptionListsToQuery, fetchReferences]); + }, [exceptionListsFormattedForReferenceQuery, fetchReferences]); useEffect(() => { if (isFetchReferencesError) { @@ -241,6 +255,7 @@ const ExceptionsViewerComponent = ({ async (options?: GetExceptionItemProps) => { try { const { pageIndex, itemsPerPage, total, data } = await handleFetchItems(options); + setViewerState(total > 0 ? null : 'empty'); setExceptions({ exceptions: data, @@ -306,15 +321,26 @@ const ExceptionsViewerComponent = ({ [setFlyoutType] ); - const handleCancelExceptionItemFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleCancelExceptionItemFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + }, + [onRuleChange, setFlyoutType] + ); - const handleConfirmExceptionFlyout = useCallback((): void => { - setFlyoutType(null); - handleGetExceptionListItems(); - }, [setFlyoutType, handleGetExceptionListItems]); + const handleConfirmExceptionFlyout = useCallback( + (didRuleChange: boolean): void => { + setFlyoutType(null); + if (didRuleChange && onRuleChange != null) { + onRuleChange(); + } + handleGetExceptionListItems(); + }, + [setFlyoutType, handleGetExceptionListItems, onRuleChange] + ); const handleDeleteException = useCallback( async ({ id: itemId, name, namespaceType }: ExceptionListItemIdentifiers) => { @@ -360,49 +386,53 @@ const ExceptionsViewerComponent = ({ } }, [exceptionListsToQuery.length, handleGetExceptionListItems, setViewerState]); + const exceptionToEditList = useMemo( + (): ExceptionListSchema | null => + allReferences != null && exceptionToEdit != null + ? (allReferences[exceptionToEdit.list_id] as ExceptionListSchema) + : null, + [allReferences, exceptionToEdit] + ); + return ( <> - {currenFlyout === 'editException' && exceptionToEdit != null && rule != null && ( - - )} + {currenFlyout === 'editException' && + exceptionToEditList != null && + exceptionToEdit != null && + rule != null && ( + + )} {currenFlyout === 'addException' && rule != null && ( )} <> - {listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT - : i18n.EXCEPTIONS_TAB_ABOUT} + {isEndpointSpecified ? i18n.ENDPOINT_EXCEPTIONS_TAB_ABOUT : i18n.EXCEPTIONS_TAB_ABOUT} {!STATES_SEARCH_HIDDEN.includes(viewerState) && ( { it('it does not display add exception button if user is read only', () => { const wrapper = mount( { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add rule exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('detection'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); it('it invokes "onAddExceptionClick" when user selects to add an endpoint exception item', () => { @@ -52,7 +50,7 @@ describe('ExceptionsViewerSearchBar', () => { const wrapper = mount( { expect(wrapper.find('[data-test-subj="exceptionsHeaderAddExceptionBtn"]').at(0).text()).toEqual( 'Add endpoint exception' ); - expect(mockOnAddExceptionClick).toHaveBeenCalledWith('endpoint'); + expect(mockOnAddExceptionClick).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx index ec85567baef42..36dac931265c2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/search_bar.tsx @@ -8,8 +8,6 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiSearchBar } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import * as sharedI18n from '../../utils/translations'; import * as i18n from './translations'; import type { GetExceptionItemProps } from '.'; @@ -47,10 +45,10 @@ interface ExceptionsViewerSearchBarProps { canAddException: boolean; // Exception list type used to determine what type of item is // being created when "onAddExceptionClick" is invoked - listType: ExceptionListTypeEnum; + isEndpoint: boolean; isSearching: boolean; onSearch: (arg: GetExceptionItemProps) => void; - onAddExceptionClick: (type: ExceptionListTypeEnum) => void; + onAddExceptionClick: () => void; } /** @@ -58,7 +56,7 @@ interface ExceptionsViewerSearchBarProps { */ const ExceptionsViewerSearchBarComponent = ({ canAddException, - listType, + isEndpoint, isSearching, onSearch, onAddExceptionClick, @@ -71,14 +69,12 @@ const ExceptionsViewerSearchBarComponent = ({ ); const handleAddException = useCallback(() => { - onAddExceptionClick(listType); - }, [onAddExceptionClick, listType]); + onAddExceptionClick(); + }, [onAddExceptionClick]); const addExceptionButtonText = useMemo(() => { - return listType === ExceptionListTypeEnum.ENDPOINT - ? sharedI18n.ADD_TO_ENDPOINT_LIST - : sharedI18n.ADD_TO_DETECTIONS_LIST; - }, [listType]); + return isEndpoint ? i18n.ADD_TO_ENDPOINT_LIST : i18n.ADD_TO_DETECTIONS_LIST; + }, [isEndpoint]); return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts index 221143a1e0b64..6e50d5d28fe3e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/translations.ts @@ -8,63 +8,63 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptTitle', { defaultMessage: 'No results match your search criteria', } ); export const EXCEPTION_NO_SEARCH_RESULTS_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.noSearchResultsPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.noSearchResultsPromptBody', { defaultMessage: 'Try modifying your search.', } ); export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.addExceptionsEmptyPromptTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addExceptionsEmptyPromptTitle', { defaultMessage: 'Add exceptions to this rule', } ); export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptBody', { defaultMessage: 'There are no exceptions for this rule. Create your first rule exception.', } ); export const EXCEPTION_EMPTY_ENDPOINT_PROMPT_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptBody', { defaultMessage: 'There are no endpoint exceptions. Create your first endpoint exception.', } ); export const EXCEPTION_EMPTY_PROMPT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.emptyPromptButtonLabel', { defaultMessage: 'Add rule exception', } ); export const EXCEPTION_EMPTY_PROMPT_ENDPOINT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.endpoint.emptyPromptButtonLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.endpoint.emptyPromptButtonLabel', { defaultMessage: 'Add endpoint exception', } ); export const EXCEPTION_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchError', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchError', { defaultMessage: 'Unable to load exception items', } ); export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemsFetchErrorDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemsFetchErrorDescription', { defaultMessage: 'There was an error loading the exception items. Contact your administrator for help.', @@ -72,48 +72,51 @@ export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( ); export const EXCEPTION_SEARCH_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorTitle', { defaultMessage: 'Error searching', } ); export const EXCEPTION_SEARCH_ERROR_BODY = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemSearchErrorBody', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemSearchErrorBody', { defaultMessage: 'An error occurred searching for exception items. Please try again.', } ); export const EXCEPTION_DELETE_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionDeleteErrorTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDeleteErrorTitle', { defaultMessage: 'Error deleting exception item', } ); export const EXCEPTION_ITEMS_PAGINATION_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.paginationAriaLabel', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.paginationAriaLabel', { defaultMessage: 'Exception item table pagination', } ); export const EXCEPTION_ITEM_DELETE_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessTitle', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessTitle', { defaultMessage: 'Exception deleted', } ); export const EXCEPTION_ITEM_DELETE_TEXT = (itemName: string) => - i18n.translate('xpack.securitySolution.exceptions.allItems.exceptionItemDeleteSuccessText', { - values: { itemName }, - defaultMessage: '"{itemName}" deleted successfully.', - }); + i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionItemDeleteSuccessText', + { + values: { itemName }, + defaultMessage: '"{itemName}" deleted successfully.', + } + ); export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionEndpointDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionEndpointDetailsDescription', { defaultMessage: 'Endpoint exceptions are added to both the detection rule and the Elastic Endpoint agent on your hosts.', @@ -121,15 +124,29 @@ export const ENDPOINT_EXCEPTIONS_TAB_ABOUT = i18n.translate( ); export const EXCEPTIONS_TAB_ABOUT = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.exceptionDetectionDetailsDescription', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.exceptionDetectionDetailsDescription', { defaultMessage: 'Rule exceptions are added to the detection rule.', } ); export const SEARCH_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.allExceptionItems.searchPlaceholder', + 'xpack.securitySolution.ruleExceptions.allExceptionItems.searchPlaceholder', { defaultMessage: 'Filter exceptions using simple query syntax, for example, name:"my list"', } ); + +export const ADD_TO_ENDPOINT_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToEndpointListLabel', + { + defaultMessage: 'Add endpoint exception', + } +); + +export const ADD_TO_DETECTIONS_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.allExceptionItems.addToDetectionsListLabel', + { + defaultMessage: 'Add rule exception', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx index aa604dbfbf001..d9fe0218311c5 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/utility_bar.test.tsx @@ -6,14 +6,14 @@ */ import React from 'react'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { mount } from 'enzyme'; import { ExceptionsViewerUtility } from './utility_bar'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionsViewerUtility', () => { it('it renders correct item counts', () => { - const wrapper = mountWithIntl( + const wrapper = mount( { }); it('it renders last updated message', () => { - const wrapper = mountWithIntl( + const wrapper = mount( >; const mockUseSignalIndex = useSignalIndex as jest.Mock>>; -const mockUseAddOrUpdateException = useAddOrUpdateException as jest.Mock< - ReturnType ->; const mockUseFetchIndex = useFetchIndex as jest.Mock; const mockUseCurrentUser = useCurrentUser as jest.Mock>>; -const mockUseRuleAsync = useRuleAsync as jest.Mock; +const mockFetchIndexPatterns = useFetchIndexPatterns as jest.Mock< + ReturnType +>; +const mockUseAddOrUpdateException = useCreateOrUpdateException as jest.Mock< + ReturnType +>; +const mockUseFindExceptionListReferences = useFindExceptionListReferences as jest.Mock; describe('When the edit exception modal is opened', () => { - const ruleName = 'test rule'; - beforeEach(() => { const emptyComp = ; mockGetExceptionBuilderComponentLazy.mockReturnValue(emptyComp); @@ -66,19 +76,42 @@ describe('When the edit exception modal is opened', () => { loading: false, signalIndexName: 'test-signal', }); - mockUseAddOrUpdateException.mockImplementation(() => [{ isLoading: false }, jest.fn()]); + mockUseAddOrUpdateException.mockImplementation(() => [false, jest.fn()]); mockUseFetchIndex.mockImplementation(() => [ false, { indexPatterns: createStubIndexPattern({ spec: { id: '1234', - title: 'logstash-*', + title: 'filebeat-*', fields: { - response: { - name: 'response', - type: 'number', - esTypes: ['integer'], + 'event.code': { + name: 'event.code', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.path.caseless': { + name: 'file.path.caseless', + type: 'string', + aggregatable: true, + searchable: true, + }, + subject_name: { + name: 'subject_name', + type: 'string', + aggregatable: true, + searchable: true, + }, + trusted: { + name: 'trusted', + type: 'string', + aggregatable: true, + searchable: true, + }, + 'file.hash.sha256': { + name: 'file.hash.sha256', + type: 'string', aggregatable: true, searchable: true, }, @@ -88,9 +121,40 @@ describe('When the edit exception modal is opened', () => { }, ]); mockUseCurrentUser.mockReturnValue({ username: 'test-username' }); - mockUseRuleAsync.mockImplementation(() => ({ - rule: getRulesSchemaMock(), + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: false, + indexPatterns: stubIndexPattern, })); + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.DETECTION, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); }); afterEach(() => { @@ -100,24 +164,22 @@ describe('When the edit exception modal is opened', () => { describe('when the modal is loading', () => { it('renders the loading spinner', async () => { - mockUseFetchIndex.mockImplementation(() => [ - true, - { - indexPatterns: stubIndexPattern, - }, - ]); + // Mocks one of the hooks as loading + mockFetchIndexPatterns.mockImplementation(() => ({ + isLoading: true, + indexPatterns: { fields: [], title: 'foo' }, + })); + const wrapper = mount( - + - + ); await waitFor(() => { expect(wrapper.find('[data-test-subj="loadingEditExceptionFlyout"]').exists()).toBeTruthy(); @@ -125,69 +187,198 @@ describe('When the edit exception modal is opened', () => { }); }); - describe('when an endpoint exception with exception data is passed', () => { - describe('when exception entry fields are included in the index pattern', () => { + describe('exception list type of "endpoint"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + endpoint_list: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'endpoint_list', + namespace_type: 'agnostic', + type: ExceptionListTypeEnum.ENDPOINT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + namespace_type: 'single', + type: ExceptionListTypeEnum.ENDPOINT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + + describe('common functionality to test', () => { + let wrapper: ReactWrapper; + beforeEach(async () => { + wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); + }); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + ); + }); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); + }); + + it('should render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeTruthy(); + }); + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list or rule item assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeTruthy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).not.toBeTruthy(); + }); + }); + + describe('when exception entry fields and index allow user to bulk close', () => { let wrapper: ReactWrapper; beforeEach(async () => { const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [ - { field: 'response', operator: 'included', type: 'match', value: '3' }, + { field: 'file.hash.sha256', operator: 'included', type: 'match' }, ] as EntriesArray, }; wrapper = mount( ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); - }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); + it('should have the bulk close checkbox enabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).not.toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); - describe("when exception entry fields aren't included in the index pattern", () => { + describe('when entry has non ecs type', () => { let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( ); @@ -196,182 +387,310 @@ describe('When the edit exception modal is opened', () => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); - }); + it('should have the bulk close checkbox disabled', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); - }); - it('should contain the endpoint specific documentation text', () => { - expect( - wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() - ).toBeTruthy(); - }); }); }); - describe('when an exception assigned to a sequence eql rule type is passed', () => { + describe('exception list type of "detection"', () => { let wrapper: ReactWrapper; beforeEach(async () => { - (useRuleAsync as jest.Mock).mockImplementation(() => ({ - rule: { - ...getRulesEqlSchemaMock(), - query: - 'sequence [process where process.name = "test.exe"] [process where process.name = "explorer.exe"]', - }, - })); wrapper = mount( - + - + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) ); - const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeTruthy(); }); - it('should display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).toBeTruthy(); + + it('does NOT render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeFalsy(); + }); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when a detection exception with entries is passed', () => { + describe('exception list type of "rule_default"', () => { + mockUseFindExceptionListReferences.mockImplementation(() => [ + false, + false, + { + my_list_id: { + ...getExceptionListSchemaMock(), + id: '123', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + name: 'My exception list', + referenced_rules: [ + { + id: '345', + name: 'My rule', + rule_id: 'my_rule_id', + exception_lists: [ + { + id: '1234', + list_id: 'my_list_id', + namespace_type: 'single', + type: ExceptionListTypeEnum.RULE_DEFAULT, + }, + ], + }, + ], + }, + }, + jest.fn(), + ]); + let wrapper: ReactWrapper; beforeEach(async () => { wrapper = mount( - + - + ); const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => { - callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); - }); + await waitFor(() => + callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }) + ); }); - it('has the edit exception button enabled', () => { - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).not.toBeDisabled(); + + it('displays proper flyout and button text', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutTitle"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); + expect(wrapper.find('[data-test-subj="editExceptionConfirmButton"]').at(1).text()).toEqual( + i18n.EDIT_EXCEPTION_TITLE + ); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should render item name input', () => { + expect(wrapper.find('[data-test-subj="exceptionFlyoutNameInput"]').exists()).toBeTruthy(); }); - it('should not contain the endpoint specific documentation text', () => { - expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + + it('should not render OS info', () => { + expect(wrapper.find('[data-test-subj="exceptionItemSelectedOs"]').exists()).toBeFalsy(); }); - it('should have the bulk close checkbox disabled', () => { + + it('should render the exception builder', () => { + expect(wrapper.find('ExceptionsConditions').exists()).toBeTruthy(); + }); + + it('does NOT render section showing list item is assigned to', () => { expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() - ).toBeDisabled(); + wrapper.find('[data-test-subj="exceptionItemLinkedToListSection"]').exists() + ).toBeFalsy(); + }); + + it('does render section showing rule item is assigned to', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemLinkedToRuleSection"]').exists() + ).toBeTruthy(); }); - it('should not display the eql sequence callout', () => { - expect(wrapper.find('[data-test-subj="eql-sequence-callout"]').exists()).not.toBeTruthy(); + + it('should NOT contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="addExceptionEndpointText"]').exists()).toBeFalsy(); + }); + + it('should NOT display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeFalsy(); }); }); - describe('when an exception with no entries is passed', () => { + describe('when an exception assigned to a sequence eql rule type is passed', () => { let wrapper: ReactWrapper; beforeEach(async () => { - const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; wrapper = mount( - + - + ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + const callProps = (getExceptionBuilderComponentLazy as jest.Mock).mock.calls[0][0]; await waitFor(() => { callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] }); }); }); - it('has the edit exception button disabled', () => { + + it('should have the bulk close checkbox disabled', () => { expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + wrapper.find('input[data-test-subj="bulkCloseAlertOnAddExceptionCheckbox"]').getDOMNode() ).toBeDisabled(); }); - it('renders the exceptions builder', () => { - expect(wrapper.find('[data-test-subj="edit-exception-builder"]').exists()).toBeTruthy(); + + it('should display the eql sequence callout', () => { + expect(wrapper.find('[data-test-subj="eqlSequenceCallout"]').exists()).toBeTruthy(); }); - it('should have the bulk close checkbox disabled', () => { + }); + + describe('error states', () => { + test('when there are exception builder errors has submit button disabled', async () => { + const wrapper = mount( + + + + ); + const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; + await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); + expect( - wrapper - .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') - .getDOMNode() + wrapper.find('button[data-test-subj="editExceptionConfirmButton"]').getDOMNode() ).toBeDisabled(); }); }); - - test('when there are exception builder errors has the add exception button disabled', async () => { - const wrapper = mount( - - - - ); - const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0]; - await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true })); - - expect( - wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() - ).toBeDisabled(); - }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx index 85a59f06281f1..d6dbc402a92e6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/index.tsx @@ -5,75 +5,61 @@ * 2.0. */ -// Component being re-implemented in 8.5 - -/* eslint-disable complexity */ - -import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import styled, { css } from 'styled-components'; import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, - EuiCheckbox, EuiSpacer, - EuiFormRow, - EuiText, - EuiCallOut, EuiFlyoutHeader, EuiFlyoutBody, EuiFlexGroup, EuiTitle, EuiFlyout, EuiFlyoutFooter, + EuiLoadingContent, } from '@elastic/eui'; import type { - ExceptionListType, - OsTypeArray, - OsType, ExceptionListItemSchema, - CreateExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + ExceptionListTypeEnum, + exceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public'; -import type { DataViewBase } from '@kbn/es-query'; +import { isEmpty } from 'lodash/fp'; import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; -import { useRuleIndices } from '../../../../detections/containers/detection_engine/rules/use_rule_indices'; -import { hasEqlSequenceQuery, isEqlRule } from '../../../../../common/detection_engine/utils'; -import { useFetchIndex } from '../../../../common/containers/source'; -import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async'; - import * as i18n from './translations'; -import * as sharedI18n from '../../utils/translations'; -import { useKibana } from '../../../../common/lib/kibana'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useAddOrUpdateException } from '../../logic/use_add_exception'; -import { ExceptionItemComments } from '../item_comments'; +import { ExceptionsFlyoutMeta } from '../flyout_components/item_meta_form'; +import { createExceptionItemsReducer } from './reducer'; +import { ExceptionsLinkedToLists } from '../flyout_components/linked_to_list'; +import { ExceptionsLinkedToRule } from '../flyout_components/linked_to_rule'; +import type { Rule } from '../../../../detections/containers/detection_engine/rules/types'; +import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; +import { ExceptionsConditions } from '../flyout_components/item_conditions'; import { - enrichExistingExceptionItemWithComments, - enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, - lowercaseHashValues, - filterIndexPatterns, -} from '../../utils/helpers'; -import { Loader } from '../../../../common/components/loader'; -import type { ErrorInfo } from '../error_callout'; -import { ErrorCallout } from '../error_callout'; -import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; + isEqlRule, + isNewTermsRule, + isThresholdRule, +} from '../../../../../common/detection_engine/utils'; +import { useFetchIndexPatterns } from '../../logic/use_exception_flyout_data'; +import { filterIndexPatterns } from '../../utils/helpers'; +import { entrichExceptionItemsForUpdate } from '../flyout_components/utils'; +import { useEditExceptionItems } from './use_edit_exception'; +import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; +import { useFindExceptionListReferences } from '../../logic/use_find_references'; +import { ExceptionItemComments } from '../item_comments'; interface EditExceptionFlyoutProps { - ruleName: string; - ruleId: string; - ruleIndices: string[]; - dataViewId?: string; - exceptionItem: ExceptionListItemSchema; - exceptionListType: ExceptionListType; - onCancel: () => void; - onConfirm: () => void; - onRuleChange?: () => void; + list: ExceptionListSchema; + itemToEdit: ExceptionListItemSchema; + showAlertCloseOptions: boolean; + rule?: Rule; + onCancel: (arg: boolean) => void; + onConfirm: (arg: boolean) => void; } const FlyoutHeader = styled(EuiFlyoutHeader)` @@ -82,412 +68,335 @@ const FlyoutHeader = styled(EuiFlyoutHeader)` `} `; -const FlyoutSubtitle = styled.div` - ${({ theme }) => css` - color: ${theme.eui.euiColorMediumShade}; +const FlyoutBodySection = styled(EuiFlyoutBody)` + ${() => css` + &.builder-section { + overflow-y: scroll; + } `} `; -const FlyoutBodySection = styled.section` +const FlyoutFooterGroup = styled(EuiFlexGroup)` ${({ theme }) => css` - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; + padding: ${theme.eui.euiSizeS}; `} `; -const FlyoutCheckboxesSection = styled.section` - overflow-y: inherit; - height: auto; - .euiFlyoutBody__overflowContent { - padding-top: 0; - } -`; - -const FlyoutFooterGroup = styled(EuiFlexGroup)` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeS}; +const SectionHeader = styled(EuiTitle)` + ${() => css` + font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold}; `} `; -export const EditExceptionFlyout = memo(function EditExceptionFlyout({ - ruleName, - ruleId, - ruleIndices, - dataViewId, - exceptionItem, - exceptionListType, +const EditExceptionFlyoutComponent: React.FC = ({ + list, + itemToEdit, + rule, + showAlertCloseOptions, onCancel, onConfirm, - onRuleChange, -}: EditExceptionFlyoutProps) { - const { http, unifiedSearch, data } = useKibana().services; - const [comment, setComment] = useState(''); - const [errorsExist, setErrorExists] = useState(false); - const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); - const [updateError, setUpdateError] = useState(null); - const [hasVersionConflict, setHasVersionConflict] = useState(false); - const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false); - const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false); - const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState< - ExceptionsBuilderReturnExceptionItem[] - >([]); - const { addError, addSuccess } = useAppToasts(); - const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); - const memoSignalIndexName = useMemo( - () => (signalIndexName !== null ? [signalIndexName] : []), - [signalIndexName] - ); - const [isSignalIndexPatternLoading, { indexPatterns: signalIndexPatterns }] = - useFetchIndex(memoSignalIndexName); - - const { mlJobLoading, ruleIndices: memoRuleIndices } = useRuleIndices( - maybeRule?.machine_learning_job_id, - ruleIndices - ); +}): JSX.Element => { + const selectedOs = useMemo(() => itemToEdit.os_types, [itemToEdit]); + const rules = useMemo(() => (rule != null ? [rule] : null), [rule]); + const listType = useMemo((): ExceptionListTypeEnum => list.type as ExceptionListTypeEnum, [list]); - const hasDataViewId = dataViewId || maybeRule?.data_view_id || null; - const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + const { isLoading, indexPatterns } = useFetchIndexPatterns(rules); + const [isSubmitting, submitEditExceptionItems] = useEditExceptionItems(); + const [isClosingAlerts, closeAlerts] = useCloseAlertsFromExceptions(); - useEffect(() => { - const fetchSingleDataView = async () => { - if (hasDataViewId) { - const dv = await data.dataViews.get(hasDataViewId); - setDataViewIndexPatterns(dv); - } - }; - - fetchSingleDataView(); - }, [hasDataViewId, data.dataViews, setDataViewIndexPatterns]); - - // Don't fetch indices if rule has data view id (currently rule can technically have - // both defined and in that case we'd be doing unnecessary work here if all we want is - // the data view fields) - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( - hasDataViewId ? [] : memoRuleIndices - ); + const [ + { + exceptionItems, + exceptionItemMeta: { name: exceptionItemName }, + newComment, + bulkCloseAlerts, + disableBulkClose, + bulkCloseIndex, + entryErrorExists, + }, + dispatch, + ] = useReducer(createExceptionItemsReducer(), { + exceptionItems: [itemToEdit], + exceptionItemMeta: { name: itemToEdit.name }, + newComment: '', + bulkCloseAlerts: false, + disableBulkClose: true, + bulkCloseIndex: undefined, + entryErrorExists: false, + }); + + const allowLargeValueLists = useMemo((): boolean => { + if (rule != null) { + // We'll only block this when we know what rule we're dealing with. + // When editing an item outside the context of a specific rule, + // we won't block but should communicate to the user that large value lists + // won't be applied to all rule types. + return !isEqlRule(rule.type) && !isThresholdRule(rule.type) && !isNewTermsRule(rule.type); + } else { + return true; + } + }, [rule]); - const indexPattern = useMemo( - (): DataViewBase | null => (hasDataViewId ? dataViewIndexPatterns : indexIndexPatterns), - [hasDataViewId, dataViewIndexPatterns, indexIndexPatterns] - ); + const [isLoadingReferences, referenceFetchError, ruleReferences, fetchReferences] = + useFindExceptionListReferences(); - const handleExceptionUpdateError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { - if (error.message.includes('Conflict')) { - setHasVersionConflict(true); - } else { - setUpdateError({ - reason: error.message, - code: statusCode, - details: message, - listListId: exceptionItem.list_id, - }); - } + useEffect(() => { + if (fetchReferences != null) { + fetchReferences([ + { + id: list.id, + listId: list.list_id, + namespaceType: list.namespace_type, + }, + ]); + } + }, [list, fetchReferences]); + + /** + * Reducer action dispatchers + * */ + const setExceptionItemsToAdd = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): void => { + dispatch({ + type: 'setExceptionItems', + items, + }); }, - [setUpdateError, setHasVersionConflict, exceptionItem.list_id] + [dispatch] ); - const handleDissasociationSuccess = useCallback( - (id: string): void => { - addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id)); - - if (onRuleChange) { - onRuleChange(); - } - - onCancel(); + const setExceptionItemMeta = useCallback( + (value: [string, string]): void => { + dispatch({ + type: 'setExceptionItemMeta', + value, + }); }, - [addSuccess, onCancel, onRuleChange] + [dispatch] ); - const handleDissasociationError = useCallback( - (error: Error): void => { - addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR }); - onCancel(); + const setComment = useCallback( + (comment: string): void => { + dispatch({ + type: 'setComment', + comment, + }); }, - [addError, onCancel] + [dispatch] ); - const handleExceptionUpdateSuccess = useCallback((): void => { - addSuccess(i18n.EDIT_EXCEPTION_SUCCESS); - onConfirm(); - }, [addSuccess, onConfirm]); - - const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( - { - http, - onSuccess: handleExceptionUpdateSuccess, - onError: handleExceptionUpdateError, - } + const setBulkCloseAlerts = useCallback( + (bulkClose: boolean): void => { + dispatch({ + type: 'setBulkCloseAlerts', + bulkClose, + }); + }, + [dispatch] ); - useEffect(() => { - if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { - setShouldDisableBulkClose( - entryHasListType(exceptionItemsToAdd) || - entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.every((item) => item.entries.length === 0) - ); - } - }, [ - setShouldDisableBulkClose, - exceptionItemsToAdd, - isSignalIndexPatternLoading, - isSignalIndexLoading, - signalIndexPatterns, - ]); - - useEffect(() => { - if (shouldDisableBulkClose === true) { - setShouldBulkCloseAlert(false); - } - }, [shouldDisableBulkClose]); - - const isSubmitButtonDisabled = useMemo( - () => - exceptionItemsToAdd.every((item) => item.entries.length === 0) || - hasVersionConflict || - errorsExist, - [exceptionItemsToAdd, hasVersionConflict, errorsExist] + const setDisableBulkCloseAlerts = useCallback( + (disableBulkCloseAlerts: boolean): void => { + dispatch({ + type: 'setDisableBulkCloseAlerts', + disableBulkCloseAlerts, + }); + }, + [dispatch] ); - const handleBuilderOnChange = useCallback( - ({ - exceptionItems, - errorExists, - }: { - exceptionItems: ExceptionsBuilderReturnExceptionItem[]; - errorExists: boolean; - }) => { - setExceptionItemsToAdd(exceptionItems); - setErrorExists(errorExists); + const setBulkCloseIndex = useCallback( + (index: string[] | undefined): void => { + dispatch({ + type: 'setBulkCloseIndex', + bulkCloseIndex: index, + }); }, - [setExceptionItemsToAdd] + [dispatch] ); - const onCommentChange = useCallback( - (value: string) => { - setComment(value); + const setConditionsValidationError = useCallback( + (errorExists: boolean): void => { + dispatch({ + type: 'setConditionValidationErrorExists', + errorExists, + }); }, - [setComment] + [dispatch] ); - const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { - setShouldBulkCloseAlert(event.currentTarget.checked); + const handleCloseFlyout = useCallback((): void => { + onCancel(false); + }, [onCancel]); + + const areItemsReadyForUpdate = useCallback( + (items: ExceptionsBuilderReturnExceptionItem[]): items is ExceptionListItemSchema[] => { + return items.every((item) => exceptionListItemSchema.is(item)); }, - [setShouldBulkCloseAlert] + [] ); - const enrichExceptionItems = useCallback(() => { - const [exceptionItemToEdit] = exceptionItemsToAdd; - let enriched: ExceptionsBuilderReturnExceptionItem[] = [ - { - ...enrichExistingExceptionItemWithComments(exceptionItemToEdit, [ - ...exceptionItem.comments, - ...(comment.trim() !== '' ? [{ comment }] : []), - ]), - }, - ]; - if (exceptionListType === 'endpoint') { - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, exceptionItem.os_types)); - } - return enriched; - }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); - - const onEditExceptionConfirm = useCallback(() => { - if (addOrUpdateExceptionItems !== null) { - const bulkCloseIndex = - shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; - addOrUpdateExceptionItems( - maybeRule?.rule_id ?? '', - // This is being rewritten in https://github.com/elastic/kibana/pull/140643 - // As of now, flyout cannot yet create item of type CreateRuleExceptionListItemSchema - enrichExceptionItems() as Array, - undefined, - bulkCloseIndex - ); + const handleSubmit = useCallback(async (): Promise => { + if (submitEditExceptionItems == null) return; + + try { + const items = entrichExceptionItemsForUpdate({ + itemName: exceptionItemName, + commentToAdd: newComment, + listType, + selectedOs: itemToEdit.os_types, + items: exceptionItems, + }); + + if (areItemsReadyForUpdate(items)) { + await submitEditExceptionItems({ + itemsToUpdate: items, + }); + + const ruleDefaultRule = rule != null ? [rule.rule_id] : []; + const referencedRules = + ruleReferences != null + ? ruleReferences[list.list_id].referenced_rules.map(({ rule_id: ruleId }) => ruleId) + : []; + const ruleIdsForBulkClose = + listType === ExceptionListTypeEnum.RULE_DEFAULT ? ruleDefaultRule : referencedRules; + + if (closeAlerts != null && !isEmpty(ruleIdsForBulkClose) && bulkCloseAlerts) { + await closeAlerts(ruleIdsForBulkClose, items, undefined, bulkCloseIndex); + } + + onConfirm(true); + } + } catch (e) { + onCancel(false); } }, [ - addOrUpdateExceptionItems, - maybeRule, - enrichExceptionItems, - shouldBulkCloseAlert, - signalIndexName, + submitEditExceptionItems, + exceptionItemName, + newComment, + listType, + itemToEdit.os_types, + exceptionItems, + areItemsReadyForUpdate, + rule, + ruleReferences, + list.list_id, + closeAlerts, + bulkCloseAlerts, + onConfirm, + bulkCloseIndex, + onCancel, ]); - const isRuleEQLSequenceStatement = useMemo((): boolean => { - if (maybeRule != null) { - return isEqlRule(maybeRule.type) && hasEqlSequenceQuery(maybeRule.query); - } - return false; - }, [maybeRule]); - - const osDisplay = (osTypes: OsTypeArray): string => { - const translateOS = (currentOs: OsType): string => { - return currentOs === 'linux' - ? sharedI18n.OPERATING_SYSTEM_LINUX - : currentOs === 'macos' - ? sharedI18n.OPERATING_SYSTEM_MAC - : sharedI18n.OPERATING_SYSTEM_WINDOWS; - }; - return osTypes - .reduce((osString, currentOs) => { - return `${translateOS(currentOs)}, ${osString}`; - }, '') - .slice(0, -2); - }; - - const allowLargeValueLists = useMemo( - () => (maybeRule != null ? ruleTypesThatAllowLargeValueLists.includes(maybeRule.type) : false), - [maybeRule] + const editExceptionMessage = useMemo( + () => + listType === ExceptionListTypeEnum.ENDPOINT + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE, + [listType] + ); + + const isSubmitButtonDisabled = useMemo( + () => + isSubmitting || + isClosingAlerts || + exceptionItems.every((item) => item.entries.length === 0) || + isLoading || + entryErrorExists, + [isLoading, entryErrorExists, exceptionItems, isSubmitting, isClosingAlerts] ); return ( - + -

- {exceptionListType === 'endpoint' - ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE - : i18n.EDIT_EXCEPTION_TITLE} -

+

{editExceptionMessage}

- -
- {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( - - )} - {!isSignalIndexLoading && - indexPattern != null && - !addExceptionIsLoading && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && ( - - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - {exceptionListType === 'endpoint' && ( - <> - -
-
{sharedI18n.OPERATING_SYSTEM_LABEL}
-
{osDisplay(exceptionItem.os_types)}
-
-
- - - )} - {getExceptionBuilderComponentLazy({ - allowLargeValueLists, - httpService: http, - autocompleteService: unifiedSearch.autocomplete, - exceptionListItems: [exceptionItem], - listType: exceptionListType, - listId: exceptionItem.list_id, - listNamespaceType: exceptionItem.namespace_type, - listTypeSpecificIndexPatternFilter: filterIndexPatterns, - ruleName, - isOrDisabled: true, - isAndDisabled: false, - osTypes: exceptionItem.os_types, - isNestedDisabled: false, - dataTestSubj: 'edit-exception-builder', - idAria: 'edit-exception-builder', - onChange: handleBuilderOnChange, - indexPatterns: indexPattern, - })} - - - - -
+ {isLoading && } + + + + + {listType === ExceptionListTypeEnum.DETECTION && ( + <> - - - - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - -
+ + )} - - - {hasVersionConflict && ( + {listType === ExceptionListTypeEnum.RULE_DEFAULT && rule != null && ( <> - -

{i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

-
- + + )} - {updateError != null && ( + + +

{i18n.COMMENTS_SECTION_TITLE(itemToEdit.comments.length ?? 0)}

+ + } + exceptionItemComments={itemToEdit.comments} + newCommentValue={newComment} + newCommentOnChange={setComment} + /> + {showAlertCloseOptions && ( <> - + - )} - {updateError === null && ( - - - {i18n.CANCEL} - - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - - )} + + + + + {i18n.CANCEL} + + + + {editExceptionMessage} + +
); -}); +}; + +export const EditExceptionFlyout = React.memo(EditExceptionFlyoutComponent); + +EditExceptionFlyout.displayName = 'EditExceptionFlyout'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts new file mode 100644 index 0000000000000..22fefe760e4aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/reducer.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExceptionsBuilderReturnExceptionItem } from '@kbn/securitysolution-list-utils'; + +export interface State { + exceptionItems: ExceptionsBuilderReturnExceptionItem[]; + exceptionItemMeta: { name: string }; + newComment: string; + bulkCloseAlerts: boolean; + disableBulkClose: boolean; + bulkCloseIndex: string[] | undefined; + entryErrorExists: boolean; +} + +export type Action = + | { + type: 'setExceptionItemMeta'; + value: [string, string]; + } + | { + type: 'setComment'; + comment: string; + } + | { + type: 'setBulkCloseAlerts'; + bulkClose: boolean; + } + | { + type: 'setDisableBulkCloseAlerts'; + disableBulkCloseAlerts: boolean; + } + | { + type: 'setBulkCloseIndex'; + bulkCloseIndex: string[] | undefined; + } + | { + type: 'setExceptionItems'; + items: ExceptionsBuilderReturnExceptionItem[]; + } + | { + type: 'setConditionValidationErrorExists'; + errorExists: boolean; + }; + +export const createExceptionItemsReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptionItemMeta': { + const { value } = action; + + return { + ...state, + exceptionItemMeta: { + ...state.exceptionItemMeta, + [value[0]]: value[1], + }, + }; + } + case 'setComment': { + const { comment } = action; + + return { + ...state, + newComment: comment, + }; + } + case 'setBulkCloseAlerts': { + const { bulkClose } = action; + + return { + ...state, + bulkCloseAlerts: bulkClose, + }; + } + case 'setDisableBulkCloseAlerts': { + const { disableBulkCloseAlerts } = action; + + return { + ...state, + disableBulkClose: disableBulkCloseAlerts, + }; + } + case 'setBulkCloseIndex': { + const { bulkCloseIndex } = action; + + return { + ...state, + bulkCloseIndex, + }; + } + case 'setExceptionItems': { + const { items } = action; + + return { + ...state, + exceptionItems: items, + }; + } + case 'setConditionValidationErrorExists': { + const { errorExists } = action; + + return { + ...state, + entryErrorExists: errorExists, + }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts index 6a5fd6f44810c..9839fa5ddc9de 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/translations.ts @@ -7,87 +7,50 @@ import { i18n } from '@kbn/i18n'; -export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editException.cancel', { +export const CANCEL = i18n.translate('xpack.securitySolution.ruleExceptions.editException.cancel', { defaultMessage: 'Cancel', }); -export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionSaveButton', - { - defaultMessage: 'Save', - } -); - export const EDIT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editExceptionTitle', + 'xpack.securitySolution.ruleExceptions.editException.editExceptionTitle', { - defaultMessage: 'Edit Rule Exception', + defaultMessage: 'Edit rule exception', } ); export const EDIT_ENDPOINT_EXCEPTION_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle', - { - defaultMessage: 'Edit Endpoint Exception', - } -); - -export const EDIT_EXCEPTION_SUCCESS = i18n.translate( - 'xpack.securitySolution.exceptions.editException.success', - { - defaultMessage: 'Successfully updated exception', - } -); - -export const BULK_CLOSE_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel', - { - defaultMessage: 'Close all alerts that match this exception and were generated by this rule', - } -); - -export const BULK_CLOSE_LABEL_DISABLED = i18n.translate( - 'xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled', + 'xpack.securitySolution.ruleExceptions.editException.editEndpointExceptionTitle', { - defaultMessage: - 'Close all alerts that match this exception and were generated by this rule (Lists and non-ECS fields are not supported)', + defaultMessage: 'Edit endpoint exception', } ); -export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( - 'xpack.securitySolution.exceptions.editException.endpointQuarantineText', +export const EDIT_RULE_EXCEPTION_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessTitle', { - defaultMessage: - 'On all Endpoint hosts, quarantined files that match the exception are automatically restored to their original locations. This exception applies to all rules using Endpoint exceptions.', + defaultMessage: 'Rule exception updated', } ); -export const EXCEPTION_BUILDER_INFO = i18n.translate( - 'xpack.securitySolution.exceptions.editException.infoLabel', - { - defaultMessage: "Alerts are generated when the rule's conditions are met, except when:", - } -); +export const EDIT_RULE_EXCEPTION_SUCCESS_TEXT = (exceptionItemName: string, numItems: number) => + i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastSuccessText', + { + values: { exceptionItemName, numItems }, + defaultMessage: + '{numItems, plural, =1 {Exception} other {Exceptions}} - {exceptionItemName} - {numItems, plural, =1 {has} other {have}} been updated.', + } + ); -export const VERSION_CONFLICT_ERROR_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictTitle', +export const EDIT_RULE_EXCEPTION_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.ruleExceptions.editException.editRuleExceptionToastErrorTitle', { - defaultMessage: 'Sorry, there was an error', + defaultMessage: 'Error updating exception', } ); -export const VERSION_CONFLICT_ERROR_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.editException.versionConflictDescription', - { - defaultMessage: - "It appears this exception was updated since you first selected to edit it. Try clicking 'Cancel' and editing the exception again.", - } -); - -export const EDIT_EXCEPTION_SEQUENCE_WARNING = i18n.translate( - 'xpack.securitySolution.exceptions.editException.sequenceWarning', - { - defaultMessage: - "This rule's query contains an EQL sequence statement. The exception modified will apply to all events in the sequence.", - } -); +export const COMMENTS_SECTION_TITLE = (comments: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.editExceptionFlyout.commentsTitle', { + values: { comments }, + defaultMessage: 'Add comments ({comments})', + }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx new file mode 100644 index 0000000000000..e5dd0dc4844dc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/edit_exception_flyout/use_edit_exception.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import * as i18n from './translations'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useCreateOrUpdateException } from '../../logic/use_create_update_exception'; + +export interface EditExceptionItemHookProps { + itemsToUpdate: ExceptionListItemSchema[]; +} + +export type EditExceptionItemHookFuncProps = (arg: EditExceptionItemHookProps) => Promise; + +export type ReturnUseEditExceptionItems = [boolean, EditExceptionItemHookFuncProps | null]; + +/** + * Hook for editing exception items from flyout + * + */ +export const useEditExceptionItems = (): ReturnUseEditExceptionItems => { + const { addSuccess, addError, addWarning } = useAppToasts(); + const [isAddingExceptions, updateExceptions] = useCreateOrUpdateException(); + + const [isLoading, setIsLoading] = useState(false); + const updateExceptionsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const updateExceptionItem = async ({ itemsToUpdate }: EditExceptionItemHookProps) => { + if (updateExceptions == null) return; + + try { + setIsLoading(true); + + await updateExceptions(itemsToUpdate); + + addSuccess({ + title: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TITLE, + text: i18n.EDIT_RULE_EXCEPTION_SUCCESS_TEXT( + itemsToUpdate.map(({ name }) => name).join(', '), + itemsToUpdate.length + ), + }); + + if (isSubscribed) { + setIsLoading(false); + } + } catch (e) { + if (isSubscribed) { + setIsLoading(false); + addError(e, { title: i18n.EDIT_RULE_EXCEPTION_ERROR_TITLE }); + throw new Error(e); + } + } + }; + + updateExceptionsRef.current = updateExceptionItem; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning, updateExceptions]); + + return [isLoading || isAddingExceptions, updateExceptionsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx index f80372dc11283..1697a5d5b0bc7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.test.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { mount } from 'enzyme'; -import { ExceptionItemCard } from '.'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../../common/mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { TestProviders } from '../../../../common/mock'; +import { ExceptionItemCard } from '.'; + jest.mock('../../../../common/lib/kibana'); describe('ExceptionItemCard', () => { @@ -28,8 +28,8 @@ describe('ExceptionItemCard', () => { onDeleteException={jest.fn()} onEditException={jest.fn()} exceptionItem={exceptionItem} - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -77,8 +77,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -125,8 +125,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -169,8 +169,8 @@ describe('ExceptionItemCard', () => { onEditException={mockOnEditException} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -203,6 +203,69 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') + .simulate('click'); + + expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock()); + }); + + it('it invokes "onEditException" when edit button clicked when "isEndpoint" is "true"', () => { + const mockOnEditException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]').text() + ).toEqual('Edit endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]') .simulate('click'); @@ -222,8 +285,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { @@ -256,6 +319,73 @@ describe('ExceptionItemCard', () => { .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') .at(0) .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete rule exception'); + + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') + .simulate('click'); + + expect(mockOnDeleteException).toHaveBeenCalledWith({ + id: '1', + name: 'some name', + namespaceType: 'single', + }); + }); + + it('it invokes "onDeleteException" when delete button clicked when "isEndpoint" is "true"', () => { + const mockOnDeleteException = jest.fn(); + const exceptionItem = getExceptionListItemSchemaMock(); + + const wrapper = mount( + + + + ); + + // click on popover + wrapper + .find('button[data-test-subj="exceptionItemCardHeader-actionButton"]') + .at(0) + .simulate('click'); + + expect( + wrapper.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]').text() + ).toEqual('Delete endpoint exception'); + wrapper .find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]') .simulate('click'); @@ -278,8 +408,8 @@ describe('ExceptionItemCard', () => { onEditException={jest.fn()} exceptionItem={exceptionItem} dataTestSubj="item" - listType={ExceptionListTypeEnum.DETECTION} - ruleReferences={{ + isEndpoint={false} + listAndReferences={{ ...getExceptionListSchemaMock(), referenced_rules: [ { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx index 4bfa09e96486e..233e11b0709eb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/index.tsx @@ -9,7 +9,6 @@ import type { EuiCommentProps } from '@elastic/eui'; import { EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useMemo, useCallback } from 'react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { getFormattedComments } from '../../utils/helpers'; import type { ExceptionListItemIdentifiers } from '../../utils/types'; @@ -22,9 +21,9 @@ import { ExceptionItemCardComments } from './comments'; export interface ExceptionItemProps { exceptionItem: ExceptionListItemSchema; - listType: ExceptionListTypeEnum; + isEndpoint: boolean; disableActions: boolean; - ruleReferences: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; onDeleteException: (arg: ExceptionListItemIdentifiers) => void; onEditException: (item: ExceptionListItemSchema) => void; dataTestSubj: string; @@ -33,8 +32,8 @@ export interface ExceptionItemProps { const ExceptionItemCardComponent = ({ disableActions, exceptionItem, - listType, - ruleReferences, + isEndpoint, + listAndReferences, onDeleteException, onEditException, dataTestSubj, @@ -65,19 +64,17 @@ const ExceptionItemCardComponent = ({ { key: 'edit', icon: 'controlsHorizontal', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON - : i18n.EXCEPTION_ITEM_EDIT_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON + : i18n.EXCEPTION_ITEM_EDIT_BUTTON, onClick: handleEdit, }, { key: 'delete', icon: 'trash', - label: - listType === ExceptionListTypeEnum.ENDPOINT - ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON - : i18n.EXCEPTION_ITEM_DELETE_BUTTON, + label: isEndpoint + ? i18n.ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON + : i18n.EXCEPTION_ITEM_DELETE_BUTTON, onClick: handleDelete, }, ]} @@ -88,7 +85,7 @@ const ExceptionItemCardComponent = ({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx index 8892117f1616e..0a355a4567a8d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.test.tsx @@ -6,193 +6,233 @@ */ import React from 'react'; +import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { ExceptionItemCardMetaInfo } from './meta'; import { TestProviders } from '../../../../common/mock'; describe('ExceptionItemCardMetaInfo', () => { - it('it renders item creation info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() - ).toEqual('some user'); - }); + describe('general functionality', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders item creation info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders item update info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() + ).toEqual('Apr 20, 2020 @ 15:25:31.830'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() + ).toEqual('some user'); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); + + it('it renders references info when multiple references exist', () => { + wrapper = mount( + + + + ); - it('it renders item update info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text() - ).toEqual('Apr 20, 2020 @ 15:25:31.830'); - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text() - ).toEqual('some user'); + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 2 rules'); + }); }); - it('it renders references info', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 1 rule'); + describe('exception item for "rule_default" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it does NOT render affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').exists() + ).toBeFalsy(); + }); }); - it('it renders references info when multiple references exist', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() - ).toEqual('Affects 2 rules'); + describe('exception item for "endpoint" list', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount( + + + + ); + }); + + it('it renders references info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedRulesButton"]').at(0).text() + ).toEqual('Affects 1 rule'); + }); + + it('it renders affected shared list info', () => { + expect( + wrapper.find('[data-test-subj="exceptionItemMeta-affectedListsButton"]').at(0).text() + ).toEqual('Affects shared list'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx index 453e1542bfce8..8005264636bfa 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/meta.tsx @@ -19,6 +19,7 @@ import { EuiPopover, } from '@elastic/eui'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import styled from 'styled-components'; import * as i18n from './translations'; @@ -36,24 +37,27 @@ const StyledFlexItem = styled(EuiFlexItem)` export interface ExceptionItemCardMetaInfoProps { item: ExceptionListItemSchema; - references: ExceptionListRuleReferencesSchema | null; + listAndReferences: ExceptionListRuleReferencesSchema | null; dataTestSubj: string; } export const ExceptionItemCardMetaInfo = memo( - ({ item, references, dataTestSubj }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + ({ item, listAndReferences, dataTestSubj }) => { + const [isListsPopoverOpen, setIsListsPopoverOpen] = useState(false); + const [isRulesPopoverOpen, setIsRulesPopoverOpen] = useState(false); - const onAffectedRulesClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); + const onAffectedRulesClick = () => setIsRulesPopoverOpen((isOpen) => !isOpen); + const onAffectedListsClick = () => setIsListsPopoverOpen((isOpen) => !isOpen); + const onCloseRulesPopover = () => setIsRulesPopoverOpen(false); + const onClosListsPopover = () => setIsListsPopoverOpen(false); const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => { - if (references == null) { + if (listAndReferences == null) { return []; } - return references.referenced_rules.map((reference) => ( + return listAndReferences.referenced_rules.map((reference) => ( @@ -61,13 +65,92 @@ export const ExceptionItemCardMetaInfo = memo( data-test-subj="ruleName" deepLinkId={SecurityPageName.rules} path={getRuleDetailsTabUrl(reference.id, RuleDetailTabs.alerts)} + external > {reference.name} )); - }, [references, dataTestSubj]); + }, [listAndReferences, dataTestSubj]); + + const rulesAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + return ( + + + {i18n.AFFECTED_RULES(listAndReferences?.referenced_rules.length ?? 0)} + + } + panelPaddingSize="none" + isOpen={isRulesPopoverOpen} + closePopover={onCloseRulesPopover} + data-test-subj={`${dataTestSubj}-rulesPopover`} + id={'rulesPopover'} + > + + + + ); + }, [listAndReferences, dataTestSubj, isRulesPopoverOpen, itemActions]); + + const listsAffected = useMemo((): JSX.Element => { + if (listAndReferences == null) return <>; + + if (listAndReferences.type !== ExceptionListTypeEnum.RULE_DEFAULT) { + return ( + + + {i18n.AFFECTED_LIST} + + } + panelPaddingSize="none" + isOpen={isListsPopoverOpen} + closePopover={onClosListsPopover} + data-test-subj={`${dataTestSubj}-listsPopover`} + id={'listsPopover'} + > + + + + {listAndReferences.name} + + + , + ]} + /> + + + ); + } else { + return <>; + } + }, [listAndReferences, dataTestSubj, isListsPopoverOpen]); return ( ( dataTestSubj={`${dataTestSubj}-updatedBy`} /> - {references != null && ( - - - {i18n.AFFECTED_RULES(references?.referenced_rules.length ?? 0)} - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - data-test-subj={`${dataTestSubj}-items`} - > - - - + {listAndReferences != null && ( + <> + {rulesAffected} + {listsAffected} + )} ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts index ccdd5eebf3b8c..75d0b098c297e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/exception_item_card/translations.ts @@ -8,174 +8,181 @@ import { i18n } from '@kbn/i18n'; export const EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.editItemButton', { defaultMessage: 'Edit rule exception', } ); export const EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.deleteItemButton', { defaultMessage: 'Delete rule exception', } ); export const ENDPOINT_EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.editItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.editItemButton', { defaultMessage: 'Edit endpoint exception', } ); export const ENDPOINT_EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.endpoint.deleteItemButton', + 'xpack.securitySolution.ruleExceptions.exceptionItem.endpoint.deleteItemButton', { defaultMessage: 'Delete endpoint exception', } ); export const EXCEPTION_ITEM_CREATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.createdLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.createdLabel', { defaultMessage: 'Created', } ); export const EXCEPTION_ITEM_UPDATED_LABEL = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.updatedLabel', + 'xpack.securitySolution.ruleExceptions.exceptionItem.updatedLabel', { defaultMessage: 'Updated', } ); export const EXCEPTION_ITEM_META_BY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy', + 'xpack.securitySolution.ruleExceptions.exceptionItem.metaDetailsBy', { defaultMessage: 'by', } ); export const exceptionItemCommentsAccordion = (comments: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.showCommentsLabel', { values: { comments }, defaultMessage: 'Show {comments, plural, =1 {comment} other {comments}} ({comments})', }); export const CONDITION_OPERATOR_TYPE_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator', { defaultMessage: 'IS', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchOperator.not', { defaultMessage: 'IS NOT', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardMatchesOperator', { defaultMessage: 'MATCHES', } ); export const CONDITION_OPERATOR_TYPE_WILDCARD_DOES_NOT_MATCH = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator', { defaultMessage: 'DOES NOT MATCH', } ); export const CONDITION_OPERATOR_TYPE_NESTED = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.nestedOperator', { defaultMessage: 'has', } ); export const CONDITION_OPERATOR_TYPE_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator', { defaultMessage: 'is one of', } ); export const CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.matchAnyOperator.not', { defaultMessage: 'is not one of', } ); export const CONDITION_OPERATOR_TYPE_EXISTS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator', { defaultMessage: 'exists', } ); export const CONDITION_OPERATOR_TYPE_DOES_NOT_EXIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.existsOperator.not', { defaultMessage: 'does not exist', } ); export const CONDITION_OPERATOR_TYPE_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator', { defaultMessage: 'included in', } ); export const CONDITION_OPERATOR_TYPE_NOT_IN_LIST = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.listOperator.not', { defaultMessage: 'is not included in', } ); export const CONDITION_AND = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.and', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.and', { defaultMessage: 'AND', } ); export const CONDITION_OS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.os', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.os', { defaultMessage: 'OS', } ); export const OS_WINDOWS = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.windows', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.windows', { defaultMessage: 'Windows', } ); export const OS_LINUX = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.linux', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.linux', { defaultMessage: 'Linux', } ); export const OS_MAC = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionItem.conditions.macos', + 'xpack.securitySolution.ruleExceptions.exceptionItem.conditions.macos', { defaultMessage: 'Mac', } ); export const AFFECTED_RULES = (numRules: number) => - i18n.translate('xpack.securitySolution.exceptions.exceptionItem.affectedRules', { + i18n.translate('xpack.securitySolution.ruleExceptions.exceptionItem.affectedRules', { values: { numRules }, defaultMessage: 'Affects {numRules} {numRules, plural, =1 {rule} other {rules}}', }); + +export const AFFECTED_LIST = i18n.translate( + 'xpack.securitySolution.ruleExceptions.exceptionItem.affectedList', + { + defaultMessage: 'Affects shared list', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx index 46f16ec468873..4869c80c172a2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/item_conditions/index.tsx @@ -266,6 +266,7 @@ const ExceptionsConditionsComponent: React.FC ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx index 214d0041fb9a2..47933db0b3522 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.test.tsx @@ -110,7 +110,6 @@ describe('ExceptionItemComments', () => { ); - expect(wrapper.find('[data-test-subj="exceptionItemCommentsAccordion"]').exists()).toBeFalsy(); expect( wrapper.find('[data-test-subj="newExceptionItemCommentTextArea"]').at(1).props().value ).toEqual('This is a new comment'); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx index fa514850e30b4..a3553c78f8b30 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/item_comments/index.tsx @@ -82,10 +82,6 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ setShouldShowComments(isOpen); }, []); - const exceptionItemsExist: boolean = useMemo(() => { - return exceptionItemComments != null && exceptionItemComments.length > 0; - }, [exceptionItemComments]); - const commentsAccordionTitle = useMemo(() => { if (exceptionItemComments && exceptionItemComments.length > 0) { return ( @@ -110,32 +106,30 @@ export const ExceptionItemComments = memo(function ExceptionItemComments({ return (
- {exceptionItemsExist && ( - handleTriggerOnClick(isOpen)} - > - - - )} - - - - - - - - + handleTriggerOnClick(isOpen)} + > + + + + + + + + + +
); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts new file mode 100644 index 0000000000000..8c1ceb9f639fb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CLOSE_ALERTS_SUCCESS = (numAlerts: number) => + i18n.translate('xpack.securitySolution.ruleExceptions.logic.closeAlerts.success', { + values: { numAlerts }, + defaultMessage: + 'Successfully updated {numAlerts} {numAlerts, plural, =1 {alert} other {alerts}}', + }); + +export const CLOSE_ALERTS_ERROR = i18n.translate( + 'xpack.securitySolution.ruleExceptions.logic.closeAlerts.error', + { + defaultMessage: 'Failed to close alerts', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx deleted file mode 100644 index e882e05f6e085..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.test.tsx +++ /dev/null @@ -1,423 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { RenderHookResult } from '@testing-library/react-hooks'; -import { act, renderHook } from '@testing-library/react-hooks'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { coreMock } from '@kbn/core/public/mocks'; -import { KibanaServices } from '../../../common/lib/kibana'; - -import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; -import * as listsApi from '@kbn/securitysolution-list-api'; -import * as getQueryFilterHelper from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import * as buildFilterHelpers from '../../../detections/components/alerts_table/default_config'; -import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; -import { getCreateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_item_schema.mock'; -import { getUpdateExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/request/update_exception_list_item_schema.mock'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { TestProviders } from '../../../common/mock'; -import type { - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException, - AddOrUpdateExceptionItemsFunc, -} from './use_add_exception'; -import { useAddOrUpdateException } from './use_add_exception'; - -const mockKibanaHttpService = coreMock.createStart().http; -const mockKibanaServices = KibanaServices.get as jest.Mock; -jest.mock('../../../common/lib/kibana'); -jest.mock('@kbn/securitysolution-list-api'); - -const fetchMock = jest.fn(); -mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); - -describe('useAddOrUpdateException', () => { - let updateAlertStatus: jest.SpyInstance>; - let addExceptionListItem: jest.SpyInstance>; - let updateExceptionListItem: jest.SpyInstance>; - let getQueryFilter: jest.SpyInstance>; - let buildAlertStatusesFilter: jest.SpyInstance< - ReturnType - >; - let buildAlertsFilter: jest.SpyInstance>; - let addOrUpdateItemsArgs: Parameters; - let render: () => RenderHookResult; - const onError = jest.fn(); - const onSuccess = jest.fn(); - const ruleStaticId = 'rule-id'; - const alertIdToClose = 'idToClose'; - const bulkCloseIndex = ['.custom']; - const itemsToAdd: CreateExceptionListItemSchema[] = [ - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 1', - }, - { - ...getCreateExceptionListItemSchemaMock(), - name: 'item to add 2', - }, - ]; - const itemsToUpdate: ExceptionListItemSchema[] = [ - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 1', - }, - { - ...getExceptionListItemSchemaMock(), - name: 'item to update 2', - }, - ]; - const itemsToUpdateFormatted: UpdateExceptionListItemSchema[] = itemsToUpdate.map( - (item: ExceptionListItemSchema) => { - const formatted: UpdateExceptionListItemSchema = getUpdateExceptionListItemSchemaMock(); - const newObj = (Object.keys(formatted) as Array).reduce( - (acc, key) => { - return { - ...acc, - [key]: item[key], - }; - }, - {} as UpdateExceptionListItemSchema - ); - return newObj; - } - ); - - const itemsToAddOrUpdate = [...itemsToAdd, ...itemsToUpdate]; - - const waitForAddOrUpdateFunc: (arg: { - waitForNextUpdate: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['waitForNextUpdate']; - rerender: RenderHookResult< - UseAddOrUpdateExceptionProps, - ReturnUseAddOrUpdateException - >['rerender']; - result: RenderHookResult['result']; - }) => Promise = async ({ - waitForNextUpdate, - rerender, - result, - }) => { - await waitForNextUpdate(); - rerender(); - expect(result.current[1]).not.toBeNull(); - return Promise.resolve(result.current[1]); - }; - - beforeEach(() => { - updateAlertStatus = jest.spyOn(alertsApi, 'updateAlertStatus'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockResolvedValue(getExceptionListItemSchemaMock()); - - getQueryFilter = jest - .spyOn(getQueryFilterHelper, 'getEsQueryFilter') - .mockResolvedValue({ bool: { must_not: [], must: [], filter: [], should: [] } }); - - buildAlertStatusesFilter = jest.spyOn(buildFilterHelpers, 'buildAlertStatusesFilter'); - - buildAlertsFilter = jest.spyOn(buildFilterHelpers, 'buildAlertsFilter'); - - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate]; - render = () => - renderHook( - () => - useAddOrUpdateException({ - http: mockKibanaHttpService, - onError, - onSuccess, - }), - { - wrapper: TestProviders, - } - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - it('initializes hook', async () => { - await act(async () => { - const { result, waitForNextUpdate } = render(); - await waitForNextUpdate(); - expect(result.current).toEqual([{ isLoading: false }, result.current[1]]); - }); - }); - - it('invokes "onError" if call to add exception item fails', async () => { - const mockError = new Error('error adding item'); - - addExceptionListItem = jest - .spyOn(listsApi, 'addExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - it('invokes "onError" if call to update exception item fails', async () => { - const mockError = new Error('error updating item'); - - updateExceptionListItem = jest - .spyOn(listsApi, 'updateExceptionListItem') - .mockRejectedValue(mockError); - - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(onError).toHaveBeenCalledWith(mockError, null, null); - }); - }); - - describe('when alertIdToClose is not passed in', () => { - it('should not update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).not.toHaveBeenCalled(); - }); - }); - - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when alertIdToClose is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, alertIdToClose]; - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); - - describe('when bulkCloseIndex is passed in', () => { - beforeEach(() => { - addOrUpdateItemsArgs = [ruleStaticId, itemsToAddOrUpdate, undefined, bulkCloseIndex]; - }); - it('should update the status of only alerts that are open', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertStatusesFilter).toHaveBeenCalledTimes(1); - expect(buildAlertStatusesFilter.mock.calls[0][0]).toEqual([ - 'open', - 'acknowledged', - 'in-progress', - ]); - }); - }); - it('should update the status of only alerts generated by the provided rule', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(buildAlertsFilter).toHaveBeenCalledTimes(1); - expect(buildAlertsFilter.mock.calls[0][0]).toEqual(ruleStaticId); - }); - }); - it('should generate the query filter using exceptions', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(getQueryFilter).toHaveBeenCalledTimes(1); - expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); - expect(getQueryFilter.mock.calls[0][5]).toEqual(false); - }); - }); - it('should update the alert status', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateAlertStatus).toHaveBeenCalledTimes(1); - }); - }); - it('creates new items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(addExceptionListItem).toHaveBeenCalledTimes(2); - expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); - }); - }); - it('updates existing items', async () => { - await act(async () => { - const { rerender, result, waitForNextUpdate } = render(); - const addOrUpdateItems = await waitForAddOrUpdateFunc({ - rerender, - result, - waitForNextUpdate, - }); - if (addOrUpdateItems) { - addOrUpdateItems(...addOrUpdateItemsArgs); - } - await waitForNextUpdate(); - expect(updateExceptionListItem).toHaveBeenCalledTimes(2); - expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( - itemsToUpdateFormatted[1] - ); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx deleted file mode 100644 index a6149f366dfaf..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_exception.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useRef, useState, useCallback } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useApi, removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; -import type { HttpStart } from '@kbn/core/public'; - -import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; -import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { - buildAlertsFilter, - buildAlertStatusesFilter, -} from '../../../detections/components/alerts_table/default_config'; -import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; -import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; -import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from '../utils/helpers'; -import { useKibana } from '../../../common/lib/kibana'; - -/** - * Adds exception items to the list. Also optionally closes alerts. - * - * @param ruleStaticId static id of the rule (rule.ruleId, not rule.id) where the exception updates will be applied - * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update - * @param alertIdToClose - optional string representing alert to close - * @param bulkCloseIndex - optional index used to create bulk close query - * - */ -export type AddOrUpdateExceptionItemsFunc = ( - ruleStaticId: string, - exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string, - bulkCloseIndex?: Index -) => Promise; - -export type ReturnUseAddOrUpdateException = [ - { isLoading: boolean }, - AddOrUpdateExceptionItemsFunc | null -]; - -export interface UseAddOrUpdateExceptionProps { - http: HttpStart; - onError: (arg: Error, code: number | null, message: string | null) => void; - onSuccess: (updated: number, conficts: number) => void; -} - -/** - * Hook for adding and updating an exception item - * - * @param http Kibana http service - * @param onError error callback - * @param onSuccess callback when all lists fetched successfully - * - */ -export const useAddOrUpdateException = ({ - http, - onError, - onSuccess, -}: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => { - const { services } = useKibana(); - const [isLoading, setIsLoading] = useState(false); - const addOrUpdateExceptionRef = useRef(null); - const { addExceptionListItem, updateExceptionListItem } = useApi(services.http); - const addOrUpdateException = useCallback( - async (ruleStaticId, exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { - if (addOrUpdateExceptionRef.current != null) { - addOrUpdateExceptionRef.current( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ); - } - }, - [] - ); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const onUpdateExceptionItemsAndAlertStatus: AddOrUpdateExceptionItemsFunc = async ( - ruleStaticId, - exceptionItemsToAddOrUpdate, - alertIdToClose, - bulkCloseIndex - ) => { - const addOrUpdateItems = async ( - exceptionListItems: Array - ): Promise => { - await Promise.all( - exceptionListItems.map( - (item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { - if ('id' in item && item.id != null) { - const formattedExceptionItem = formatExceptionItemForUpdate(item); - return updateExceptionListItem({ - listItem: formattedExceptionItem, - }); - } else { - return addExceptionListItem({ - listItem: item, - }); - } - } - ) - ); - }; - - try { - setIsLoading(true); - let alertIdResponse: estypes.UpdateByQueryResponse | undefined; - let bulkResponse: estypes.UpdateByQueryResponse | undefined; - if (alertIdToClose != null) { - alertIdResponse = await updateAlertStatus({ - query: getUpdateAlertsQuery([alertIdToClose]), - status: 'closed', - signal: abortCtrl.signal, - }); - } - - if (bulkCloseIndex != null) { - const alertStatusFilter = buildAlertStatusesFilter([ - 'open', - 'acknowledged', - 'in-progress', - ]); - - const exceptionsToFilter = exceptionItemsToAddOrUpdate.map((exception) => - removeIdFromExceptionItemsEntries(exception) - ); - - const filter = await getEsQueryFilter( - '', - 'kuery', - [...buildAlertsFilter(ruleStaticId), ...alertStatusFilter], - bulkCloseIndex, - prepareExceptionItemsForBulkClose(exceptionsToFilter), - false - ); - - bulkResponse = await updateAlertStatus({ - query: { - query: filter, - }, - status: 'closed', - signal: abortCtrl.signal, - }); - } - - await addOrUpdateItems(exceptionItemsToAddOrUpdate); - - // NOTE: there could be some overlap here... it's possible that the first response had conflicts - // but that the alert was closed in the second call. In this case, a conflict will be reported even - // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should - // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the - // state of the alerts and their representation in the UI would be consistent. - const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); - const conflicts = - alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); - if (isSubscribed) { - setIsLoading(false); - onSuccess(updated, conflicts); - } - } catch (error) { - if (isSubscribed) { - setIsLoading(false); - if (error.body != null) { - onError(error, error.body.status_code, error.body.message); - } else { - onError(error, null, null); - } - } - } - }; - - addOrUpdateExceptionRef.current = onUpdateExceptionItemsAndAlertStatus; - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [addExceptionListItem, http, onSuccess, onError, updateExceptionListItem]); - - return [{ isLoading }, addOrUpdateException]; -}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx new file mode 100644 index 0000000000000..7cf4ff2d8417d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_add_rule_exception.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useEffect, useRef, useState } from 'react'; + +import { addRuleExceptions } from '../../../detections/containers/detection_engine/rules/api'; +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; + +/** + * Adds exception items to rules default exception list + * + * @param exceptions exception items to be added + * @param ruleId `id` of rule to add exceptions to + * + */ +export type AddRuleExceptionsFunc = ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] +) => Promise; + +export type ReturnUseAddRuleException = [boolean, AddRuleExceptionsFunc | null]; + +/** + * Hook for adding exceptions to a rule default exception list + * + */ +export const useAddRuleDefaultException = (): ReturnUseAddRuleException => { + const [isLoading, setIsLoading] = useState(false); + const addRuleExceptionFunc = useRef(null); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const addExceptionItemsToRule: AddRuleExceptionsFunc = async ( + exceptions: CreateRuleExceptionListItemSchema[], + rules: Rule[] + ): Promise => { + setIsLoading(true); + + // TODO: Update once bulk route is added + const result = await Promise.all( + rules.map(async (rule) => + addRuleExceptions({ + items: exceptions, + ruleId: rule.id, + signal: abortCtrl.signal, + }) + ) + ); + + setIsLoading(false); + + return result.flatMap((r) => r); + }; + addRuleExceptionFunc.current = addExceptionItemsToRule; + + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, []); + + return [isLoading, addRuleExceptionFunc.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx new file mode 100644 index 0000000000000..5dc960cb96e2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_close_alerts.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; +import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; +import { + buildAlertStatusesFilter, + buildAlertsFilter, +} from '../../../detections/components/alerts_table/default_config'; +import { getEsQueryFilter } from '../../../detections/containers/detection_engine/exceptions/get_es_query_filter'; +import type { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { prepareExceptionItemsForBulkClose } from '../utils/helpers'; +import * as i18nCommon from '../../../common/translations'; +import * as i18n from './translations'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +/** + * Closes alerts. + * + * @param ruleStaticIds static id of the rules (rule.ruleId, not rule.id) where the exception updates will be applied + * @param exceptionItems array of ExceptionListItemSchema to add or update + * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query + * + */ +export type AddOrUpdateExceptionItemsFunc = ( + ruleStaticIds: string[], + exceptionItems: ExceptionListItemSchema[], + alertIdToClose?: string, + bulkCloseIndex?: Index +) => Promise; + +export type ReturnUseCloseAlertsFromExceptions = [boolean, AddOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for closing alerts from exceptions + */ +export const useCloseAlertsFromExceptions = (): ReturnUseCloseAlertsFromExceptions => { + const { addSuccess, addError, addWarning } = useAppToasts(); + + const [isLoading, setIsLoading] = useState(false); + const closeAlertsRef = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const onUpdateAlerts: AddOrUpdateExceptionItemsFunc = async ( + ruleStaticIds, + exceptionItems, + alertIdToClose, + bulkCloseIndex + ) => { + try { + setIsLoading(true); + let alertIdResponse: estypes.UpdateByQueryResponse | undefined; + let bulkResponse: estypes.UpdateByQueryResponse | undefined; + if (alertIdToClose != null) { + alertIdResponse = await updateAlertStatus({ + query: getUpdateAlertsQuery([alertIdToClose]), + status: 'closed', + signal: abortCtrl.signal, + }); + } + + if (bulkCloseIndex != null) { + const alertStatusFilter = buildAlertStatusesFilter([ + 'open', + 'acknowledged', + 'in-progress', + ]); + + const filter = await getEsQueryFilter( + '', + 'kuery', + [...ruleStaticIds.flatMap((id) => buildAlertsFilter(id)), ...alertStatusFilter], + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItems), + false + ); + + bulkResponse = await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + signal: abortCtrl.signal, + }); + } + + // NOTE: there could be some overlap here... it's possible that the first response had conflicts + // but that the alert was closed in the second call. In this case, a conflict will be reported even + // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should + // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the + // state of the alerts and their representation in the UI would be consistent. + const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); + const conflicts = + alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); + if (isSubscribed) { + setIsLoading(false); + addSuccess(i18n.CLOSE_ALERTS_SUCCESS(updated)); + if (conflicts > 0) { + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } + } + } catch (error) { + if (isSubscribed) { + setIsLoading(false); + addError(error, { title: i18n.CLOSE_ALERTS_ERROR }); + } + } + }; + + closeAlertsRef.current = onUpdateAlerts; + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [addSuccess, addError, addWarning]); + + return [isLoading, closeAlertsRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx new file mode 100644 index 0000000000000..fbe9c0d46e6b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_create_update_exception.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useApi } from '@kbn/securitysolution-list-hooks'; + +import { formatExceptionItemForUpdate } from '../utils/helpers'; +import { useKibana } from '../../../common/lib/kibana'; + +export type CreateOrUpdateExceptionItemsFunc = ( + args: Array +) => Promise; + +export type ReturnUseCreateOrUpdateException = [boolean, CreateOrUpdateExceptionItemsFunc | null]; + +/** + * Hook for adding and/or updating an exception item + */ +export const useCreateOrUpdateException = (): ReturnUseCreateOrUpdateException => { + const { + services: { http }, + } = useKibana(); + const [isLoading, setIsLoading] = useState(false); + const addOrUpdateExceptionRef = useRef(null); + const { addExceptionListItem, updateExceptionListItem } = useApi(http); + + useEffect(() => { + const abortCtrl = new AbortController(); + + const onCreateOrUpdateExceptionItem: CreateOrUpdateExceptionItemsFunc = async (items) => { + setIsLoading(true); + const itemsAdded = await Promise.all( + items.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if ('id' in item && item.id != null) { + const formattedExceptionItem = formatExceptionItemForUpdate(item); + return updateExceptionListItem({ + listItem: formattedExceptionItem, + }); + } else { + return addExceptionListItem({ + listItem: item, + }); + } + }) + ); + + setIsLoading(false); + + return itemsAdded; + }; + + addOrUpdateExceptionRef.current = onCreateOrUpdateExceptionItem; + return (): void => { + setIsLoading(false); + abortCtrl.abort(); + }; + }, [updateExceptionListItem, http, addExceptionListItem]); + + return [isLoading, addOrUpdateExceptionRef.current]; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx new file mode 100644 index 0000000000000..57cfda994d37c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useMemo } from 'react'; +import type { DataViewBase } from '@kbn/es-query'; + +import type { Rule } from '../../../detections/containers/detection_engine/rules/types'; +import { useGetInstalledJob } from '../../../common/components/ml/hooks/use_get_jobs'; +import { useKibana } from '../../../common/lib/kibana'; +import { useFetchIndex } from '../../../common/containers/source'; + +export interface ReturnUseFetchExceptionFlyoutData { + isLoading: boolean; + indexPatterns: DataViewBase; +} + +/** + * Hook for fetching the fields to be used for populating the exception + * item conditions options. + * + */ +export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExceptionFlyoutData => { + const { data } = useKibana().services; + const [dataViewLoading, setDataViewLoading] = useState(false); + const isSingleRule = useMemo(() => rules != null && rules.length === 1, [rules]); + const isMLRule = useMemo( + () => rules != null && isSingleRule && rules[0].type === 'machine_learning', + [isSingleRule, rules] + ); + // If data view is defined, it superceeds use of rule defined index patterns. + // If no rule is available, use fields from default data view id. + const memoDataViewId = useMemo( + () => + rules != null && isSingleRule ? rules[0].data_view_id || null : 'security-solution-default', + [isSingleRule, rules] + ); + + const memoNonDataViewIndexPatterns = useMemo( + () => + !memoDataViewId && rules != null && isSingleRule && rules[0].index != null + ? rules[0].index + : [], + [memoDataViewId, isSingleRule, rules] + ); + + // Index pattern logic for ML + const memoMlJobIds = useMemo( + () => (isMLRule && isSingleRule && rules != null ? rules[0].machine_learning_job_id ?? [] : []), + [isMLRule, isSingleRule, rules] + ); + const { loading: mlJobLoading, jobs } = useGetInstalledJob(memoMlJobIds); + + // We only want to provide a non empty array if it's an ML rule and we were able to fetch + // the index patterns, or if it's a rule not using data views. Otherwise, return an empty + // empty array to avoid making the `useFetchIndex` call + const memoRuleIndices = useMemo(() => { + if (isMLRule && jobs.length > 0) { + return jobs[0].results_index_name ? [`.ml-anomalies-${jobs[0].results_index_name}`] : []; + } else if (memoDataViewId != null) { + return []; + } else { + return memoNonDataViewIndexPatterns; + } + }, [jobs, isMLRule, memoDataViewId, memoNonDataViewIndexPatterns]); + + const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = + useFetchIndex(memoRuleIndices); + + // Data view logic + const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); + useEffect(() => { + const fetchSingleDataView = async () => { + if (memoDataViewId) { + setDataViewLoading(true); + const dv = await data.dataViews.get(memoDataViewId); + setDataViewLoading(false); + setDataViewIndexPatterns(dv); + } + }; + + fetchSingleDataView(); + }, [memoDataViewId, data.dataViews, setDataViewIndexPatterns]); + + // Determine whether to use index patterns or data views + const indexPatternsToUse = useMemo( + (): DataViewBase => + memoDataViewId && dataViewIndexPatterns != null ? dataViewIndexPatterns : indexIndexPatterns, + [memoDataViewId, dataViewIndexPatterns, indexIndexPatterns] + ); + + return { + isLoading: isIndexPatternLoading || mlJobLoading || dataViewLoading, + indexPatterns: indexPatternsToUse, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx index c88a7d16b6c4d..a9a43ef2e47aa 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.test.tsx @@ -16,8 +16,6 @@ import { enrichNewExceptionItemsWithComments, enrichExistingExceptionItemWithComments, enrichExceptionItemsWithOS, - entryHasListType, - entryHasNonEcsType, prepareExceptionItemsForBulkClose, lowercaseHashValues, getPrepopulatedEndpointException, @@ -34,7 +32,6 @@ import type { OsTypeArray, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { DataViewBase } from '@kbn/es-query'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; @@ -317,32 +314,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasListType', () => { - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a list type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasListType(payload); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a list type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ type: OperatorTypeEnum.LIST }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasListType(payload); - expect(result).toEqual(true); - }); - }); - describe('#getCodeSignatureValue', () => { test('it should return empty string if code_signature nested value are undefined', () => { // Using the unsafe casting because with our types this shouldn't be possible but there have been issues with old data having undefined values in these fields @@ -354,47 +325,6 @@ describe('Exception helpers', () => { }); }); - describe('#entryHasNonEcsType', () => { - const mockEcsIndexPattern = { - title: 'testIndex', - fields: [ - { - name: 'some.parentField', - }, - { - name: 'some.not.nested.field', - }, - { - name: 'nested.field', - }, - ], - } as DataViewBase; - - test('it should return false with an empty array', () => { - const payload: ExceptionListItemSchema[] = []; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test("it should return false with exception items that don't contain a non ecs type", () => { - const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(false); - }); - - test('it should return true with exception items that do contain a non ecs type', () => { - const payload = [ - { - ...getExceptionListItemSchemaMock(), - entries: [{ field: 'some.nonEcsField' }] as EntriesArray, - }, - getExceptionListItemSchemaMock(), - ]; - const result = entryHasNonEcsType(payload, mockEcsIndexPattern); - expect(result).toEqual(true); - }); - }); - describe('#prepareExceptionItemsForBulkClose', () => { test('it should return no exceptionw when passed in an empty array', () => { const payload: ExceptionListItemSchema[] = []; @@ -509,7 +439,7 @@ describe('Exception helpers', () => { test('it returns prepopulated fields with empty values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: '', trusted: '' }, eventCode: '', alertEcsData: { ...alertDataMock, file: { path: '', hash: { sha256: '' } } }, @@ -534,7 +464,7 @@ describe('Exception helpers', () => { test('it returns prepopulated items with actual values', () => { const prepopulatedItem = getPrepopulatedEndpointException({ listId: 'some_id', - ruleName: 'my rule', + name: 'my rule', codeSignature: { subjectName: 'someSubjectName', trusted: 'false' }, eventCode: 'some-event-code', alertEcsData: alertDataMock, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx index f876f11be9e3d..41d588a23763a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx @@ -21,26 +21,19 @@ import type { OsTypeArray, ExceptionListType, ExceptionListItemSchema, - CreateExceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; -import { - comment, - osType, - ListOperatorTypeEnum as OperatorTypeEnum, -} from '@kbn/securitysolution-io-ts-list-types'; +import { comment, osType } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionsBuilderExceptionItem, ExceptionsBuilderReturnExceptionItem, } from '@kbn/securitysolution-list-utils'; -import { - getOperatorType, - getNewExceptionItem, - addIdToEntries, -} from '@kbn/securitysolution-list-utils'; +import { getNewExceptionItem, addIdToEntries } from '@kbn/securitysolution-list-utils'; import type { DataViewBase } from '@kbn/es-query'; +import { removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-hooks'; + import * as i18n from './translations'; import type { AlertData, Flattened } from './types'; @@ -145,9 +138,9 @@ export const formatExceptionItemForUpdate = ( * @param exceptionItems new or existing ExceptionItem[] */ export const prepareExceptionItemsForBulkClose = ( - exceptionItems: Array -): Array => { - return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + exceptionItems: ExceptionListItemSchema[] +): ExceptionListItemSchema[] => { + return exceptionItems.map((item: ExceptionListItemSchema) => { if (item.entries !== undefined) { const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { return { @@ -285,17 +278,6 @@ export const lowercaseHashValues = ( }); }; -export const entryHasListType = (exceptionItems: ExceptionsBuilderReturnExceptionItem[]) => { - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { - return true; - } - } - } - return false; -}; - /** * Returns the value for `file.Ext.code_signature` which * can be an object or array of objects @@ -377,7 +359,7 @@ function filterEmptyExceptionEntries(entries: T[]): T[ */ export const getPrepopulatedEndpointException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -385,7 +367,7 @@ export const getPrepopulatedEndpointException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -449,7 +431,7 @@ export const getPrepopulatedEndpointException = ({ }; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: entriesToAdd(), }; }; @@ -459,7 +441,7 @@ export const getPrepopulatedEndpointException = ({ */ export const getPrepopulatedRansomwareException = ({ listId, - ruleName, + name, codeSignature, eventCode, listNamespace = 'agnostic', @@ -467,7 +449,7 @@ export const getPrepopulatedRansomwareException = ({ }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; codeSignature: { subjectName: string; trusted: string }; eventCode: string; alertEcsData: Flattened; @@ -477,7 +459,7 @@ export const getPrepopulatedRansomwareException = ({ const executable = process?.executable ?? ''; const ransomwareFeature = Ransomware?.feature ?? ''; return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries([ { field: 'process.Ext.code_signature', @@ -527,14 +509,14 @@ export const getPrepopulatedRansomwareException = ({ export const getPrepopulatedMemorySignatureException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -566,20 +548,20 @@ export const getPrepopulatedMemorySignatureException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedMemoryShellcodeException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -618,21 +600,21 @@ export const getPrepopulatedMemoryShellcodeException = ({ ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; export const getPrepopulatedBehaviorException = ({ listId, - ruleName, + name, eventCode, listNamespace = 'agnostic', alertEcsData, }: { listId: string; listNamespace?: NamespaceType; - ruleName: string; + name: string; eventCode: string; alertEcsData: Flattened; }): ExceptionsBuilderExceptionItem => { @@ -748,47 +730,17 @@ export const getPrepopulatedBehaviorException = ({ }, ]); return { - ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, name }), entries: addIdToEntries(entries), }; }; -/** - * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping - */ -export const entryHasNonEcsType = ( - exceptionItems: ExceptionsBuilderReturnExceptionItem[], - indexPatterns: DataViewBase -): boolean => { - const doesFieldNameExist = (exceptionEntry: Entry): boolean => { - return indexPatterns.fields.some(({ name }) => name === exceptionEntry.field); - }; - - if (exceptionItems.length === 0) { - return false; - } - for (const { entries } of exceptionItems) { - for (const exceptionEntry of entries ?? []) { - if (exceptionEntry.type === 'nested') { - for (const nestedExceptionEntry of exceptionEntry.entries) { - if (doesFieldNameExist(nestedExceptionEntry) === false) { - return true; - } - } - } else if (doesFieldNameExist(exceptionEntry) === false) { - return true; - } - } - } - return false; -}; - /** * Returns the default values from the alert data to autofill new endpoint exceptions */ export const defaultEndpointExceptionItems = ( listId: string, - ruleName: string, + name: string, alertEcsData: Flattened & { 'event.code'?: string } ): ExceptionsBuilderExceptionItem[] => { const eventCode = alertEcsData['event.code'] ?? alertEcsData.event?.code; @@ -798,7 +750,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedBehaviorException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -807,7 +759,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemorySignatureException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -816,7 +768,7 @@ export const defaultEndpointExceptionItems = ( return [ getPrepopulatedMemoryShellcodeException({ listId, - ruleName, + name, eventCode, alertEcsData, }), @@ -825,7 +777,7 @@ export const defaultEndpointExceptionItems = ( return getProcessCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedRansomwareException({ listId, - ruleName, + name, eventCode, codeSignature, alertEcsData, @@ -836,7 +788,7 @@ export const defaultEndpointExceptionItems = ( return getFileCodeSignature(alertEcsData).map((codeSignature) => getPrepopulatedEndpointException({ listId, - ruleName, + name, eventCode: eventCode ?? '', codeSignature, alertEcsData, @@ -872,7 +824,7 @@ export const enrichRuleExceptions = ( ): ExceptionsBuilderReturnExceptionItem[] => { return exceptionItems.map((item: ExceptionsBuilderReturnExceptionItem) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: undefined, namespace_type: 'single', }; @@ -891,7 +843,7 @@ export const enrichSharedExceptions = ( return lists.flatMap((list) => { return exceptionItems.map((item) => { return { - ...item, + ...removeIdFromExceptionItemsEntries(item), list_id: list.list_id, namespace_type: list.namespace_type, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 25853c932a185..03141dfb02f57 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -11,7 +11,7 @@ import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@ela import { indexOf } from 'lodash'; import type { ConnectedProps } from 'react-redux'; import { connect } from 'react-redux'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { get } from 'lodash/fp'; import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public'; import { isActiveTimeline } from '../../../../helpers'; @@ -42,6 +42,7 @@ import { ATTACH_ALERT_TO_CASE_FOR_ROW } from '../../../../timelines/components/t import { useEventFilterAction } from './use_event_filter_action'; import { useAddToCaseActions } from './use_add_to_case_actions'; import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check'; +import type { Rule } from '../../../containers/detection_engine/rules/types'; interface AlertContextMenuProps { ariaLabel?: string; @@ -100,7 +101,6 @@ const AlertContextMenuComponent: React.FC indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const isAgentEndpoint = useMemo(() => ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]); - const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); const scopeIdAllowsAddEndpointEventFilter = useMemo( () => scopeId === TableId.hostsPageEvents || scopeId === TableId.usersPageEvents, @@ -147,16 +147,19 @@ const AlertContextMenuComponent: React.FC { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); closePopover(); }, @@ -251,22 +254,19 @@ const AlertContextMenuComponent: React.FC )} - {exceptionFlyoutType != null && - ruleId != null && - ruleName != null && - ecsRowData?._id != null && ( - - )} + {openAddExceptionFlyout && ruleId != null && ruleName != null && ecsRowData?._id != null && ( + + )} {isAddEventFilterModalOpen && ecsRowData != null && ( )} @@ -301,9 +301,19 @@ export const AlertContextMenu = connector(React.memo(AlertContextMenuComponent)) type AddExceptionFlyoutWrapperProps = Omit< AddExceptionFlyoutProps, - 'alertData' | 'isAlertDataLoading' + | 'alertData' + | 'isAlertDataLoading' + | 'isEndpointItem' + | 'rules' + | 'isBulkAction' + | 'showAlertCloseOptions' > & { eventId?: string; + ruleId: Rule['id']; + ruleIndices: Rule['index']; + ruleDataViewId: Rule['data_view_id']; + ruleName: Rule['name']; + exceptionListType: ExceptionListTypeEnum | null; }; /** @@ -312,15 +322,15 @@ type AddExceptionFlyoutWrapperProps = Omit< * we cannot use the fetch hook within the flyout component itself */ export const AddExceptionFlyoutWrapper: React.FC = ({ - ruleName, ruleId, ruleIndices, + ruleDataViewId, + ruleName, exceptionListType, eventId, onCancel, onConfirm, alertStatus, - onRuleChange, }) => { const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); @@ -354,8 +364,8 @@ export const AddExceptionFlyoutWrapper: React.FC ? enrichedAlert.signal.rule.index : [enrichedAlert.signal.rule.index]; } - return []; - }, [enrichedAlert]); + return ruleIndices; + }, [enrichedAlert, ruleIndices]); const memoDataViewId = useMemo(() => { if ( @@ -364,23 +374,51 @@ export const AddExceptionFlyoutWrapper: React.FC ) { return enrichedAlert['kibana.alert.rule.parameters'].data_view_id; } - }, [enrichedAlert]); - const isLoading = isLoadingAlertData && isSignalIndexLoading; + return ruleDataViewId; + }, [enrichedAlert, ruleDataViewId]); + + // TODO: Do we want to notify user when they are working off of an older version of a rule + // if they select to add an exception from an alert referencing an older rule version? + const memoRule = useMemo(() => { + if (enrichedAlert != null && enrichedAlert['kibana.alert.rule.parameters'] != null) { + return [ + { + ...enrichedAlert['kibana.alert.rule.parameters'], + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + } + + return [ + { + id: ruleId, + name: ruleName, + index: memoRuleIndices, + data_view_id: memoDataViewId, + }, + ] as Rule[]; + }, [enrichedAlert, memoDataViewId, memoRuleIndices, ruleId, ruleName]); + + const isLoading = + (isLoadingAlertData && isSignalIndexLoading) || + enrichedAlert == null || + memoRuleIndices == null; return ( ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx index e0a09be0873d6..e63cbcc4c22d8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -7,14 +7,14 @@ import React, { useCallback, useMemo } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { useUserData } from '../../user_info'; import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations'; interface UseExceptionActionProps { isEndpointAlert: boolean; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; } export const useExceptionActions = ({ @@ -24,11 +24,11 @@ export const useExceptionActions = ({ const [{ canUserCRUD, hasIndexWrite }] = useUserData(); const handleDetectionExceptionModal = useCallback(() => { - onAddExceptionTypeClick('detection'); + onAddExceptionTypeClick(); }, [onAddExceptionTypeClick]); const handleEndpointExceptionModal = useCallback(() => { - onAddExceptionTypeClick('endpoint'); + onAddExceptionTypeClick(ExceptionListTypeEnum.ENDPOINT); }, [onAddExceptionTypeClick]); const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx index aff1c943110c0..85892f4ba5b53 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_flyout.tsx @@ -5,63 +5,66 @@ * 2.0. */ -import { useCallback, useMemo, useState } from 'react'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { useCallback, useState } from 'react'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; import type { inputsModel } from '../../../../common/store'; interface UseExceptionFlyoutProps { - ruleIndex: string[] | null | undefined; refetch?: inputsModel.Refetch; + onRuleChange?: () => void; isActiveTimelines: boolean; } interface UseExceptionFlyout { - exceptionFlyoutType: ExceptionListType | null; - onAddExceptionTypeClick: (type: ExceptionListType) => void; - onAddExceptionCancel: () => void; - onAddExceptionConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; - ruleIndices: string[]; + exceptionFlyoutType: ExceptionListTypeEnum | null; + openAddExceptionFlyout: boolean; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; + onAddExceptionCancel: (didRuleChange: boolean) => void; + onAddExceptionConfirm: ( + didRuleChange: boolean, + didCloseAlert: boolean, + didBulkCloseAlert: boolean + ) => void; } export const useExceptionFlyout = ({ - ruleIndex, refetch, + onRuleChange, isActiveTimelines, }: UseExceptionFlyoutProps): UseExceptionFlyout => { - const [exceptionFlyoutType, setOpenAddExceptionFlyout] = useState(null); - - const ruleIndices = useMemo((): string[] => { - if (ruleIndex != null) { - return ruleIndex; - } else { - return DEFAULT_INDEX_PATTERN; - } - }, [ruleIndex]); + const [openAddExceptionFlyout, setOpenAddExceptionFlyout] = useState(false); + const [exceptionFlyoutType, setExceptionFlyoutType] = useState( + null + ); - const onAddExceptionTypeClick = useCallback((exceptionListType: ExceptionListType): void => { - setOpenAddExceptionFlyout(exceptionListType); + const onAddExceptionTypeClick = useCallback((exceptionListType?: ExceptionListTypeEnum): void => { + setExceptionFlyoutType(exceptionListType ?? null); + setOpenAddExceptionFlyout(true); }, []); const onAddExceptionCancel = useCallback(() => { - setOpenAddExceptionFlyout(null); + setExceptionFlyoutType(null); + setOpenAddExceptionFlyout(false); }, []); const onAddExceptionConfirm = useCallback( - (didCloseAlert: boolean, didBulkCloseAlert) => { + (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert) => { if (refetch && (isActiveTimelines === false || didBulkCloseAlert)) { refetch(); } - setOpenAddExceptionFlyout(null); + if (onRuleChange != null && didRuleChange) { + onRuleChange(); + } + setOpenAddExceptionFlyout(false); }, - [refetch, isActiveTimelines] + [onRuleChange, refetch, isActiveTimelines] ); return { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index ba641875b2500..f4364443a6dfa 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { isActiveTimeline } from '../../../helpers'; import { TableId } from '../../../../common/types'; import { useResponderActionItem } from '../endpoint_responder'; @@ -45,7 +45,7 @@ export interface TakeActionDropdownProps { isHostIsolationPanelOpen: boolean; loadingEventDetails: boolean; onAddEventFilterClick: () => void; - onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionTypeClick: (type?: ExceptionListTypeEnum) => void; onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; refetch: (() => void) | undefined; refetchFlyoutData: () => Promise; @@ -144,7 +144,7 @@ export const TakeActionDropdown = React.memo( ); const handleOnAddExceptionTypeClick = useCallback( - (type: ExceptionListType) => { + (type?: ExceptionListTypeEnum) => { onAddExceptionTypeClick(type); setIsPopoverOpen(false); }, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts index bab81d4625f0c..2b280786e2905 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/exceptions/get_es_query_filter.ts @@ -6,13 +6,11 @@ */ import type { Language } from '@kbn/securitysolution-io-ts-alerting-types'; -import type { - ExceptionListItemSchema, - CreateExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; import type { Filter, EsQueryConfig, DataViewBase } from '@kbn/es-query'; import { getExceptionFilterFromExceptions } from '@kbn/securitysolution-list-api'; import { buildEsQuery } from '@kbn/es-query'; + +import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { KibanaServices } from '../../../../common/lib/kibana'; import type { Query, Index } from '../../../../../common/detection_engine/schemas/common'; @@ -23,7 +21,7 @@ export const getEsQueryFilter = async ( language: Language, filters: unknown, index: Index, - lists: Array, + lists: ExceptionListItemSchema[], excludeExceptions: boolean = true ): Promise => { const indexPattern: DataViewBase = { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 1a1238c36ca34..e2c1e0d31cd57 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -8,6 +8,10 @@ import { camelCase } from 'lodash'; import type { HttpStart } from '@kbn/core/public'; +import type { + CreateRuleExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, @@ -406,3 +410,30 @@ export const findRuleExceptionReferences = async ({ } ); }; + +/** + * Add exception items to default rule exception list + * + * @param ruleId `id` of rule to add items to + * @param items CreateRuleExceptionListItemSchema[] + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const addRuleExceptions = async ({ + ruleId, + items, + signal, +}: { + ruleId: string; + items: CreateRuleExceptionListItemSchema[]; + signal: AbortSignal | undefined; +}): Promise => + KibanaServices.get().http.fetch( + `${DETECTION_ENGINE_RULES_URL}/${ruleId}/exceptions`, + { + method: 'POST', + body: JSON.stringify({ items }), + signal, + } + ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 97c302d7038c5..2989b77ec28de 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -866,7 +866,10 @@ const RuleDetailsPageComponent: React.FC = ({ = ({ > { const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null; - const ruleIndex = useMemo( + const ruleIndexRaw = useMemo( () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ?? find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData) ?.values, [detailsData] ); + const ruleIndex = useMemo( + (): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined), + [ruleIndexRaw] + ); + const ruleDataViewIdRaw = useMemo( + () => + find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ?? + find( + { category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, + detailsData + )?.values, + [detailsData] + ); + const ruleDataViewId = useMemo( + (): string | undefined => + Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined, + [ruleDataViewIdRaw] + ); const addExceptionModalWrapperData = useMemo( () => @@ -102,12 +120,11 @@ export const FlyoutFooterComponent = React.memo( const { exceptionFlyoutType, + openAddExceptionFlyout, onAddExceptionTypeClick, onAddExceptionCancel, onAddExceptionConfirm, - ruleIndices, } = useExceptionFlyout({ - ruleIndex, refetch: refetchAll, isActiveTimelines: isActiveTimeline(scopeId), }); @@ -154,12 +171,13 @@ export const FlyoutFooterComponent = React.memo( {/* This is still wrong to do render flyout/modal inside of the flyout We need to completely refactor the EventDetails component to be correct */} - {exceptionFlyoutType != null && + {openAddExceptionFlyout && addExceptionModalWrapperData.ruleId != null && addExceptionModalWrapperData.eventId != null && ( `exception-list.attributes.list_id:${listId}`) + .map( + (listId, index) => + `${getSavedObjectType({ + namespaceType: namespaceTypes[index], + })}.attributes.list_id:${listId}` + ) .join(' OR ')})` : undefined, - namespaceType: ['agnostic', 'single'], + namespaceType: namespaceTypes, page: 1, perPage: 10000, sortField: undefined, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 76d8a094a43a6..cb1903d4a9616 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25624,7 +25624,6 @@ "xpack.securitySolution.eventsTab.unit": "{totalCount, plural, =1 {alerte externe} other {alertes externes}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, =1 {événement} other {événements}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "La liste d'exceptions ({id}) a été retirée avec succès", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "Afficher {comments, plural, =1 {commentaire} other {commentaires}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "Une erreur s'est produite lors du chargement des politiques : \"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "La liste d'exceptions associée ({listId}) n'existe plus. Veuillez retirer la liste d'exceptions manquante pour ajouter des exceptions supplémentaires à la règle de détection.", "xpack.securitySolution.exceptions.hideCommentsLabel": "Masquer ({comments}) {comments, plural, =1 {commentaire} other {commentaires}}", @@ -28164,58 +28163,14 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "Statut", "xpack.securitySolution.eventsViewer.eventsLabel": "Événements", "xpack.securitySolution.eventsViewer.showingLabel": "Affichage", - "xpack.securitySolution.exceptions.addException.addEndpointException": "Ajouter une exception de point de terminaison", - "xpack.securitySolution.exceptions.addException.addException": "Ajouter une exception à une règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.addException.cancel": "Annuler", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.addException.error": "Impossible d'ajouter l'exception", - "xpack.securitySolution.exceptions.addException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "Sélectionner un système d'exploitation", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception créée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.addException.success": "Exception ajoutée avec succès", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "Impossible de créer, de modifier ou de supprimer des exceptions", "xpack.securitySolution.exceptions.cancelLabel": "Annuler", "xpack.securitySolution.exceptions.clearExceptionsLabel": "Retirer la liste d'exceptions", "xpack.securitySolution.exceptions.commentEventLabel": "a ajouté un commentaire", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "Impossible de retirer la liste d'exceptions", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "Fermer toutes les alertes qui correspondent à cette exception et ont été générées par cette règle (les listes et les champs non ECS ne sont pas pris en charge)", - "xpack.securitySolution.exceptions.editException.cancel": "Annuler", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "Modifier une exception de point de terminaison", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "Enregistrer", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "Modifier une exception à une règle", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "Sur tous les hôtes Endpoint, les fichiers en quarantaine qui correspondent à l'exception sont automatiquement restaurés à leur emplacement d'origine. Cette exception s'applique à toutes les règles utilisant les exceptions Endpoint.", - "xpack.securitySolution.exceptions.editException.infoLabel": "Les alertes sont générées lorsque les conditions de la règle sont remplies, sauf quand :", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "La requête de cette règle contient une instruction de séquence EQL. L'exception modifiée s'appliquera à tous les événements de la séquence.", - "xpack.securitySolution.exceptions.editException.success": "L'exception a été mise à jour avec succès", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "Cette exception semble avoir été mise à jour depuis que vous l'avez sélectionnée pour la modifier. Essayez de cliquer sur \"Annuler\" et de modifier à nouveau l'exception.", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "Désolé, une erreur est survenue", "xpack.securitySolution.exceptions.errorLabel": "Erreur", "xpack.securitySolution.exceptions.fetchError": "Erreur lors de la récupération de la liste d'exceptions", "xpack.securitySolution.exceptions.modalErrorAccordionText": "Afficher les informations de référence de la règle :", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "existe", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "n'existe pas", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "n'est pas inclus dans", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "est l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "n'est pas l'une des options suivantes", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "N'EST PAS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "a", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "Système d'exploitation", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "NE CORRESPOND PAS À", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "CORRESPONDANCES", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "Créé", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "Supprimer un élément", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "Modifier l’élément", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "par", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "Mis à jour", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "Système d'exploitation", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", "xpack.securitySolution.exceptions.operatingSystemMac": "macOS", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a03f2a024c213..81bd0f3d33b03 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25599,7 +25599,6 @@ "xpack.securitySolution.eventsTab.unit": "外部{totalCount, plural, other {アラート}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {イベント}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外リスト({id})が正常に削除されました", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "{comments, plural, other {件のコメント}}を表示({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "ポリシーの読み込みエラーが発生しました:\"{error}\"", "xpack.securitySolution.exceptions.fetch404Error": "関連付けられた例外リスト({listId})は存在しません。その他の例外を検出ルールに追加するには、見つからない例外リストを削除してください。", "xpack.securitySolution.exceptions.hideCommentsLabel": "({comments}){comments, plural, other {件のコメント}}を非表示", @@ -28139,57 +28138,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "ステータス", "xpack.securitySolution.eventsViewer.eventsLabel": "イベント", "xpack.securitySolution.eventsViewer.showingLabel": "表示中", - "xpack.securitySolution.exceptions.addException.addEndpointException": "エンドポイント例外の追加", - "xpack.securitySolution.exceptions.addException.addException": "ルール例外の追加", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.addException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.addException.error": "例外を追加できませんでした", - "xpack.securitySolution.exceptions.addException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "オペレーティングシステムを選択", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。作成された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.addException.success": "正常に例外を追加しました", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "例外を作成、編集、削除できません", "xpack.securitySolution.exceptions.cancelLabel": "キャンセル", "xpack.securitySolution.exceptions.clearExceptionsLabel": "例外リストを削除", "xpack.securitySolution.exceptions.commentEventLabel": "コメントを追加しました", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "例外リストを削除できませんでした", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "この例外一致し、このルールによって生成された、すべてのアラートを閉じる", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "この例外と一致し、このルールによって生成された、すべてのアラートを閉じる(リストと非ECSフィールドはサポートされません)", - "xpack.securitySolution.exceptions.editException.cancel": "キャンセル", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "エンドポイント例外の編集", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "ルール例外を編集", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "すべてのエンドポイントホストで、例外と一致する隔離されたファイルは、自動的に元の場所に復元されます。この例外はエンドポイント例外を使用するすべてのルールに適用されます。", - "xpack.securitySolution.exceptions.editException.infoLabel": "ルールの条件が満たされるときにアラートが生成されます。例外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "このルールのクエリにはEQLシーケンス文があります。修正された例外は、シーケンスのすべてのイベントに適用されます。", - "xpack.securitySolution.exceptions.editException.success": "正常に例外を更新しました", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "最初に編集することを選択したときからこの例外が更新されている可能性があります。[キャンセル]をクリックし、もう一度例外を編集してください。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "申し訳ございません、エラーが発生しました", "xpack.securitySolution.exceptions.errorLabel": "エラー", "xpack.securitySolution.exceptions.fetchError": "例外リストの取得エラー", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "AND", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在する", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "存在しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "に含まれる", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "に含まれない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "is one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "is not one of", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "IS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "IS NOT", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "がある", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "一致しない", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "一致", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "作成済み", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "アイテムを削除", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "項目を編集", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "グループ基準", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "更新しました", "xpack.securitySolution.exceptions.modalErrorAccordionText": "ルール参照情報を表示:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "オペレーティングシステム", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 193d57ba8f62b..c1d8961ca766a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25633,7 +25633,6 @@ "xpack.securitySolution.eventsTab.unit": "个外部{totalCount, plural, other {告警}}", "xpack.securitySolution.eventsViewer.unit": "{totalCount, plural, other {个事件}}", "xpack.securitySolution.exceptions.dissasociateListSuccessText": "例外列表 ({id}) 已成功移除", - "xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel": "显示{comments, plural, other {注释}} ({comments})", "xpack.securitySolution.exceptions.failedLoadPolicies": "加载策略时出错:“{error}”", "xpack.securitySolution.exceptions.fetch404Error": "关联的例外列表 ({listId}) 已不存在。请移除缺少的例外列表,以将其他例外添加到检测规则。", "xpack.securitySolution.exceptions.hideCommentsLabel": "隐藏 ({comments}) 个{comments, plural, other {注释}}", @@ -28173,57 +28172,13 @@ "xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle": "状态", "xpack.securitySolution.eventsViewer.eventsLabel": "事件", "xpack.securitySolution.eventsViewer.showingLabel": "正在显示", - "xpack.securitySolution.exceptions.addException.addEndpointException": "添加终端例外", - "xpack.securitySolution.exceptions.addException.addException": "添加规则例外", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.addException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.addException.cancel": "取消", - "xpack.securitySolution.exceptions.addException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.addException.error": "添加例外失败", - "xpack.securitySolution.exceptions.addException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.addException.operatingSystemPlaceHolder": "选择操作系统", - "xpack.securitySolution.exceptions.addException.sequenceWarning": "此规则的查询包含 EQL 序列语句。创建的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.addException.success": "已成功添加例外", "xpack.securitySolution.exceptions.badge.readOnly.tooltip": "无法创建、编辑或删除例外", "xpack.securitySolution.exceptions.cancelLabel": "取消", "xpack.securitySolution.exceptions.clearExceptionsLabel": "移除例外列表", "xpack.securitySolution.exceptions.commentEventLabel": "已添加注释", "xpack.securitySolution.exceptions.dissasociateExceptionListError": "无法移除例外列表", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel": "关闭所有与此例外匹配且根据此规则生成的告警", - "xpack.securitySolution.exceptions.editException.bulkCloseLabel.disabled": "关闭所有与此例外匹配且根据此规则生成的告警(不支持列表和非 ECS 字段)", - "xpack.securitySolution.exceptions.editException.cancel": "取消", - "xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle": "编辑终端例外", - "xpack.securitySolution.exceptions.editException.editExceptionSaveButton": "保存", - "xpack.securitySolution.exceptions.editException.editExceptionTitle": "编辑规则例外", - "xpack.securitySolution.exceptions.editException.endpointQuarantineText": "在所有终端主机上,与该例外匹配的已隔离文件会自动还原到其原始位置。此例外适用于使用终端例外的所有规则。", - "xpack.securitySolution.exceptions.editException.infoLabel": "满足规则的条件时生成告警,但以下情况除外:", - "xpack.securitySolution.exceptions.editException.sequenceWarning": "此规则的查询包含 EQL 序列语句。修改的例外将应用于序列中的所有事件。", - "xpack.securitySolution.exceptions.editException.success": "已成功更新例外", - "xpack.securitySolution.exceptions.editException.versionConflictDescription": "此例外可能自您首次选择编辑后已更新。尝试单击“取消”,重新编辑该例外。", - "xpack.securitySolution.exceptions.editException.versionConflictTitle": "抱歉,有错误", "xpack.securitySolution.exceptions.errorLabel": "错误", "xpack.securitySolution.exceptions.fetchError": "提取例外列表时出错", - "xpack.securitySolution.exceptions.exceptionItem.conditions.and": "且", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator": "存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator.not": "不存在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.linux": "Linux", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator": "包含在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator.not": "未包括在", - "xpack.securitySolution.exceptions.exceptionItem.conditions.macos": "Mac", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator": "属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not": "不属于", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator": "是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not": "不是", - "xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator": "具有", - "xpack.securitySolution.exceptions.exceptionItem.conditions.os": "OS", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardDoesNotMatchOperator": "不匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator": "匹配", - "xpack.securitySolution.exceptions.exceptionItem.conditions.windows": "Windows", - "xpack.securitySolution.exceptions.exceptionItem.createdLabel": "创建时间", - "xpack.securitySolution.exceptions.exceptionItem.deleteItemButton": "删除项", - "xpack.securitySolution.exceptions.exceptionItem.editItemButton": "编辑项目", - "xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy": "依据", - "xpack.securitySolution.exceptions.exceptionItem.updatedLabel": "已更新", "xpack.securitySolution.exceptions.modalErrorAccordionText": "显示规则引用信息:", "xpack.securitySolution.exceptions.operatingSystemFullLabel": "操作系统", "xpack.securitySolution.exceptions.operatingSystemLinux": "Linux", diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts index 7619c6f3e359a..173aaa86c4328 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/find_rule_exception_references.ts @@ -210,7 +210,7 @@ export default ({ getService }: FtrProviderContext) => { .get(DETECTION_ENGINE_RULES_EXCEPTIONS_REFERENCE_URL) .set('kbn-xsrf', 'true') .query({ - namespace_types: `${exceptionList.namespace_type},${exceptionList2.namespace_type}`, + namespace_types: 'single,agnostic', }) .expect(200);