Skip to content
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

[Security Solution][Detection Alerts] Alert tagging #157786

Merged
merged 36 commits into from
Jun 21, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3ad0adf
working poc-types still broken
dplumlee May 15, 2023
233586d
adds translations
dplumlee May 22, 2023
c5029b6
updates some tests
dplumlee May 25, 2023
96658d8
fixes some type errors
dplumlee May 25, 2023
c77aa15
fixes more tests
dplumlee May 25, 2023
d391a76
fixes some more types
dplumlee May 25, 2023
a06d3b5
fixes tests
dplumlee May 29, 2023
1a5c13c
fixes tests 2
dplumlee May 29, 2023
bbd7a89
Merge remote-tracking branch 'upstream/main' into security-alert-tags
dplumlee May 29, 2023
938e805
fix telemetry check
dplumlee May 29, 2023
3bcd05a
uiSettings fix
dplumlee May 30, 2023
3b9291b
fix merge conflicts
dplumlee Jun 11, 2023
d3fda33
switches to mixed state selectable
dplumlee Jun 12, 2023
488293a
fixes sort method
dplumlee Jun 13, 2023
aa26c0a
Merge remote-tracking branch 'upstream/main' into security-alert-tags
dplumlee Jun 13, 2023
09cae98
addresses remaining comments
dplumlee Jun 14, 2023
49c39b8
adds tests
dplumlee Jun 15, 2023
f9f91f9
Merge remote-tracking branch 'upstream/main' into security-alert-tags
dplumlee Jun 15, 2023
b24c6d0
creates new workflow_tags field to populate
dplumlee Jun 15, 2023
10eaf14
Merge remote-tracking branch 'upstream/main' into security-alert-tags
dplumlee Jun 15, 2023
7d3d600
fixes tests
dplumlee Jun 15, 2023
a9cc608
adds functional tests and 890 types
dplumlee Jun 15, 2023
8698128
fixes tests
dplumlee Jun 15, 2023
5f291df
fixes cypress tests
dplumlee Jun 16, 2023
f877402
[CI] Auto-commit changed files from 'node scripts/precommit_hook.js -…
kibanamachine Jun 16, 2023
2df309a
fix type issue
dplumlee Jun 16, 2023
982b81f
updates tests
dplumlee Jun 16, 2023
a352269
[CI] Auto-commit changed files from 'node scripts/precommit_hook.js -…
kibanamachine Jun 16, 2023
8339d9f
fixes some tests
dplumlee Jun 16, 2023
64b5077
updates cypress tests
dplumlee Jun 16, 2023
69cbf20
addresses comments
dplumlee Jun 16, 2023
4a6a735
addresses comments
dplumlee Jun 20, 2023
db9bc72
updates helper util
dplumlee Jun 20, 2023
0119e69
Merge remote-tracking branch 'upstream/main' into security-alert-tags
dplumlee Jun 20, 2023
9d8ae0c
addresses comments and updates tests
dplumlee Jun 20, 2023
baf4fde
skips tests due to refresh change
dplumlee Jun 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kbn-securitysolution-ecs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export interface EcsSecurityExtension {
suricata?: SuricataEcs;
system?: SystemEcs;
timestamp?: string;
tags?: string[];
winlog?: WinlogEcs;
zeek?: ZeekEcs;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },
},
'securitySolution:alertTags': {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },
},
'securitySolution:newsFeedUrl': {
type: 'keyword',
_meta: { description: 'Default value of the setting was changed.' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface UsageStats {
*/
'securitySolution:defaultIndex': string;
'securitySolution:defaultThreatIndex': string;
'securitySolution:alertTags': string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should this be string[]?

Copy link
Contributor Author

@dplumlee dplumlee May 31, 2023

Choose a reason for hiding this comment

The 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 string[] types in that file. This is consistent with the other string arrays in the file, but if that type works better, I can revert to that other commit

'securitySolution:newsFeedUrl': string;
'xpackReporting:customPdfLogo': string;
'notifications:banner': string;
Expand Down
6 changes: 6 additions & 0 deletions src/plugins/telemetry/schema/oss_plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -8683,6 +8683,12 @@
"description": "Non-default value of setting."
}
},
"securitySolution:alertTags": {
"type": "keyword",
"_meta": {
"description": "Default value of the setting was changed."
}
},
"search:includeFrozen": {
"type": "boolean",
"_meta": {
Expand Down
9 changes: 9 additions & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import * as i18n from './translations';

/**
* as const
Expand Down Expand Up @@ -359,6 +360,7 @@ export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL =
`${DETECTION_ENGINE_SIGNALS_URL}/migration_status` as const;
export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL =
`${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration` as const;
export const DETECTION_ENGINE_ALERT_TAGS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/tags` as const;

export const ALERTS_AS_DATA_URL = '/internal/rac/alerts' as const;
export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const;
Expand Down Expand Up @@ -535,3 +537,10 @@ export const ALERTS_TABLE_REGISTRY_CONFIG_IDS = {
RULE_DETAILS: `${APP_ID}-rule-details`,
CASE: `${APP_ID}-case`,
} as const;

export const DEFAULT_ALERT_TAGS_KEY = 'securitySolution:alertTags' as const;
export const DEFAULT_ALERT_TAGS_VALUE = [
i18n.DUPLICATE,
i18n.FALSE_POSITIVE,
i18n.FURTHER_INVESTIGATION_REQUIRED,
] as const;
Original file line number Diff line number Diff line change
Expand Up @@ -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

export const fields = t.array(t.string);
export type Fields = t.TypeOf<typeof fields>;
export const fieldsOrUndefined = t.union([fields, t.undefined]);
Expand Down Expand Up @@ -125,3 +127,10 @@ export const privilege = t.type({
});

export type Privilege = t.TypeOf<typeof privilege>;

export const alert_tags = t.type({
Copy link
Contributor

Choose a reason for hiding this comment

The 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:

const BulkActionEditPayloadTags = t.type({
  type: t.union([
    t.literal(BulkActionEditType.add_tags),
    t.literal(BulkActionEditType.delete_tags),
    t.literal(BulkActionEditType.set_tags),
  ]),
  value: RuleTagArray,
});

That way if we decide theres a new action type we want to introduce for tags, the schema updates are minimal.

Copy link
Contributor Author

@dplumlee dplumlee May 22, 2023

Choose a reason for hiding this comment

The 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;
26 changes: 26 additions & 0 deletions x-pack/plugins/security_solution/common/translations.ts
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
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import { EuiContextMenuPanel, EuiPopover, EuiPopoverTitle } from '@elastic/eui';
import { EuiContextMenu, EuiPopover, EuiPopoverTitle } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';

import { useAlertsActions } from '../../../../detections/components/alerts_table/timeline_actions/use_alerts_actions';
Expand Down Expand Up @@ -56,6 +56,8 @@ export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
refetch: refetchGlobalQuery,
});

const panels = useMemo(() => [{ id: 0, items: actionItems }], [actionItems]);

// statusPopoverVisible includes the logic for the visibility of the popover in
// case actionItems is an empty array ( ex, when user has read access ).
const statusPopoverVisible = useMemo(() => actionItems.length > 0, [actionItems]);
Expand Down Expand Up @@ -94,9 +96,10 @@ export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
data-test-subj="alertStatus"
>
<EuiPopoverTitle paddingSize="m">{CHANGE_ALERT_STATUS}</EuiPopoverTitle>
<EuiContextMenuPanel
<EuiContextMenu
panels={panels}
initialPanelId={0}
data-test-subj="event-details-alertStatusPopover"
items={actionItems}
/>
</EuiPopover>
);
Expand Down
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(
() => createInitialTagsState(existingTags, defaultAlertTagOptions),
[existingTags, defaultAlertTagOptions]
);

const tagsToAdd: Record<string, boolean> = useMemo(() => ({}), []);
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;
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we group all these props under a single onUpdateSuccess prop? And let the caller do whatever it needs when the update succeeded, outside this component.


if (response.version_conflicts && ids.length === 1) {
throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT);
}

onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0);
} 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') {
// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are you checking if the checked property exists or if it's null or undefined?

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));
};
Loading