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 1 commit
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;
}
8 changes: 8 additions & 0 deletions x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,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 +536,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 = [
'False positive',
'Duplicate',
'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;
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 = [{ id: 0, items: 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,10 +96,7 @@ export const StatusPopoverButton = React.memo<StatusPopoverButtonProps>(
data-test-subj="alertStatus"
>
<EuiPopoverTitle paddingSize="m">{CHANGE_ALERT_STATUS}</EuiPopoverTitle>
<EuiContextMenuPanel
data-test-subj="event-details-alertStatusPopover"
items={actionItems}
/>
<EuiContextMenu panels={panels} data-test-subj="event-details-alertStatusPopover" />
</EuiPopover>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* 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'; // TODO: maybe not the correct import place as this will be deleted?
import React, { useCallback, useMemo, useState } from 'react';
import { TAGS } from '@kbn/rule-data-utils';
import { intersection, union } from 'lodash';
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';

interface BulkAlertTagsPanelComponentProps {
alertIds: TimelineItem[];
refetchQuery: () => void;
setIsLoading: (isLoading: boolean) => void;
refresh?: () => void;
clearSelection?: () => void;
closePopoverMenu: () => void;
}
export const BulkAlertTagsPanelComponent: React.FC<BulkAlertTagsPanelComponentProps> = ({
alertIds,
refresh,
refetchQuery,
setIsLoading,
clearSelection,
closePopoverMenu,
}) => {
const [alertTagOptions] = useUiSetting$<string[]>(DEFAULT_ALERT_TAGS_KEY);
const { addSuccess, addError, addWarning } = useAppToasts();

const { setAlertTags } = useSetAlertTags();
const initalTagsState = useMemo(() => {
const existingTags = alertIds.map(
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: can we extract this into a helper or util we can unit test?

Also, would using a set here simplify the number of iterations through the tags?

(item) => item.data.find((data) => data.field === TAGS)?.value ?? []
);
const existingTagsIntersection = intersection(...existingTags);
const existingTagsUnion = union(...existingTags);
const allTagsUnion = union(existingTagsUnion, alertTagOptions);
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));
}, [alertIds, alertTagOptions]);
const tagsToAdd: Record<string, boolean> = useMemo(() => ({}), []);
const tagsToRemove: Record<string, boolean> = useMemo(() => ({}), []);

const onUpdateSuccess = useCallback(
(updated: number, conflicts: number) => {
if (conflicts > 0) {
addWarning({
title: 'Warning',
text: `${updated} alerts updated successfully, but ${conflicts} didn't due to version conflicts`,
});
} else {
addSuccess(`${updated} alerts successfully updated`);
}
},
[addSuccess, addWarning]
);

const onUpdateFailure = useCallback(
(error: Error) => {
addError(error.message, { title: 'Tags failed to update' });
},
[addError]
);

const [selectableAlertTags, setSelectableAlertTags] =
useState<EuiSelectableOption[]>(initalTagsState);

const onTagsUpdate = useCallback(async () => {
closePopoverMenu();
const ids = alertIds.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);
refetchQuery();
if (refresh) refresh();
if (clearSelection) clearSelection();

if (response.version_conflicts && ids.length === 1) {
throw new Error('Updated failed due to version conflicts');
}

onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0);
} catch (err) {
onUpdateFailure(err);
}
}, [
closePopoverMenu,
alertIds,
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: 'Search tags',
}}
aria-label={'search them tags'}
options={selectableAlertTags}
onChange={handleTagsOnChange}
emptyMessage={'im empty fill me up'}
noMatchesMessage={'no matches here you absolute fool'}
>
{(list, search) => (
<div>
<EuiPopoverTitle>{search}</EuiPopoverTitle>
{list}
</div>
)}
</EuiSelectable>
<EuiButton fullWidth size="s" onClick={onTagsUpdate}>
{'Update tags'}
</EuiButton>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
* 2.0.
*/

import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel } from '@elastic/eui';
import { EuiPopover, EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import type { AlertTableContextMenuItem } from '../../../../detections/components/alerts_table/types';

interface OwnProps {
selectText: string;
selectClearAllText: string;
showClearSelection: boolean;
onSelectAll: () => void;
onClearSelection: () => void;
bulkActionItems?: JSX.Element[];
bulkActionItems: AlertTableContextMenuItem[];
}

const BulkActionsContainer = styled.div`
Expand Down Expand Up @@ -60,6 +61,13 @@ const BulkActionsComponent: React.FC<OwnProps> = ({
}
}, [onClearSelection, onSelectAll, showClearSelection]);

const panels = [
{
id: 0,
items: [...bulkActionItems],
},
];

return (
<BulkActionsContainer
onClick={closeIfPopoverIsOpen}
Expand All @@ -84,7 +92,7 @@ const BulkActionsComponent: React.FC<OwnProps> = ({
}
closePopover={closeActionPopover}
>
<EuiContextMenuPanel size="s" items={bulkActionItems} />
<EuiContextMenu size="s" panels={panels} initialPanelId={0} />
</EuiPopover>

<EuiButtonEmpty
Expand Down
Loading