-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[Security Solution][Detection Alerts] Alert tagging #157786
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
3ad0adf
233586d
c5029b6
96658d8
c77aa15
d391a76
a06d3b5
1a5c13c
bbd7a89
938e805
3bcd05a
3b9291b
d3fda33
488293a
aa26c0a
09cae98
49c39b8
f9f91f9
b24c6d0
10eaf14
7d3d600
a9cc608
8698128
5f291df
f877402
2df309a
982b81f
a352269
8339d9f
64b5077
69cbf20
4a6a735
db9bc72
0119e69
9d8ae0c
baf4fde
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ export interface UsageStats { | |
| */ | ||
| 'securitySolution:defaultIndex': string; | ||
| 'securitySolution:defaultThreatIndex': string; | ||
| 'securitySolution:alertTags': string; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: should this be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking that in the previous commit here, yeah, but I ran into a test failing with an error message about this type having to be a keyword and not an array, like the other |
||
| 'securitySolution:newsFeedUrl': string; | ||
| 'xpackReporting:customPdfLogo': string; | ||
| 'notifications:banner': string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,8 @@ export type SignalIds = t.TypeOf<typeof signal_ids>; | |
| // TODO: Can this be more strict or is this is the set of all Elastic Queries? | ||
| export const signal_status_query = t.object; | ||
|
|
||
| export const alert_tag_query = t.object; // TODO: i agree with the above TODO | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
|
|
||
| export const fields = t.array(t.string); | ||
| export type Fields = t.TypeOf<typeof fields>; | ||
| export const fieldsOrUndefined = t.union([fields, t.undefined]); | ||
|
|
@@ -125,3 +127,10 @@ export const privilege = t.type({ | |
| }); | ||
|
|
||
| export type Privilege = t.TypeOf<typeof privilege>; | ||
|
|
||
| export const alert_tags = t.type({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think of following the rules bulk edit tags schema? It may make it easier to expand the feature by following a more reusable pattern like: That way if we decide theres a new action type we want to introduce for tags, the schema updates are minimal.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd be open to more discussion on this pertaining to more reusable patterns and expandability, it's definitely a bit unusual, but the main reason we created it like this in the first place was because we have the intermediate state in the bulk actions tags dropdown, so we couldn't just handle setting all queried alerts to the same tags array. With the two different buckets of add and remove we can cover each of the possible use cases for each specific alert with one api call. |
||
| tags_to_add: t.array(t.string), | ||
| tags_to_remove: t.array(t.string), | ||
| }); | ||
|
|
||
| export type AlertTags = t.TypeOf<typeof alert_tags>; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 * as t from 'io-ts'; | ||
|
|
||
| import { alert_tag_query, alert_tags } from '../common/schemas'; | ||
|
|
||
| export const setAlertTagsSchema = t.intersection([ | ||
| t.type({ | ||
| tags: alert_tags, | ||
| }), | ||
| t.partial({ | ||
| query: alert_tag_query, | ||
| }), | ||
| ]); | ||
|
|
||
| export type SetAlertTagsSchema = t.TypeOf<typeof setAlertTagsSchema>; | ||
| export type SetAlertTagsSchemaDecoded = SetAlertTagsSchema; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /* | ||
| * 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 DUPLICATE = i18n.translate('xpack.securitySolution.defaultAlertTags.duplicate', { | ||
| defaultMessage: 'Duplicate', | ||
| }); | ||
|
|
||
| export const FALSE_POSITIVE = i18n.translate( | ||
| 'xpack.securitySolution.defaultAlertTags.falsePositive', | ||
| { | ||
| defaultMessage: 'False Positive', | ||
| } | ||
| ); | ||
|
|
||
| export const FURTHER_INVESTIGATION_REQUIRED = i18n.translate( | ||
| 'xpack.securitySolution.defaultAlertTags.furtherInvestigationRequired', | ||
| { | ||
| defaultMessage: 'Further investigation required', | ||
| } | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| /* | ||
| * 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 { EuiSelectableOption } from '@elastic/eui'; | ||
| import { EuiPopoverTitle, EuiSelectable, EuiButton } from '@elastic/eui'; | ||
| import type { TimelineItem } from '@kbn/timelines-plugin/common'; | ||
| import React, { memo, useCallback, useMemo, useState } from 'react'; | ||
| import { TAGS } from '@kbn/rule-data-utils'; | ||
| import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable'; | ||
| import { getUpdateAlertsQuery } from '../../../../detections/components/alerts_table/actions'; | ||
| import { DEFAULT_ALERT_TAGS_KEY } from '../../../../../common/constants'; | ||
| import { useUiSetting$ } from '../../../lib/kibana'; | ||
| import { useSetAlertTags } from './use_set_alert_tags'; | ||
| import { useAppToasts } from '../../../hooks/use_app_toasts'; | ||
| import * as i18n from './translations'; | ||
| import { createInitialTagsState } from './helpers'; | ||
|
|
||
| interface BulkAlertTagsPanelComponentProps { | ||
| alertItems: TimelineItem[]; | ||
| refetchQuery?: () => void; | ||
| setIsLoading: (isLoading: boolean) => void; | ||
| refresh?: () => void; | ||
| clearSelection?: () => void; | ||
| closePopoverMenu: () => void; | ||
| } | ||
| const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> = ({ | ||
| alertItems, | ||
| refresh, | ||
| refetchQuery, | ||
| setIsLoading, | ||
| clearSelection, | ||
| closePopoverMenu, | ||
| }) => { | ||
| const [defaultAlertTagOptions] = useUiSetting$<string[]>(DEFAULT_ALERT_TAGS_KEY); | ||
| const { addSuccess, addError, addWarning } = useAppToasts(); | ||
|
|
||
| const { setAlertTags } = useSetAlertTags(); | ||
| const existingTags = useMemo( | ||
| () => alertItems.map((item) => item.data.find((data) => data.field === TAGS)?.value ?? []), | ||
| [alertItems] | ||
| ); | ||
| const initalTagsState = useMemo( | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| () => createInitialTagsState(existingTags, defaultAlertTagOptions), | ||
| [existingTags, defaultAlertTagOptions] | ||
| ); | ||
|
|
||
| const tagsToAdd: Record<string, boolean> = useMemo(() => ({}), []); | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| const tagsToRemove: Record<string, boolean> = useMemo(() => ({}), []); | ||
|
|
||
| const onUpdateSuccess = useCallback( | ||
| (updated: number, conflicts: number) => { | ||
| if (conflicts > 0) { | ||
| addWarning({ | ||
| title: i18n.UPDATE_ALERT_TAGS_FAILED(conflicts), | ||
| text: i18n.UPDATE_ALERT_TAGS_FAILED_DETAILED(updated, conflicts), | ||
| }); | ||
| } else { | ||
| addSuccess(i18n.UPDATE_ALERT_TAGS_SUCCESS_TOAST(updated)); | ||
| } | ||
| }, | ||
| [addSuccess, addWarning] | ||
| ); | ||
|
|
||
| const onUpdateFailure = useCallback( | ||
| (error: Error) => { | ||
| addError(error.message, { title: i18n.UPDATE_ALERT_TAGS_FAILURE }); | ||
| }, | ||
| [addError] | ||
| ); | ||
|
|
||
| const [selectableAlertTags, setSelectableAlertTags] = | ||
| useState<EuiSelectableOption[]>(initalTagsState); | ||
|
|
||
| const onTagsUpdate = useCallback(async () => { | ||
| closePopoverMenu(); | ||
| const ids = alertItems.map((item) => item._id); | ||
| const query: Record<string, unknown> = getUpdateAlertsQuery(ids).query; | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| const tagsToAddArray = Object.keys(tagsToAdd); | ||
| const tagsToRemoveArray = Object.keys(tagsToRemove); | ||
| try { | ||
| setIsLoading(true); | ||
|
|
||
| const response = await setAlertTags({ | ||
| tags: { tags_to_add: tagsToAddArray, tags_to_remove: tagsToRemoveArray }, | ||
| query, | ||
| }); | ||
|
|
||
| setIsLoading(false); | ||
| if (refetchQuery) refetchQuery(); | ||
| if (refresh) refresh(); | ||
| if (clearSelection) clearSelection(); | ||
|
Comment on lines
+67
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we group all these props under a single |
||
|
|
||
| if (response.version_conflicts && ids.length === 1) { | ||
| throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT); | ||
| } | ||
|
|
||
| onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0); | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| } catch (err) { | ||
| onUpdateFailure(err); | ||
| } | ||
| }, [ | ||
| closePopoverMenu, | ||
| alertItems, | ||
| tagsToAdd, | ||
| tagsToRemove, | ||
| setIsLoading, | ||
| setAlertTags, | ||
| refetchQuery, | ||
| refresh, | ||
| clearSelection, | ||
| onUpdateSuccess, | ||
| onUpdateFailure, | ||
| ]); | ||
|
|
||
| const handleTagsOnChange = ( | ||
| newOptions: EuiSelectableOption[], | ||
| event: EuiSelectableOnChangeEvent, | ||
| changedOption: EuiSelectableOption | ||
| ) => { | ||
| if (changedOption.checked === 'off') { | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| // Don't allow intermediate state when selecting, only from initial state | ||
| newOptions[newOptions.findIndex((option) => option.label === changedOption.label)] = { | ||
| ...changedOption, | ||
| checked: undefined, | ||
| }; | ||
| tagsToRemove[changedOption.label] = true; | ||
| delete tagsToAdd[changedOption.label]; | ||
| } else if (changedOption.checked === 'on') { | ||
| tagsToAdd[changedOption.label] = true; | ||
| delete tagsToRemove[changedOption.label]; | ||
| } else if (!changedOption.checked) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here are you checking if the |
||
| tagsToRemove[changedOption.label] = true; | ||
| delete tagsToAdd[changedOption.label]; | ||
| } | ||
| setSelectableAlertTags(newOptions); | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <EuiSelectable | ||
| allowExclusions | ||
| searchable | ||
| searchProps={{ | ||
| placeholder: i18n.ALERT_TAGS_MENU_SEARCH_PLACEHOLDER, | ||
| }} | ||
| aria-label={i18n.ALERT_TAGS_MENU_SEARCH_PLACEHOLDER} | ||
| options={selectableAlertTags} | ||
| onChange={handleTagsOnChange} | ||
| emptyMessage={i18n.ALERT_TAGS_MENU_EMPTY} | ||
| noMatchesMessage={i18n.ALERT_TAGS_MENU_SEARCH_NO_TAGS_FOUND} | ||
| > | ||
| {(list, search) => ( | ||
| <div> | ||
| <EuiPopoverTitle>{search}</EuiPopoverTitle> | ||
| {list} | ||
| </div> | ||
| )} | ||
| </EuiSelectable> | ||
| <EuiButton fullWidth size="s" onClick={onTagsUpdate}> | ||
| {i18n.ALERT_TAGS_UPDATE_BUTTON_MESSAGE} | ||
| </EuiButton> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| export const BulkAlertTagsPanel = memo(BulkAlertTagsPanelComponent); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| /* | ||
| * 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 { createInitialTagsState } from './helpers'; | ||
|
|
||
| const defaultTags = ['test 1', 'test 2', 'test 3']; | ||
|
|
||
| describe('createInitialTagsState', () => { | ||
| it('should return default tags if no existing tags are provided ', () => { | ||
| const initialState = createInitialTagsState([], defaultTags); | ||
| expect(initialState).toMatchInlineSnapshot(` | ||
| Array [ | ||
| Object { | ||
| "checked": undefined, | ||
| "label": "test 1", | ||
| }, | ||
| Object { | ||
| "checked": undefined, | ||
| "label": "test 2", | ||
| }, | ||
| Object { | ||
| "checked": undefined, | ||
| "label": "test 3", | ||
| }, | ||
| ] | ||
| `); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /* | ||
| * 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 { EuiSelectableOption } from '@elastic/eui'; | ||
| import { intersection, union } from 'lodash'; | ||
|
|
||
| export const createInitialTagsState = (existingTags: string[][], defaultTags: string[]) => { | ||
| const existingTagsIntersection = intersection(...existingTags); | ||
| const existingTagsUnion = union(...existingTags); | ||
| const allTagsUnion = union(existingTagsUnion, defaultTags); | ||
| return allTagsUnion | ||
| .map((tag): EuiSelectableOption => { | ||
| return { | ||
| label: tag, | ||
| checked: existingTagsIntersection.includes(tag) | ||
| ? 'on' | ||
| : existingTagsUnion.includes(tag) | ||
| ? 'off' | ||
| : undefined, | ||
| }; | ||
| }) | ||
| .sort((a, b) => (a.checked ? a.checked < b.checked : true)); | ||
|
dplumlee marked this conversation as resolved.
Outdated
|
||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.