diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index be89aa8e33718..10d510c5f56c3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -43,6 +43,7 @@ import { defaultEndpointExceptionItems, entryHasListType, entryHasNonEcsType, + getMappedNonEcsValue, } from '../helpers'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; @@ -65,7 +66,7 @@ interface AddExceptionModalProps { nonEcsData: TimelineNonEcsData[]; }; onCancel: () => void; - onConfirm: () => void; + onConfirm: (didCloseAlert: boolean) => void; } const Modal = styled(EuiModal)` @@ -130,8 +131,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onSuccess = useCallback(() => { displaySuccessToast(i18n.ADD_EXCEPTION_SUCCESS, dispatchToaster); - onConfirm(); - }, [dispatchToaster, onConfirm]); + onConfirm(shouldCloseAlert); + }, [dispatchToaster, onConfirm, shouldCloseAlert]); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { @@ -193,6 +194,12 @@ export const AddExceptionModal = memo(function AddExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const onCommentChange = useCallback( (value: string) => { setComment(value); @@ -214,6 +221,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [setShouldBulkCloseAlert] ); + const retrieveAlertOsTypes = useCallback(() => { + const osDefaults = ['windows', 'macos', 'linux']; + if (alertData) { + const osTypes = getMappedNonEcsValue({ + data: alertData.nonEcsData, + fieldName: 'host.os.family', + }); + if (osTypes.length === 0) { + return osDefaults; + } + return osTypes; + } + return osDefaults; + }, [alertData]); + const enrichExceptionItems = useCallback(() => { let enriched: Array = []; enriched = @@ -221,11 +243,11 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ? enrichExceptionItemsWithComments(exceptionItemsToAdd, [{ comment }]) : exceptionItemsToAdd; if (exceptionListType === 'endpoint') { - const osTypes = alertData ? ['windows'] : ['windows', 'macos', 'linux']; + const osTypes = retrieveAlertOsTypes(); enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; - }, [comment, exceptionItemsToAdd, exceptionListType, alertData]); + }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index aa36b65e04b69..0ef3350fbefc7 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -37,7 +37,7 @@ import { AddExceptionComments } from '../add_exception_comments'; import { enrichExceptionItemsWithComments, enrichExceptionItemsWithOS, - getOsTagValues, + getOperatingSystems, entryHasListType, entryHasNonEcsType, } from '../helpers'; @@ -135,6 +135,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ indexPatterns, ]); + useEffect(() => { + if (shouldDisableBulkClose === true) { + setShouldBulkCloseAlert(false); + } + }, [shouldDisableBulkClose]); + const handleBuilderOnChange = useCallback( ({ exceptionItems, @@ -167,7 +173,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ...(comment !== '' ? [{ comment }] : []), ]); if (exceptionListType === 'endpoint') { - const osTypes = exceptionItem._tags ? getOsTagValues(exceptionItem._tags) : ['windows']; + const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : []; enriched = enrichExceptionItemsWithOS(enriched, osTypes); } return enriched; @@ -199,6 +205,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {!isSignalIndexLoading && ( <> + {i18n.EXCEPTION_BUILDER_INFO} + { beforeEach(() => { @@ -248,6 +258,36 @@ describe('Exception helpers', () => { }); }); + describe('#getEntryValue', () => { + it('returns "match" entry value', () => { + const payload = getEntryMatchMock(); + const result = getEntryValue(payload); + const expected = 'some host name'; + expect(result).toEqual(expected); + }); + + it('returns "match any" entry values', () => { + const payload = getEntryMatchAnyMock(); + const result = getEntryValue(payload); + const expected = ['some host name']; + expect(result).toEqual(expected); + }); + + it('returns "exists" entry value', () => { + const payload = getEntryExistsMock(); + const result = getEntryValue(payload); + const expected = undefined; + expect(result).toEqual(expected); + }); + + it('returns "list" entry value', () => { + const payload = getEntryListMock(); + const result = getEntryValue(payload); + const expected = 'some-list-id'; + expect(result).toEqual(expected); + }); + }); + describe('#formatEntry', () => { test('it formats an entry', () => { const payload = getEntryMatchMock(); @@ -280,25 +320,55 @@ describe('Exception helpers', () => { test('it returns null if no operating system tag specified', () => { const result = getOperatingSystems(['some tag', 'some other tag']); - expect(result).toEqual(''); + expect(result).toEqual([]); }); test('it returns null if operating system tag malformed', () => { const result = getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']); + expect(result).toEqual([]); + }); + + test('it returns operating systems if space included in os tag', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag']); + expect(result).toEqual(['macos']); + }); + + test('it returns operating systems if multiple os tags specified', () => { + const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']); + expect(result).toEqual(['macos', 'windows']); + }); + }); + + describe('#formatOperatingSystems', () => { + test('it returns null if no operating system tag specified', () => { + const result = formatOperatingSystems(getOperatingSystems(['some tag', 'some other tag'])); + + expect(result).toEqual(''); + }); + + test('it returns null if operating system tag malformed', () => { + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']) + ); + expect(result).toEqual(''); }); test('it returns formatted operating systems if space included in os tag', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag']) + ); - expect(result).toEqual('Mac'); + expect(result).toEqual('macOS'); }); test('it returns formatted operating systems if multiple os tags specified', () => { - const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag', 'os:windows']); + const result = formatOperatingSystems( + getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']) + ); - expect(result).toEqual('Mac, Windows'); + expect(result).toEqual('macOS, Windows'); }); }); @@ -441,4 +511,176 @@ describe('Exception helpers', () => { expect(exceptions).toEqual([{ ...rest, meta: undefined }]); }); }); + + describe('#formatExceptionItemForUpdate', () => { + test('it should return correct update fields', () => { + const payload = getExceptionListItemSchemaMock(); + const result = formatExceptionItemForUpdate(payload); + const expected = { + _tags: ['endpoint', 'process', 'malware', 'os:linux'], + comments: [], + description: 'This is a sample endpoint type exception', + entries: ENTRIES, + id: '1', + item_id: 'endpoint_list_item', + meta: {}, + name: 'Sample Endpoint Exception List', + namespace_type: 'single', + tags: ['user added string for a tag', 'malware'], + type: 'simple', + }; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithComments', () => { + test('it should add comments to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add comments to multiple exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const comments = getCommentsArrayMock(); + const result = enrichExceptionItemsWithComments(payload, comments); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + { + ...getExceptionListItemSchemaMock(), + comments: getCommentsArrayMock(), + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#enrichExceptionItemsWithOS', () => { + test('it should add an os tag to an exception item', () => { + const payload = [getExceptionListItemSchemaMock()]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add multiple os tags to all exception items', () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const osTypes = ['windows', 'macos']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], + }, + ]; + expect(result).toEqual(expected); + }); + + test('it should add os tag to all exception items without duplication', () => { + const payload = [ + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux', 'os:windows'] }, + { ...getExceptionListItemSchemaMock(), _tags: ['os:linux'] }, + ]; + const osTypes = ['windows']; + const result = enrichExceptionItemsWithOS(payload, osTypes); + const expected = [ + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + { + ...getExceptionListItemSchemaMock(), + _tags: ['os:linux', 'os:windows'], + }, + ]; + expect(result).toEqual(expected); + }); + }); + + 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('#entryHasNonEcsType', () => { + const mockEcsIndexPattern = { + title: 'testIndex', + fields: [ + { + name: 'some.parentField', + }, + { + name: 'some.not.nested.field', + }, + { + name: 'nested.field', + }, + ], + } as IIndexPattern; + + 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); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index db7cb5aeac8f0..481b2736b7597 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -14,7 +14,6 @@ import * as i18n from './translations'; import { FormattedEntry, BuilderEntry, - EmptyListEntry, DescriptionListItem, FormattedBuilderEntry, CreateExceptionListItemBuilderSchema, @@ -39,9 +38,6 @@ import { ExceptionListType, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; - -export const isListType = (item: BuilderEntry): item is EmptyListEntry => - item.type === OperatorTypeEnum.LIST; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -82,11 +78,6 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption = } }; -export const getExceptionOperatorFromSelect = (value: string): OperatorOption => { - const operator = EXCEPTION_OPERATORS.filter(({ message }) => message === value); - return operator[0] ?? isOperator; -}; - /** * Formats ExceptionItem entries into simple field, operator, value * for use in rendering items in table @@ -158,19 +149,32 @@ export const formatEntry = ({ }; }; -export const getOperatingSystems = (tags: string[]): string => { - const osMatches = tags - .filter((tag) => tag.startsWith('os:')) - .map((os) => capitalize(os.substring(3).trim())) - .join(', '); - - return osMatches; +/** + * Retrieves the values of tags marked as os + * + * @param tags an ExceptionItem's tags + */ +export const getOperatingSystems = (tags: string[]): string[] => { + return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); }; -export const getOsTagValues = (tags: string[]): string[] => { - return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); +/** + * Formats os value array to a displayable string + */ +export const formatOperatingSystems = (osTypes: string[]): string => { + return osTypes + .map((os) => { + if (os === 'macos') { + return 'macOS'; + } + return capitalize(os); + }) + .join(', '); }; +/** + * Returns all tags that match a given regex + */ export const getTagsInclude = ({ tags, regex, @@ -194,7 +198,7 @@ export const getDescriptionListContent = ( const details = [ { title: i18n.OPERATING_SYSTEM, - value: getOperatingSystems(exceptionItem._tags), + value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), }, { title: i18n.DATE_CREATED, @@ -376,6 +380,11 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Adds new and existing comments to all new exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param comments new Comments + */ export const enrichExceptionItemsWithComments = ( exceptionItems: Array, comments: Array @@ -388,6 +397,11 @@ export const enrichExceptionItemsWithComments = ( }); }; +/** + * Adds provided osTypes to all exceptionItems if not present already + * @param exceptionItems new or existing ExceptionItem[] + * @param osTypes array of os values + */ export const enrichExceptionItemsWithOS = ( exceptionItems: Array, osTypes: string[] @@ -402,18 +416,21 @@ export const enrichExceptionItemsWithOS = ( }); }; +/** + * Returns the value for the given fieldname within TimelineNonEcsData if it exists + */ export const getMappedNonEcsValue = ({ data, fieldName, }: { data: TimelineNonEcsData[]; fieldName: string; -}): string[] | undefined => { +}): string[] => { const item = data.find((d) => d.field === fieldName); if (item != null && item.value != null) { return item.value; } - return undefined; + return []; }; export const entryHasListType = ( @@ -421,7 +438,7 @@ export const entryHasListType = ( ) => { for (const { entries } of exceptionItems) { for (const exceptionEntry of entries ?? []) { - if (getOperatorType(exceptionEntry) === 'list') { + if (getOperatorType(exceptionEntry) === OperatorTypeEnum.LIST) { return true; } } @@ -429,16 +446,29 @@ export const entryHasListType = ( return false; }; +/** + * Determines whether or not any entries within the given exceptionItems contain values not in the specified ECS mapping + */ export const entryHasNonEcsType = ( exceptionItems: Array, indexPatterns: IIndexPattern ): 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 (indexPatterns.fields.find(({ name }) => name === exceptionEntry.field) === undefined) { + if (exceptionEntry.type === 'nested') { + for (const nestedExceptionEntry of exceptionEntry.entries) { + if (doesFieldNameExist(nestedExceptionEntry) === false) { + return true; + } + } + } else if (doesFieldNameExist(exceptionEntry) === false) { return true; } } @@ -446,19 +476,25 @@ export const entryHasNonEcsType = ( return false; }; +/** + * Returns the default values from the alert data to autofill new endpoint exceptions + */ export const defaultEndpointExceptionItems = ( listType: ExceptionListType, listId: string, ruleName: string, alertData: TimelineNonEcsData[] ): ExceptionsBuilderExceptionItem[] => { - const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }) ?? []; - const [signatureSigner] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.subject_name' }) ?? - []; - const [signatureTrusted] = - getMappedNonEcsValue({ data: alertData, fieldName: 'file.Ext.code_signature.trusted' }) ?? []; - const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }) ?? []; + const [filePath] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.path' }); + const [signatureSigner] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.subject_name', + }); + const [signatureTrusted] = getMappedNonEcsValue({ + data: alertData, + fieldName: 'file.Ext.code_signature.trusted', + }); + const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); const namespaceType = 'agnostic'; return [ @@ -483,7 +519,7 @@ export const defaultEndpointExceptionItems = ( value: signatureSigner ?? '', }, { - field: 'file.code_signature.trusted', + field: 'file.Ext.code_signature.trusted', operator: 'included', type: 'match', value: signatureTrusted ?? '', @@ -508,7 +544,7 @@ export const defaultEndpointExceptionItems = ( field: 'event.category', operator: 'included', type: 'match_any', - value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }) ?? [], + value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }), }, ], }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index cdfa358a0f9c2..3d9fe2ebaddae 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -184,7 +184,11 @@ const ExceptionsViewerComponent = ({ [setCurrentModal] ); - const handleCloseExceptionModal = useCallback((): void => { + const handleOnCancelExceptionModal = useCallback((): void => { + setCurrentModal(null); + }, [setCurrentModal]); + + const handleOnConfirmExceptionModal = useCallback((): void => { setCurrentModal(null); handleFetchList(); }, [setCurrentModal, handleFetchList]); @@ -255,8 +259,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} exceptionListType={exceptionListTypeToEdit} exceptionItem={exceptionToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} @@ -265,8 +269,8 @@ const ExceptionsViewerComponent = ({ ruleName={ruleName} ruleId={ruleId} exceptionListType={exceptionListTypeToEdit} - onCancel={handleCloseExceptionModal} - onConfirm={handleCloseExceptionModal} + onCancel={handleOnCancelExceptionModal} + onConfirm={handleOnConfirmExceptionModal} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 697dff4012982..e95ea4531d9ad 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -36,7 +36,7 @@ import { SetEventsLoadingProps, UpdateTimelineLoading, } from './types'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { AddExceptionOnClick } from '../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../common/components/exceptions/helpers'; @@ -174,6 +174,8 @@ export const requiredFieldsForActions = [ 'signal.rule.query', 'signal.rule.to', 'signal.rule.id', + 'signal.original_event.kind', + 'signal.original_event.module', // Endpoint exception fields 'file.path', @@ -189,6 +191,7 @@ interface AlertActionArgs { createTimeline: CreateTimeline; dispatch: Dispatch; ecsRowData: Ecs; + nonEcsRowData: TimelineNonEcsData[]; hasIndexWrite: boolean; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; onAlertStatusUpdateSuccess: (count: number, status: Status) => void; @@ -211,6 +214,7 @@ export const getAlertActions = ({ createTimeline, dispatch, ecsRowData, + nonEcsRowData, hasIndexWrite, onAlertStatusUpdateFailure, onAlertStatusUpdateSuccess, @@ -281,6 +285,18 @@ export const getAlertActions = ({ width: DEFAULT_ICON_BUTTON_WIDTH, }; + const isEndpointAlert = () => { + const [module] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.module', + }); + const [kind] = getMappedNonEcsValue({ + data: nonEcsRowData, + fieldName: 'signal.original_event.kind', + }); + return module === 'endpoint' && kind === 'alert'; + }; + return [ { ...getInvestigateInResolverAction({ dispatch, timelineId }), @@ -305,15 +321,14 @@ export const getAlertActions = ({ ...(FILTER_OPEN !== status ? [openAlertActionComponent] : []), ...(FILTER_CLOSED !== status ? [closeAlertActionComponent] : []), ...(FILTER_IN_PROGRESS !== status ? [inProgressAlertActionComponent] : []), - // TODO: disable this option if the alert is not an Endpoint alert { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'endpoint', alertData: { ecsData, @@ -323,7 +338,7 @@ export const getAlertActions = ({ } }, id: 'addEndpointException', - isActionDisabled: () => !canUserCRUD || !hasIndexWrite, + isActionDisabled: () => !canUserCRUD || !hasIndexWrite || !isEndpointAlert(), dataTestSubj: 'add-endpoint-exception-menu-item', ariaLabel: 'Add Endpoint Exception', content: {i18n.ACTION_ADD_ENDPOINT_EXCEPTION}, @@ -331,12 +346,12 @@ export const getAlertActions = ({ }, { onClick: ({ ecsData, data }: TimelineRowActionOnClick) => { - const ruleNameValue = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); - const ruleId = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); - if (ruleId !== undefined && ruleId.length > 0) { + const [ruleName] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.name' }); + const [ruleId] = getMappedNonEcsValue({ data, fieldName: 'signal.rule.id' }); + if (ruleId !== undefined) { openAddExceptionModal({ - ruleName: ruleNameValue ? ruleNameValue[0] : '', - ruleId: ruleId[0], + ruleName: ruleName ?? '', + ruleId, exceptionListType: 'detection', alertData: { ecsData, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 81aebe95930ac..b9b963a84e966 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -22,7 +22,10 @@ import { inputsSelectors, State, inputsModel } from '../../../common/store'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; -import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { + useManageTimeline, + TimelineRowActionArgs, +} from '../../../timelines/components/manage_timeline'; import { useApolloClient } from '../../../common/utils/apollo_context'; import { updateAlertStatusAction } from './actions'; @@ -48,7 +51,6 @@ import { displaySuccessToast, displayErrorToast, } from '../../../common/components/toasters'; -import { Ecs } from '../../../graphql/types'; import { getInvestigateInResolverAction } from '../../../timelines/components/timeline/body/helpers'; import { AddExceptionModal, @@ -321,12 +323,13 @@ export const AlertsTableComponent: React.FC = ({ // Send to Timeline / Update Alert Status Actions for each table row const additionalActions = useMemo( - () => (ecsRowData: Ecs) => + () => ({ ecsData, nonEcsData }: TimelineRowActionArgs) => getAlertActions({ apolloClient, canUserCRUD, createTimeline: createTimelineCallback, - ecsRowData, + ecsRowData: ecsData, + nonEcsRowData: nonEcsData, dispatch, hasIndexWrite, onAlertStatusUpdateFailure, @@ -401,9 +404,12 @@ export const AlertsTableComponent: React.FC = ({ closeAddExceptionModal(); }, [closeAddExceptionModal]); - const onAddExceptionConfirm = useCallback(() => { - closeAddExceptionModal(); - }, [closeAddExceptionModal]); + const onAddExceptionConfirm = useCallback( + (didCloseAlert: boolean) => { + closeAddExceptionModal(); + }, + [closeAddExceptionModal] + ); if (loading || isEmpty(signalsIndex)) { return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx index 99d79a2441ccc..7882185cbd9d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/manage_timeline/index.tsx @@ -14,7 +14,7 @@ import { SubsetTimelineModel } from '../../store/timeline/model'; import * as i18n from '../../../common/components/events_viewer/translations'; import * as i18nF from '../timeline/footer/translations'; import { timelineDefaults as timelineDefaultModel } from '../../store/timeline/defaults'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; interface ManageTimelineInit { documentType?: string; @@ -25,11 +25,16 @@ interface ManageTimelineInit { indexToAdd?: string[] | null; loadingText?: string; selectAll?: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title?: string; unit?: (totalCount: number) => string; } +export interface TimelineRowActionArgs { + ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; +} + interface ManageTimeline { documentType: string; defaultModel: SubsetTimelineModel; @@ -41,7 +46,7 @@ interface ManageTimeline { loadingText: string; queryFields: string[]; selectAll: boolean; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; title: string; unit: (totalCount: number) => string; } @@ -71,7 +76,7 @@ type ActionManageTimeline = id: string; payload: { queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }; }; @@ -142,7 +147,7 @@ interface UseTimelineManager { setTimelineRowActions: (actionsArgs: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => void; } @@ -167,7 +172,7 @@ const useTimelineManager = (manageTimelineForTesting?: ManageTimelineById): UseT }: { id: string; queryFields?: string[]; - timelineRowActions: (ecsData: Ecs) => TimelineRowAction[]; + timelineRowActions: ({ ecsData, nonEcsData }: TimelineRowActionArgs) => TimelineRowAction[]; }) => { dispatch({ type: 'SET_TIMELINE_ACTIONS', diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx index a450d082cb85d..63de117aeaf3d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/event_column_view.tsx @@ -90,8 +90,8 @@ export const EventColumnView = React.memo( }) => { const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => getManageTimelineById(timelineId).timelineRowActions(ecsData), - [ecsData, getManageTimelineById, timelineId] + () => getManageTimelineById(timelineId).timelineRowActions({ nonEcsData: data, ecsData }), + [data, ecsData, getManageTimelineById, timelineId] ); const [isPopoverOpen, setPopover] = useState(false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 8f8f19020697c..b474e4047eadd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -33,6 +33,7 @@ import { Sort } from './sort'; import { useManageTimeline } from '../../manage_timeline'; import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; +import { TimelineRowAction } from './actions'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -104,7 +105,18 @@ export const Body = React.memo( const containerElementRef = useRef(null); const { getManageTimelineById } = useManageTimeline(); const timelineActions = useMemo( - () => (data.length > 0 ? getManageTimelineById(id).timelineRowActions(data[0].ecs) : []), + () => + data.reduce((acc: TimelineRowAction[], rowData) => { + const rowActions = getManageTimelineById(id).timelineRowActions({ + ecsData: rowData.ecs, + nonEcsData: rowData.data, + }); + return rowActions && + rowActions.filter((v) => v.displayType === 'icon').length > + acc.filter((v) => v.displayType === 'icon').length + ? rowActions + : acc; + }, []), [data, getManageTimelineById, id] );