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

This commit is contained in:
Davis Plumlee 2023-06-20 22:04:52 -04:00 committed by GitHub
parent bed4609afe
commit 0f572605a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 2073 additions and 374 deletions

View file

@ -33,6 +33,7 @@ import {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
TIMESTAMP,
VERSION,
@ -173,6 +174,11 @@ export const alertFieldMap = {
array: false,
required: false,
},
[ALERT_WORKFLOW_TAGS]: {
type: 'keyword',
array: true,
required: false,
},
[SPACE_IDS]: {
type: 'keyword',
array: true,

View file

@ -113,6 +113,7 @@ const AlertOptional = rt.partial({
time_range: schemaDateRange,
url: schemaString,
workflow_status: schemaString,
workflow_tags: schemaStringArray,
}),
version: schemaString,
}),

View file

@ -236,6 +236,7 @@ const SecurityAlertOptional = rt.partial({
url: schemaString,
workflow_reason: schemaString,
workflow_status: schemaString,
workflow_tags: schemaStringArray,
workflow_user: schemaString,
}),
version: schemaString,

View file

@ -67,6 +67,9 @@ const ALERT_UUID = `${ALERT_NAMESPACE}.uuid` as const;
// kibana.alert.workflow_status - open/closed status of alert
const ALERT_WORKFLOW_STATUS = `${ALERT_NAMESPACE}.workflow_status` as const;
// kibana.alert.workflow_tags - user workflow alert tags
const ALERT_WORKFLOW_TAGS = `${ALERT_NAMESPACE}.workflow_tags` as const;
// kibana.alert.rule.category - rule type name for rule that generated this alert
const ALERT_RULE_CATEGORY = `${ALERT_RULE_NAMESPACE}.category` as const;
@ -133,6 +136,7 @@ const fields = {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
TIMESTAMP,
VERSION,
@ -171,6 +175,7 @@ export {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
TIMESTAMP,
VERSION,

View file

@ -33,6 +33,7 @@ import {
ALERT_TIME_RANGE,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
TIMESTAMP,
VERSION,
@ -169,6 +170,7 @@ const fields = {
ALERT_UUID,
ALERT_WORKFLOW_REASON,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
ALERT_WORKFLOW_USER,
ALERT_RULE_UUID,
ALERT_RULE_CATEGORY,

View file

@ -23,6 +23,7 @@ export type SignalEcsAAD = Exclude<SignalEcs, 'rule' | 'status'> & {
severity?: string[];
building_block_type?: string[];
workflow_status?: string[];
workflow_tags?: string[];
suppression?: {
docs_count: string[];
};

View file

@ -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.' },

View file

@ -12,6 +12,7 @@ export interface UsageStats {
*/
'securitySolution:defaultIndex': string;
'securitySolution:defaultThreatIndex': string;
'securitySolution:alertTags': string;
'securitySolution:newsFeedUrl': string;
'xpackReporting:customPdfLogo': string;
'notifications:banner': string;

View file

@ -8945,6 +8945,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": {

View file

@ -292,6 +292,9 @@ describe('mappingFromFieldMap', () => {
workflow_status: {
type: 'keyword',
},
workflow_tags: {
type: 'keyword',
},
},
},
space_ids: {

View file

@ -296,6 +296,11 @@ it('matches snapshot', () => {
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_tags": Object {
"array": true,
"required": false,
"type": "keyword",
},
"kibana.alert.workflow_user": Object {
"array": false,
"required": false,

View file

@ -6,6 +6,7 @@
*/
import { RuleNotifyWhen } from '@kbn/alerting-plugin/common';
import * as i18n from './translations';
/**
* as const
@ -361,6 +362,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;
@ -537,3 +539,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;

View file

@ -0,0 +1,56 @@
/*
* 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 { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0';
import type {
Ancestor880,
BaseFields880,
EqlBuildingBlockFields880,
EqlShellFields880,
NewTermsFields880,
} from '../8.8.0';
/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.9.0.
Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.9.0.
If you are adding new fields for a new release of Kibana, create a new sibling folder to this one
for the version to be released and add the field(s) to the schema in that folder.
Then, update `../index.ts` to import from the new folder that has the latest schemas, add the
new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas.
*/
export type { Ancestor880 as Ancestor890 };
export interface BaseFields890 extends BaseFields880 {
[ALERT_WORKFLOW_TAGS]: string[];
}
export interface WrappedFields890<T extends BaseFields890> {
_id: string;
_index: string;
_source: T;
}
export type GenericAlert890 = AlertWithCommonFields800<BaseFields890>;
export type EqlShellFields890 = EqlShellFields880 & BaseFields890;
export type EqlBuildingBlockFields890 = EqlBuildingBlockFields880 & BaseFields890;
export type NewTermsFields890 = NewTermsFields880 & BaseFields890;
export type NewTermsAlert890 = NewTermsFields880 & BaseFields890;
export type EqlBuildingBlockAlert890 = AlertWithCommonFields800<EqlBuildingBlockFields880>;
export type EqlShellAlert890 = AlertWithCommonFields800<EqlShellFields890>;
export type DetectionAlert890 =
| GenericAlert890
| EqlShellAlert890
| EqlBuildingBlockAlert890
| NewTermsAlert890;

View file

@ -10,15 +10,16 @@ import type { DetectionAlert800 } from './8.0.0';
import type { DetectionAlert840 } from './8.4.0';
import type { DetectionAlert860 } from './8.6.0';
import type { DetectionAlert870 } from './8.7.0';
import type { DetectionAlert880 } from './8.8.0';
import type {
Ancestor880,
BaseFields880,
DetectionAlert880,
EqlBuildingBlockFields880,
EqlShellFields880,
NewTermsFields880,
WrappedFields880,
} from './8.8.0';
Ancestor890,
BaseFields890,
DetectionAlert890,
EqlBuildingBlockFields890,
EqlShellFields890,
NewTermsFields890,
WrappedFields890,
} from './8.9.0';
// When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version
// here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0
@ -27,14 +28,15 @@ export type DetectionAlert =
| DetectionAlert840
| DetectionAlert860
| DetectionAlert870
| DetectionAlert880;
| DetectionAlert880
| DetectionAlert890;
export type {
Ancestor880 as AncestorLatest,
BaseFields880 as BaseFieldsLatest,
DetectionAlert880 as DetectionAlertLatest,
WrappedFields880 as WrappedFieldsLatest,
EqlBuildingBlockFields880 as EqlBuildingBlockFieldsLatest,
EqlShellFields880 as EqlShellFieldsLatest,
NewTermsFields880 as NewTermsFieldsLatest,
Ancestor890 as AncestorLatest,
BaseFields890 as BaseFieldsLatest,
DetectionAlert890 as DetectionAlertLatest,
WrappedFields890 as WrappedFieldsLatest,
EqlBuildingBlockFields890 as EqlBuildingBlockFieldsLatest,
EqlShellFields890 as EqlShellFieldsLatest,
NewTermsFields890 as NewTermsFieldsLatest,
};

View file

@ -45,6 +45,9 @@ 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.record(t.string, t.unknown);
export type AlertTagQuery = t.TypeOf<typeof alert_tag_query>;
export const fields = t.array(t.string);
export type Fields = t.TypeOf<typeof fields>;
export const fieldsOrUndefined = t.union([fields, t.undefined]);
@ -125,3 +128,10 @@ export const privilege = t.type({
});
export type Privilege = t.TypeOf<typeof privilege>;
export const alert_tags = t.type({
tags_to_add: t.array(t.string),
tags_to_remove: t.array(t.string),
});
export type AlertTags = t.TypeOf<typeof alert_tags>;

View file

@ -0,0 +1,13 @@
/*
* 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 { SetAlertTagsSchema } from './set_alert_tags_schema';
export const getSetAlertTagsRequestMock = (
tagsToAdd: string[] = [],
tagsToRemove: string[] = []
): SetAlertTagsSchema => ({ tags: { tags_to_add: tagsToAdd, tags_to_remove: tagsToRemove } });

View file

@ -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;

View file

@ -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',
}
);

View file

@ -0,0 +1,110 @@
/*
* 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 {
clickAlertTag,
openAlertTaggingBulkActionMenu,
selectNumberOfAlerts,
updateAlertTags,
} from '../../tasks/alerts';
import { createRule } from '../../tasks/api_calls/rules';
import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common';
import { login, visit } from '../../tasks/login';
import { ALERTS_URL } from '../../urls/navigation';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import {
ALERTS_TABLE_ROW_LOADER,
MIXED_ALERT_TAG,
SELECTED_ALERT_TAG,
UNSELECTED_ALERT_TAG,
} from '../../screens/alerts';
import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver';
describe.skip('Alert tagging', () => {
before(() => {
cleanKibana();
esArchiverResetKibana();
});
beforeEach(() => {
login();
deleteAlertsAndRules();
esArchiverLoad('endpoint');
createRule(getNewRule({ rule_id: 'new custom rule' }));
visit(ALERTS_URL);
waitForAlertsToPopulate();
});
afterEach(() => {
esArchiverUnload('endpoint');
});
it('Add and remove a tag using the alert bulk action menu', () => {
// Add a tag to one alert
selectNumberOfAlerts(1);
openAlertTaggingBulkActionMenu();
clickAlertTag('Duplicate');
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
selectNumberOfAlerts(1);
openAlertTaggingBulkActionMenu();
cy.get(SELECTED_ALERT_TAG).contains('Duplicate');
// Remove tag from that alert
clickAlertTag('Duplicate');
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
selectNumberOfAlerts(1);
openAlertTaggingBulkActionMenu();
cy.get(UNSELECTED_ALERT_TAG).first().contains('Duplicate');
});
it('Add a tag using the alert bulk action menu with mixed state', () => {
// Add tag to one alert first
selectNumberOfAlerts(1);
openAlertTaggingBulkActionMenu();
clickAlertTag('Duplicate');
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
// Then add tags to both alerts
selectNumberOfAlerts(2);
openAlertTaggingBulkActionMenu();
cy.get(MIXED_ALERT_TAG).contains('Duplicate');
clickAlertTag('Duplicate');
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
selectNumberOfAlerts(2);
openAlertTaggingBulkActionMenu();
cy.get(SELECTED_ALERT_TAG).contains('Duplicate');
});
it('Remove a tag using the alert bulk action menu with mixed state', () => {
// Add tag to one alert first
selectNumberOfAlerts(1);
openAlertTaggingBulkActionMenu();
clickAlertTag('Duplicate');
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
// Then remove tags from both alerts
selectNumberOfAlerts(2);
openAlertTaggingBulkActionMenu();
cy.get(MIXED_ALERT_TAG).contains('Duplicate');
clickAlertTag('Duplicate');
clickAlertTag('Duplicate'); // Clicking twice will return to unselected state
updateAlertTags();
cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist');
waitForAlertsToPopulate();
selectNumberOfAlerts(2);
openAlertTaggingBulkActionMenu();
cy.get(UNSELECTED_ALERT_TAG).first().contains('Duplicate');
});
});

View file

@ -187,3 +187,17 @@ export const CLOSE_OVERLAY = '[data-test-subj="close-overlay"]';
export const ALERT_SUMMARY_SEVERITY_DONUT_CHART =
getDataTestSubjectSelector('severity-level-donut');
export const ALERT_TAGGING_CONTEXT_MENU_ITEM = '[data-test-subj="alert-tags-context-menu-item"]';
export const ALERT_TAGGING_CONTEXT_MENU = '[data-test-subj="alert-tags-selectable-menu"]';
export const ALERT_TAGGING_UPDATE_BUTTON = '[data-test-subj="alert-tags-update-button"]';
export const SELECTED_ALERT_TAG = '[data-test-subj="selected-alert-tag"]';
export const MIXED_ALERT_TAG = '[data-test-subj="mixed-alert-tag"]';
export const UNSELECTED_ALERT_TAG = '[data-test-subj="unselected-alert-tag"]';
export const ALERTS_TABLE_ROW_LOADER = '[data-test-subj="row-loader"]';

View file

@ -45,6 +45,9 @@ import {
ALERTS_HISTOGRAM_LEGEND,
LEGEND_ACTIONS,
SESSION_VIEWER_BUTTON,
ALERT_TAGGING_CONTEXT_MENU_ITEM,
ALERT_TAGGING_CONTEXT_MENU,
ALERT_TAGGING_UPDATE_BUTTON,
} from '../screens/alerts';
import { LOADING_INDICATOR, REFRESH_BUTTON } from '../screens/security_header';
import { TIMELINE_COLUMN_SPINNER } from '../screens/timeline';
@ -447,3 +450,21 @@ export const visitAlertsPageWithCustomFilters = (pageFilters: FilterItemObj[]) =
export const openSessionViewerFromAlertTable = (rowIndex: number = 0) => {
cy.get(SESSION_VIEWER_BUTTON).eq(rowIndex).click();
};
export const openAlertTaggingContextMenu = () => {
cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click();
cy.get(ALERT_TAGGING_CONTEXT_MENU_ITEM).click();
};
export const openAlertTaggingBulkActionMenu = () => {
cy.get(TAKE_ACTION_POPOVER_BTN).click();
cy.get(ALERT_TAGGING_CONTEXT_MENU_ITEM).click();
};
export const clickAlertTag = (tag: string) => {
cy.get(ALERT_TAGGING_CONTEXT_MENU).contains(tag).click();
};
export const updateAlertTags = () => {
cy.get(ALERT_TAGGING_UPDATE_BUTTON).click();
};

View file

@ -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';
@ -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]);
@ -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>
);

View file

@ -0,0 +1,55 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../mock';
import { useUiSetting$ } from '../../../lib/kibana';
import { BulkAlertTagsPanel } from './alert_bulk_tags';
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useSetAlertTags } from './use_set_alert_tags';
import { getUpdateAlertsQuery } from '../../../../detections/components/alerts_table/actions';
jest.mock('../../../lib/kibana');
jest.mock('../../../hooks/use_app_toasts');
jest.mock('./use_set_alert_tags');
jest.mock('../../../../detections/components/alerts_table/actions');
const mockTagItems = [
{
_id: 'test-id',
data: [{ field: ALERT_WORKFLOW_TAGS, value: ['tag-1', 'tag-2'] }],
ecs: { _id: 'test-id' },
},
];
(useUiSetting$ as jest.Mock).mockReturnValue(['default-test-tag']);
(useAppToasts as jest.Mock).mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
});
(useSetAlertTags as jest.Mock).mockReturnValue([false, jest.fn()]);
(getUpdateAlertsQuery as jest.Mock).mockReturnValue({ query: {} });
describe('BulkAlertTagsPanel', () => {
test('it renders', () => {
const wrapper = render(
<TestProviders>
<BulkAlertTagsPanel
alertItems={mockTagItems}
setIsLoading={() => {}}
closePopoverMenu={() => {}}
/>
</TestProviders>
);
expect(wrapper.getByTestId('alert-tags-selectable-menu')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,134 @@
/*
* 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 { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
import type { EuiSelectableOnChangeEvent } from '@elastic/eui/src/components/selectable/selectable';
import { DEFAULT_ALERT_TAGS_KEY } from '../../../../../common/constants';
import { useUiSetting$ } from '../../../lib/kibana';
import { useSetAlertTags } from './use_set_alert_tags';
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 [, setAlertTags] = useSetAlertTags();
const existingTags = useMemo(
() =>
alertItems.map(
(item) => item.data.find((data) => data.field === ALERT_WORKFLOW_TAGS)?.value ?? []
),
[alertItems]
);
const initialTagsState = useMemo(
() => createInitialTagsState(existingTags, defaultAlertTagOptions),
[existingTags, defaultAlertTagOptions]
);
const tagsToAdd: Set<string> = useMemo(() => new Set(), []);
const tagsToRemove: Set<string> = useMemo(() => new Set(), []);
const [selectableAlertTags, setSelectableAlertTags] =
useState<EuiSelectableOption[]>(initialTagsState);
const onTagsUpdate = useCallback(() => {
closePopoverMenu();
if (tagsToAdd.size === 0 && tagsToRemove.size === 0) {
return;
}
const tagsToAddArray = Array.from(tagsToAdd);
const tagsToRemoveArray = Array.from(tagsToRemove);
const ids = alertItems.map((item) => item._id);
const tags = { tags_to_add: tagsToAddArray, tags_to_remove: tagsToRemoveArray };
const onSuccess = () => {
if (refetchQuery) refetchQuery();
if (refresh) refresh();
if (clearSelection) clearSelection();
};
if (setAlertTags != null) {
setAlertTags(tags, ids, onSuccess, setIsLoading);
}
}, [
closePopoverMenu,
tagsToAdd,
tagsToRemove,
alertItems,
setAlertTags,
refetchQuery,
refresh,
clearSelection,
setIsLoading,
]);
const handleTagsOnChange = (
newOptions: EuiSelectableOption[],
event: EuiSelectableOnChangeEvent,
changedOption: EuiSelectableOption
) => {
if (changedOption.checked === 'on') {
tagsToAdd.add(changedOption.label);
tagsToRemove.delete(changedOption.label);
} else if (!changedOption.checked) {
tagsToRemove.add(changedOption.label);
tagsToAdd.delete(changedOption.label);
}
setSelectableAlertTags(newOptions);
};
return (
<>
<EuiSelectable
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}
data-test-subj="alert-tags-selectable-menu"
>
{(list, search) => (
<div>
<EuiPopoverTitle>{search}</EuiPopoverTitle>
{list}
</div>
)}
</EuiSelectable>
<EuiButton
data-test-subj="alert-tags-update-button"
fullWidth
size="s"
onClick={onTagsUpdate}
>
{i18n.ALERT_TAGS_UPDATE_BUTTON_MESSAGE}
</EuiButton>
</>
);
};
export const BulkAlertTagsPanel = memo(BulkAlertTagsPanelComponent);

View file

@ -0,0 +1,113 @@
/*
* 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,
"data-test-subj": "unselected-alert-tag",
"label": "test 1",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 2",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 3",
},
]
`);
});
it('should return the correctly sorted and merged state if tags from a singular alert are provided', () => {
const mockAlertTags = ['test 1'];
const initialState = createInitialTagsState([mockAlertTags], defaultTags);
expect(initialState).toMatchInlineSnapshot(`
Array [
Object {
"checked": "on",
"data-test-subj": "selected-alert-tag",
"label": "test 1",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 2",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 3",
},
]
`);
});
it('should return the correctly sorted and merged state if tags from multiple alerts', () => {
const mockAlertTags1 = ['test 1'];
const mockAlertTags2 = ['test 1', 'test 2'];
const initialState = createInitialTagsState([mockAlertTags1, mockAlertTags2], defaultTags);
expect(initialState).toMatchInlineSnapshot(`
Array [
Object {
"checked": "on",
"data-test-subj": "selected-alert-tag",
"label": "test 1",
},
Object {
"checked": "mixed",
"data-test-subj": "mixed-alert-tag",
"label": "test 2",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 3",
},
]
`);
});
it('should return the correctly sorted and merged state if a tag not in the default tag options is provided', () => {
const mockAlertTags = ['test 1', 'test 4'];
const initialState = createInitialTagsState([mockAlertTags], defaultTags);
expect(initialState).toMatchInlineSnapshot(`
Array [
Object {
"checked": "on",
"data-test-subj": "selected-alert-tag",
"label": "test 1",
},
Object {
"checked": "on",
"data-test-subj": "selected-alert-tag",
"label": "test 4",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 2",
},
Object {
"checked": undefined,
"data-test-subj": "unselected-alert-tag",
"label": "test 3",
},
]
`);
});
});

View file

@ -0,0 +1,47 @@
/*
* 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';
// Sorts in order of `on` -> `mixed` -> `undefined`
const checkedSortCallback = (a: EuiSelectableOption, b: EuiSelectableOption) => {
if (a.checked) {
if (b.checked) {
return a.checked <= b.checked ? 1 : -1;
}
return -1;
}
if (b.checked) {
return 1;
}
return 0;
};
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 => {
let checkedStatus: { checked: EuiSelectableOption['checked']; 'data-test-subj': string } = {
checked: undefined,
'data-test-subj': 'unselected-alert-tag',
};
if (existingTagsIntersection.includes(tag)) {
checkedStatus = { checked: 'on', 'data-test-subj': 'selected-alert-tag' };
} else if (existingTagsUnion.includes(tag)) {
checkedStatus = { checked: 'mixed', 'data-test-subj': 'mixed-alert-tag' };
}
return {
label: tag,
...checkedStatus,
};
})
.sort(checkedSortCallback);
};

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel } from '@elastic/eui';
import React, { useState, useCallback } from 'react';
import { EuiPopover, EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import type { AlertTableContextMenuItem } from '../../../../detections/components/alerts_table/types';
interface OwnProps {
selectText: string;
@ -15,7 +16,7 @@ interface OwnProps {
showClearSelection: boolean;
onSelectAll: () => void;
onClearSelection: () => void;
bulkActionItems?: JSX.Element[];
bulkActionItems: AlertTableContextMenuItem[];
}
const BulkActionsContainer = styled.div`
@ -60,6 +61,16 @@ const BulkActionsComponent: React.FC<OwnProps> = ({
}
}, [onClearSelection, onSelectAll, showClearSelection]);
const panels = useMemo(
() => [
{
id: 0,
items: bulkActionItems,
},
],
[bulkActionItems]
);
return (
<BulkActionsContainer
onClick={closeIfPopoverIsOpen}
@ -84,7 +95,7 @@ const BulkActionsComponent: React.FC<OwnProps> = ({
}
closePopover={closeActionPopover}
>
<EuiContextMenuPanel size="s" items={bulkActionItems} />
<EuiContextMenu size="s" panels={panels} initialPanelId={0} />
</EuiPopover>
<EuiButtonEmpty

View file

@ -141,3 +141,66 @@ export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
defaultMessage: 'Mark as closed',
}
);
export const UPDATE_ALERT_TAGS_FAILED = (conflicts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.updateAlertTagsFailed', {
values: { conflicts },
defaultMessage:
'Failed to update tags for { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_TAGS_FAILED_DETAILED = (updated: number, conflicts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.updateAlertTagsFailedDetailed', {
values: { updated, conflicts },
defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update
because { conflicts, plural, =1 {it was} other {they were}} already being modified.`,
});
export const UPDATE_ALERT_TAGS_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.updateAlertTagsSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully updated tags for {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_TAGS_FAILURE = i18n.translate(
'xpack.securitySolution.bulkActions.updateAlertTagsFailedToastMessage',
{
defaultMessage: 'Failed to update alert tags.',
}
);
export const ALERT_TAGS_MENU_SEARCH_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.bulkActions.alertTagsMenuSearchPlaceholderMessage',
{
defaultMessage: 'Search tags',
}
);
export const ALERT_TAGS_MENU_SEARCH_NO_TAGS_FOUND = i18n.translate(
'xpack.securitySolution.bulkActions.alertTagsMenuSearchNoTagsFoundMessage',
{
defaultMessage: 'No tags match current search',
}
);
export const ALERT_TAGS_MENU_EMPTY = i18n.translate(
'xpack.securitySolution.bulkActions.alertTagsMenuEmptyMessage',
{
defaultMessage: 'No tag options exist, add tag options in Advanced Settings.',
}
);
export const ALERT_TAGS_UPDATE_BUTTON_MESSAGE = i18n.translate(
'xpack.securitySolution.bulkActions.alertTagsUpdateButtonMessage',
{
defaultMessage: 'Update tags',
}
);
export const ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE = i18n.translate(
'xpack.securitySolution.bulkActions.alertTagsContextMenuItemTitle',
{
defaultMessage: 'Manage alert tags',
}
);

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useMemo, useCallback } from 'react';
import type { AlertTableContextMenuItem } from '../../../../detections/components/alerts_table/types';
import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../common/types';
import type {
CustomBulkActionProp,
@ -151,57 +151,45 @@ export const useBulkActionItems = ({
);
const items = useMemo(() => {
const actionItems: JSX.Element[] = [];
const actionItems: AlertTableContextMenuItem[] = [];
if (showAlertStatusActions) {
if (currentStatus !== FILTER_OPEN) {
actionItems.push(
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() => onClickUpdate(FILTER_OPEN as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
);
actionItems.push({
key: 'open',
'data-test-subj': 'open-alert-status',
onClick: () => onClickUpdate(FILTER_OPEN as AlertWorkflowStatus),
name: i18n.BULK_ACTION_OPEN_SELECTED,
});
}
if (currentStatus !== FILTER_ACKNOWLEDGED) {
actionItems.push(
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() => onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
);
actionItems.push({
key: 'acknowledge',
'data-test-subj': 'acknowledged-alert-status',
onClick: () => onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus),
name: i18n.BULK_ACTION_ACKNOWLEDGED_SELECTED,
});
}
if (currentStatus !== FILTER_CLOSED) {
actionItems.push(
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() => onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>
);
actionItems.push({
key: 'close',
'data-test-subj': 'close-alert-status',
onClick: () => onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus),
name: i18n.BULK_ACTION_CLOSE_SELECTED,
});
}
}
const additionalItems = customBulkActions
? customBulkActions.reduce<JSX.Element[]>((acc, action) => {
? customBulkActions.reduce<AlertTableContextMenuItem[]>((acc, action) => {
const isDisabled = !!(query && action.disableOnQuery);
acc.push(
<EuiContextMenuItem
key={action.key}
disabled={isDisabled}
data-test-subj={action['data-test-subj']}
toolTipContent={isDisabled ? action.disabledLabel : null}
onClick={() => action.onClick(eventIds)}
>
{action.label}
</EuiContextMenuItem>
);
acc.push({
key: action.key,
disabled: isDisabled,
'data-test-subj': action['data-test-subj'],
toolTipContent: isDisabled ? action.disabledLabel : null,
onClick: () => action.onClick(eventIds),
name: action.label,
});
return acc;
}, [])
: [];

View file

@ -0,0 +1,56 @@
/*
* 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 { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types';
import React from 'react';
import { BulkAlertTagsPanel } from './alert_bulk_tags';
import * as i18n from './translations';
interface UseBulkAlertTagsItemsProps {
refetch?: () => void;
}
export const useBulkAlertTagsItems = ({ refetch }: UseBulkAlertTagsItemsProps) => {
const alertTagsItems = [
{
key: 'manage-alert-tags',
'data-test-subj': 'alert-tags-context-menu-item',
name: i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
panel: 1,
label: i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
disableOnQuery: true,
},
];
const alertTagsPanels = [
{
id: 1,
title: i18n.ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
renderContent: ({
alertItems,
refresh,
setIsBulkActionsLoading,
clearSelection,
closePopoverMenu,
}: RenderContentPanelProps) => (
<BulkAlertTagsPanel
alertItems={alertItems}
refresh={refresh}
refetchQuery={refetch}
setIsLoading={setIsBulkActionsLoading}
clearSelection={clearSelection}
closePopoverMenu={closePopoverMenu}
/>
),
},
];
return {
alertTagsItems,
alertTagsPanels,
};
};

View file

@ -0,0 +1,108 @@
/*
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getUpdateAlertsQuery } from '../../../../detections/components/alerts_table/actions';
import type { AlertTags } from '../../../../../common/detection_engine/schemas/common';
import { DETECTION_ENGINE_ALERT_TAGS_URL } from '../../../../../common/constants';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import * as i18n from './translations';
export type SetAlertTagsFunc = (
tags: AlertTags,
ids: string[],
onSuccess: () => void,
setTableLoading: (param: boolean) => void
) => Promise<void>;
export type ReturnSetAlertTags = [boolean, SetAlertTagsFunc | null];
/**
* Update alert tags by query
*
* @param tags to add and/or remove from a batch of alerts
* @param ids alert ids that will be used to create the update query.
* @param onSuccess a callback function that will be called on successful api response
* @param setTableLoading a function that sets the alert table in a loading state for bulk actions
*
* @throws An error if response is not OK
*/
export const useSetAlertTags = (): ReturnSetAlertTags => {
const { http } = useKibana<CoreStart>().services;
const { addSuccess, addError, addWarning } = useAppToasts();
const setAlertTagsRef = useRef<SetAlertTagsFunc | null>(null);
const [isLoading, setIsLoading] = useState(false);
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]
);
useEffect(() => {
let ignore = false;
const abortCtrl = new AbortController();
const onSetAlertTags: SetAlertTagsFunc = async (tags, ids, onSuccess, setTableLoading) => {
const query: Record<string, unknown> = getUpdateAlertsQuery(ids).query;
try {
setIsLoading(true);
setTableLoading(true);
const response = await http.fetch<estypes.UpdateByQueryResponse>(
DETECTION_ENGINE_ALERT_TAGS_URL,
{
method: 'POST',
body: JSON.stringify({ tags, query }),
signal: abortCtrl.signal,
}
);
if (!ignore) {
setTableLoading(false);
onSuccess();
if (response.version_conflicts && ids.length === 1) {
throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT);
}
setIsLoading(false);
onUpdateSuccess(response.updated ?? 0, response.version_conflicts ?? 0);
}
} catch (error) {
if (!ignore) {
setIsLoading(false);
setTableLoading(false);
onUpdateFailure(error);
}
}
};
setAlertTagsRef.current = onSetAlertTags;
return (): void => {
ignore = true;
abortCtrl.abort();
};
}, [http, onUpdateFailure, onUpdateSuccess]);
return [isLoading, setAlertTagsRef.current];
};

View file

@ -15,8 +15,6 @@ import type { AlertWorkflowStatus } from '../../../types';
/**
* Update alert status by query
*
* @param useDetectionEngine logic flag for using the regular Detection Engine URL or the RAC URL
*
* @param status to update to('open' / 'closed' / 'acknowledged')
* @param index index to be updated
* @param query optional query object to update alerts by query.

View file

@ -176,6 +176,7 @@ export const getAlertsPreviewDefaultModel = (license?: LicenseService): SubsetDa
export const requiredFieldsForActions = [
'@timestamp',
'kibana.alert.workflow_status',
'kibana.alert.workflow_tags',
'kibana.alert.group.id',
'kibana.alert.original_time',
'kibana.alert.building_block_type',

View file

@ -7,7 +7,7 @@
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@elastic/eui';
import { EuiButtonIcon, EuiPopover, EuiToolTip, EuiContextMenu } from '@elastic/eui';
import { indexOf } from 'lodash';
import type { ConnectedProps } from 'react-redux';
import { connect } from 'react-redux';
@ -44,6 +44,8 @@ import { useAddToCaseActions } from './use_add_to_case_actions';
import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_alert_check';
import type { Rule } from '../../../../detection_engine/rule_management/logic/types';
import { useOpenAlertDetailsAction } from './use_open_alert_details';
import type { AlertTableContextMenuItem } from '../types';
import { useAlertTagsActions } from './use_alert_tags_actions';
interface AlertContextMenuProps {
ariaLabel?: string;
@ -220,12 +222,20 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
ruleId,
});
const items: React.ReactElement[] = useMemo(
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
closePopover,
ecsRowData,
scopeId,
refetch: refetchAll,
});
const items: AlertTableContextMenuItem[] = useMemo(
() =>
!isEvent && ruleId
? [
...addToCaseActionItems,
...statusActionItems,
...alertTagsItems,
...exceptionActionItems,
...(agentId ? osqueryActionItems : []),
...alertDetailsActionItems,
@ -246,9 +256,21 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
alertDetailsActionItems,
eventFilterActionItems,
canCreateEndpointEventFilters,
alertTagsItems,
]
);
const panels = useMemo(
() => [
{
id: 0,
items,
},
...alertTagsPanels,
],
[alertTagsPanels, items]
);
const osqueryFlyout = useMemo(() => {
return (
<OsqueryFlyout
@ -274,7 +296,12 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel size="s" items={items} data-test-subj="actions-context-menu" />
<EuiContextMenu
size="s"
initialPanelId={0}
panels={panels}
data-test-subj="actions-context-menu"
/>
</EuiPopover>
</EventsTdContent>
</div>

View file

@ -5,12 +5,12 @@
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useCallback, useMemo } from 'react';
import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { useUserData } from '../../user_info';
import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations';
import type { AlertTableContextMenuItem } from '../types';
interface UseExceptionActionProps {
isEndpointAlert: boolean;
@ -34,28 +34,25 @@ export const useExceptionActions = ({
const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert;
const disabledAddException = !canUserCRUD || !hasIndexWrite;
const exceptionActionItems = useMemo(
const exceptionActionItems: AlertTableContextMenuItem[] = useMemo(
() =>
disabledAddException
? []
: [
<EuiContextMenuItem
key="add-endpoint-exception-menu-item"
data-test-subj="add-endpoint-exception-menu-item"
disabled={disabledAddEndpointException}
onClick={handleEndpointExceptionModal}
>
{ACTION_ADD_ENDPOINT_EXCEPTION}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="add-exception-menu-item"
data-test-subj="add-exception-menu-item"
disabled={disabledAddException}
onClick={handleDetectionExceptionModal}
>
{ACTION_ADD_EXCEPTION}
</EuiContextMenuItem>,
{
key: 'add-endpoint-exception-menu-item',
'data-test-subj': 'add-endpoint-exception-menu-item',
disabled: disabledAddEndpointException,
onClick: handleEndpointExceptionModal,
name: ACTION_ADD_ENDPOINT_EXCEPTION,
},
{
key: 'add-exception-menu-item',
'data-test-subj': 'add-exception-menu-item',
disabled: disabledAddException,
onClick: handleDetectionExceptionModal,
name: ACTION_ADD_EXCEPTION,
},
],
[
disabledAddEndpointException,

View file

@ -6,8 +6,7 @@
*/
import React from 'react';
import type { EuiContextMenuPanelProps } from '@elastic/eui';
import { EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { EuiContextMenu, EuiPopover } from '@elastic/eui';
import { act, renderHook } from '@testing-library/react-hooks';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@ -20,6 +19,7 @@ import {
sampleCase,
} from '../../../../common/components/guided_onboarding_tour/tour_config';
import { CasesTourSteps } from '../../../../common/components/guided_onboarding_tour/cases_tour_steps';
import type { AlertTableContextMenuItem } from '../types';
jest.mock('../../../../common/components/guided_onboarding_tour');
jest.mock('../../../../common/lib/kibana');
@ -53,7 +53,8 @@ const addToNewCase = jest.fn().mockReturnValue(caseHooksReturnedValue);
const addToExistingCase = jest.fn().mockReturnValue(caseHooksReturnedValue);
const useKibanaMock = useKibana as jest.Mock;
const renderContextMenu = (items: EuiContextMenuPanelProps['items']) => {
const renderContextMenu = (items: AlertTableContextMenuItem[]) => {
const panels = [{ id: 0, items }];
render(
<EuiPopover
isOpen={true}
@ -62,7 +63,7 @@ const renderContextMenu = (items: EuiContextMenuPanelProps['items']) => {
closePopover={() => {}}
button={<></>}
>
<EuiContextMenuPanel size="s" items={items} />
<EuiContextMenu size="s" initialPanelId={0} panels={panels} />
</EuiPopover>
);
};
@ -105,10 +106,10 @@ describe('useAddToCaseActions', () => {
wrapper: TestProviders,
});
expect(result.current.addToCaseActionItems.length).toEqual(2);
expect(result.current.addToCaseActionItems[0].props['data-test-subj']).toEqual(
expect(result.current.addToCaseActionItems[0]['data-test-subj']).toEqual(
'add-to-existing-case-action'
);
expect(result.current.addToCaseActionItems[1].props['data-test-subj']).toEqual(
expect(result.current.addToCaseActionItems[1]['data-test-subj']).toEqual(
'add-to-new-case-action'
);
});

View file

@ -6,7 +6,6 @@
*/
import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { CommentType } from '@kbn/cases-plugin/common';
import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
@ -20,6 +19,7 @@ import { useTourContext } from '../../../../common/components/guided_onboarding_
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
import type { TimelineNonEcsData } from '../../../../../common/search_strategy';
import { ADD_TO_EXISTING_CASE, ADD_TO_NEW_CASE } from '../translations';
import type { AlertTableContextMenuItem } from '../types';
export interface UseAddToCaseActions {
onMenuItemClick: () => void;
@ -130,7 +130,7 @@ export const useAddToCaseActions = ({
selectCaseModal.open({ getAttachments: () => caseAttachments });
}, [caseAttachments, onMenuItemClick, selectCaseModal]);
const addToCaseActionItems = useMemo(() => {
const addToCaseActionItems: AlertTableContextMenuItem[] = useMemo(() => {
if (
(isActiveTimelines || isInDetections) &&
userCasesPermissions.create &&
@ -139,25 +139,23 @@ export const useAddToCaseActions = ({
) {
return [
// add to existing case menu item
<EuiContextMenuItem
aria-label={ariaLabel}
data-test-subj="add-to-existing-case-action"
key="add-to-existing-case-action"
onClick={handleAddToExistingCaseClick}
size="s"
>
{ADD_TO_EXISTING_CASE}
</EuiContextMenuItem>,
{
'aria-label': ariaLabel,
'data-test-subj': 'add-to-existing-case-action',
key: 'add-to-existing-case-action',
onClick: handleAddToExistingCaseClick,
size: 's',
name: ADD_TO_EXISTING_CASE,
},
// add to new case menu item
<EuiContextMenuItem
aria-label={ariaLabel}
data-test-subj="add-to-new-case-action"
key="add-to-new-case-action"
onClick={handleAddToNewCaseClick}
size="s"
>
{ADD_TO_NEW_CASE}
</EuiContextMenuItem>,
{
'aria-label': ariaLabel,
'data-test-subj': 'add-to-new-case-action',
key: 'add-to-new-case-action',
onClick: handleAddToNewCaseClick,
size: 's',
name: ADD_TO_NEW_CASE,
},
];
}
return [];

View file

@ -0,0 +1,73 @@
/*
* 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 { EuiContextMenuPanelDescriptor } from '@elastic/eui';
import { useMemo } from 'react';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { ALERT_WORKFLOW_TAGS } from '@kbn/rule-data-utils';
import { useBulkAlertTagsItems } from '../../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items';
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
import type { AlertTableContextMenuItem } from '../types';
interface Props {
closePopover: () => void;
ecsRowData: Ecs;
scopeId: string;
refetch?: () => void;
}
export const useAlertTagsActions = ({ closePopover, ecsRowData, scopeId, refetch }: Props) => {
const { hasIndexWrite } = useAlertsPrivileges();
const alertId = ecsRowData._id;
const alertTagData = useMemo(() => {
return [
{
_id: alertId,
_index: ecsRowData._index ?? '',
data: [
{ field: ALERT_WORKFLOW_TAGS, value: ecsRowData?.kibana?.alert.workflow_tags ?? [] },
],
ecs: {
_id: alertId,
_index: ecsRowData._index ?? '',
},
},
];
}, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_tags]);
const { alertTagsItems, alertTagsPanels } = useBulkAlertTagsItems({ refetch });
const itemsToReturn: AlertTableContextMenuItem[] = useMemo(
() =>
alertTagsItems.map((item) => ({
name: item.name,
panel: item.panel,
'data-test-subj': item['data-test-subj'],
key: item.key,
})),
[alertTagsItems]
);
const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo(
() =>
alertTagsPanels.map((panel) => {
const content = panel.renderContent({
closePopoverMenu: closePopover,
setIsBulkActionsLoading: () => {},
alertItems: alertTagData,
});
return { title: panel.title, content, id: panel.id };
}),
[alertTagData, alertTagsPanels, closePopover]
);
return {
alertTagsItems: hasIndexWrite ? itemsToReturn : [],
alertTagsPanels: panelsToReturn,
};
};

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useMemo } from 'react';
import { ACTION_ADD_EVENT_FILTER } from '../translations';
import type { AlertTableContextMenuItem } from '../types';
export const useEventFilterAction = ({
onAddEventFilterClick,
@ -19,16 +19,15 @@ export const useEventFilterAction = ({
tooltipMessage?: string;
}) => {
const eventFilterActionItems = useMemo(
() => [
<EuiContextMenuItem
key="add-event-filter-menu-item"
data-test-subj="add-event-filter-menu-item"
onClick={onAddEventFilterClick}
disabled={disabled}
toolTipContent={tooltipMessage}
>
{ACTION_ADD_EVENT_FILTER}
</EuiContextMenuItem>,
(): AlertTableContextMenuItem[] => [
{
key: 'add-event-filter-menu-item',
'data-test-subj': 'add-event-filter-menu-item',
onClick: onAddEventFilterClick,
disabled,
toolTipContent: tooltipMessage,
name: ACTION_ADD_EVENT_FILTER,
},
],
[onAddEventFilterClick, disabled, tooltipMessage]
);

View file

@ -13,6 +13,9 @@ import { useInvestigateInTimeline } from './use_investigate_in_timeline';
import * as actions from '../actions';
import { coreMock } from '@kbn/core/public/mocks';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import type { AlertTableContextMenuItem } from '../types';
import React from 'react';
import { EuiPopover, EuiContextMenu } from '@elastic/eui';
const ecsRowData: Ecs = {
_id: '1',
@ -54,6 +57,21 @@ const props = {
onInvestigateInTimelineAlertClick: () => {},
};
const renderContextMenu = (items: AlertTableContextMenuItem[]) => {
const panels = [{ id: 0, items }];
return render(
<EuiPopover
isOpen={true}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => {}}
button={<></>}
>
<EuiContextMenu size="s" initialPanelId={0} panels={panels} />
</EuiPopover>
);
};
describe('use investigate in timeline hook', () => {
afterEach(() => {
jest.clearAllMocks();
@ -71,8 +89,8 @@ describe('use investigate in timeline hook', () => {
const { result } = renderHook(() => useInvestigateInTimeline(props), {
wrapper: TestProviders,
});
const component = result.current.investigateInTimelineActionItems[0];
const { getByTestId } = render(component);
const actionItem = result.current.investigateInTimelineActionItems[0];
const { getByTestId } = renderContextMenu([actionItem]);
expect(mockSendAlertToTimeline).toHaveBeenCalledTimes(0);
act(() => {
fireEvent.click(getByTestId('investigate-in-timeline-action-item'));

View file

@ -4,11 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { EuiContextMenuItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALERT_RULE_EXCEPTIONS_LIST, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils';
import type { ExceptionListId } from '@kbn/securitysolution-io-ts-list-types';
@ -179,14 +177,13 @@ export const useInvestigateInTimeline = ({
const investigateInTimelineActionItems = useMemo(
() => [
<EuiContextMenuItem
key="investigate-in-timeline-action-item"
data-test-subj="investigate-in-timeline-action-item"
disabled={ecsRowData == null}
onClick={investigateInTimelineAlertClick}
>
{ACTION_INVESTIGATE_IN_TIMELINE}
</EuiContextMenuItem>,
{
key: 'investigate-in-timeline-action-item',
'data-test-subj': 'investigate-in-timeline-action-item',
disabled: ecsRowData == null,
onClick: investigateInTimelineAlertClick,
name: ACTION_INVESTIGATE_IN_TIMELINE,
},
],
[ecsRowData, investigateInTimelineAlertClick]
);

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useGetSecuritySolutionLinkProps } from '../../../../common/components/links';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { getAlertDetailsUrl } from '../../../../common/components/link_to';
import { SecurityPageName } from '../../../../../common/constants';
import type { AlertTableContextMenuItem } from '../types';
interface Props {
ruleId?: string;
@ -28,7 +27,7 @@ export const ACTION_OPEN_ALERT_DETAILS_PAGE = i18n.translate(
export const useOpenAlertDetailsAction = ({ ruleId, closePopover, alertId }: Props) => {
const isAlertDetailsPageEnabled = useIsExperimentalFeatureEnabled('alertDetailsPageEnabled');
const alertDetailsActionItems = [];
const alertDetailsActionItems: AlertTableContextMenuItem[] = [];
const { onClick } = useGetSecuritySolutionLinkProps()({
deepLinkId: SecurityPageName.alerts,
path: alertId ? getAlertDetailsUrl(alertId) : '',
@ -36,15 +35,12 @@ export const useOpenAlertDetailsAction = ({ ruleId, closePopover, alertId }: Pro
// We check ruleId to confirm this is an alert, as this page does not support events as of 8.6
if (ruleId && alertId && isAlertDetailsPageEnabled) {
alertDetailsActionItems.push(
<EuiContextMenuItem
key="open-alert-details-item"
data-test-subj="open-alert-details-page-menu-item"
onClick={onClick}
>
{ACTION_OPEN_ALERT_DETAILS_PAGE}
</EuiContextMenuItem>
);
alertDetailsActionItems.push({
key: 'open-alert-details-item',
'data-test-subj': 'open-alert-details-page-menu-item',
onClick,
name: ACTION_OPEN_ALERT_DETAILS_PAGE,
});
}
return {

View file

@ -8,6 +8,7 @@
import type { ISearchStart } from '@kbn/data-plugin/public';
import type { Filter } from '@kbn/es-query';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu';
import type { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import type { Note } from '../../../../common/types/timeline/note/api';
import type { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
@ -78,3 +79,5 @@ export interface ThresholdAggregationData {
thresholdTo: string;
dataProviders: DataProvider[];
}
export type AlertTableContextMenuItem = EuiContextMenuPanelItemDescriptorEntry;

View file

@ -1,40 +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 { EuiContextMenuItem } from '@elastic/eui';
import React, { memo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
type ResponderContextMenuItemProps,
useResponderActionData,
} from './use_responder_action_data';
export const ResponderContextMenuItem = memo<ResponderContextMenuItemProps>(
({ endpointId, onClick }) => {
const { handleResponseActionsClick, isDisabled, tooltip } = useResponderActionData({
endpointId,
onClick,
});
return (
<EuiContextMenuItem
key="endpointResponseActions-action-item"
data-test-subj="endpointResponseActions-action-item"
disabled={isDisabled}
toolTipContent={tooltip}
size="s"
onClick={handleResponseActionsClick}
>
<FormattedMessage
id="xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.buttonLabel"
defaultMessage="Respond"
/>
</EuiContextMenuItem>
);
}
);
ResponderContextMenuItem.displayName = 'ResponderContextMenuItem';

View file

@ -7,18 +7,20 @@
import React, { useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { FormattedMessage } from '@kbn/i18n-react';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import {
isAlertFromEndpointEvent,
isTimelineEventItemAnAlert,
} from '../../../common/utils/endpoint_alert_check';
import { ResponderContextMenuItem } from './responder_context_menu_item';
import { getFieldValue } from '../host_isolation/helpers';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useResponderActionData } from './use_responder_action_data';
export const useResponderActionItem = (
eventDetailsData: TimelineEventsDetailsItem[] | null,
onClick: () => void
): JSX.Element[] => {
): AlertTableContextMenuItem[] => {
const { loading: isAuthzLoading, canAccessResponseConsole } =
useUserPrivileges().endpointPrivileges;
@ -35,19 +37,38 @@ export const useResponderActionItem = (
[eventDetailsData]
);
const { handleResponseActionsClick, isDisabled, tooltip } = useResponderActionData({
endpointId: isEndpointAlert ? endpointId : '',
onClick,
});
return useMemo(() => {
const actions: JSX.Element[] = [];
const actions: AlertTableContextMenuItem[] = [];
if (!isAuthzLoading && canAccessResponseConsole && isAlert) {
actions.push(
<ResponderContextMenuItem
key="endpointResponseActions-action-item"
endpointId={isEndpointAlert ? endpointId : ''}
onClick={onClick}
/>
);
actions.push({
key: 'endpointResponseActions-action-item',
'data-test-subj': 'endpointResponseActions-action-item',
disabled: isDisabled,
toolTipContent: tooltip,
size: 's',
onClick: handleResponseActionsClick,
name: (
<FormattedMessage
id="xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.buttonLabel"
defaultMessage="Respond"
/>
),
});
}
return actions;
}, [canAccessResponseConsole, endpointId, isAlert, isAuthzLoading, isEndpointAlert, onClick]);
}, [
canAccessResponseConsole,
handleResponseActionsClick,
isAlert,
isAuthzLoading,
isDisabled,
tooltip,
]);
};

View file

@ -4,8 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { useCallback, useMemo } from 'react';
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils';
import { HostStatus } from '../../../../common/endpoint/types';
@ -14,6 +13,7 @@ import { useHostIsolationStatus } from '../../containers/detection_engine/alerts
import { ISOLATE_HOST, UNISOLATE_HOST } from './translations';
import { getFieldValue } from './helpers';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
interface UseHostIsolationActionProps {
closePopover: () => void;
@ -79,7 +79,7 @@ export const useHostIsolationAction = ({
const isolateHostTitle = isolationStatus === false ? ISOLATE_HOST : UNISOLATE_HOST;
const hostIsolationAction = useMemo(
const hostIsolationAction: AlertTableContextMenuItem[] = useMemo(
() =>
isIsolationAllowed &&
isEndpointAlert &&
@ -87,14 +87,13 @@ export const useHostIsolationAction = ({
isHostIsolationPanelOpen === false &&
loadingHostIsolationStatus === false
? [
<EuiContextMenuItem
key="isolate-host-action-item"
data-test-subj="isolate-host-action-item"
disabled={agentStatus === HostStatus.UNENROLLED}
onClick={isolateHostHandler}
>
{isolateHostTitle}
</EuiContextMenuItem>,
{
key: 'isolate-host-action-item',
'data-test-subj': 'isolate-host-action-item',
disabled: agentStatus === HostStatus.UNENROLLED,
onClick: isolateHostHandler,
name: isolateHostTitle,
},
]
: [],
[

View file

@ -5,21 +5,17 @@
* 2.0.
*/
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { ACTION_OSQUERY } from './translations';
interface IProps {
handleClick: () => void;
}
export const OsqueryActionItem = ({ handleClick }: IProps) => (
<EuiContextMenuItem
key="osquery-action-item"
data-test-subj="osquery-action-item"
onClick={handleClick}
size={'s'}
>
{ACTION_OSQUERY}
</EuiContextMenuItem>
);
export const getOsqueryActionItem = ({ handleClick }: IProps): AlertTableContextMenuItem => ({
key: 'osquery-action-item',
'data-test-subj': 'osquery-action-item',
onClick: handleClick,
size: 's',
name: ACTION_OSQUERY,
});

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import { OsqueryActionItem } from './osquery_action_item';
import { useMemo } from 'react';
import { getOsqueryActionItem } from './osquery_action_item';
import { useKibana } from '../../../common/lib/kibana';
interface IProps {
@ -14,10 +14,7 @@ interface IProps {
}
export const useOsqueryContextActionItem = ({ handleClick }: IProps) => {
const osqueryActionItem = useMemo(
() => <OsqueryActionItem handleClick={handleClick} />,
[handleClick]
);
const osqueryActionItem = useMemo(() => getOsqueryActionItem({ handleClick }), [handleClick]);
const permissions = useKibana().services.application.capabilities.osquery;
return {

View file

@ -6,7 +6,7 @@
*/
import React, { useCallback, useMemo, useState } from 'react';
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui';
import type { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { TableId } from '@kbn/securitysolution-data-table';
@ -32,7 +32,9 @@ import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_exper
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { useAddToCaseActions } from '../alerts_table/timeline_actions/use_add_to_case_actions';
import { useKibana } from '../../../common/lib/kibana';
import { OsqueryActionItem } from '../osquery/osquery_action_item';
import { getOsqueryActionItem } from '../osquery/osquery_action_item';
import type { AlertTableContextMenuItem } from '../alerts_table/types';
import { useAlertTagsActions } from '../alerts_table/timeline_actions/use_alert_tags_actions';
interface ActionsData {
alertStatus: Status;
@ -183,6 +185,13 @@ export const TakeActionDropdown = React.memo(
scopeId,
});
const { alertTagsItems, alertTagsPanels } = useAlertTagsActions({
closePopover: closePopoverHandler,
ecsRowData: ecsData ?? { _id: actionsData.eventId },
scopeId,
refetch,
});
const { investigateInTimelineActionItems } = useInvestigateInTimeline({
ecsRowData: ecsData,
onInvestigateInTimelineAlertClick: closePopoverHandler,
@ -199,7 +208,7 @@ export const TakeActionDropdown = React.memo(
const osqueryActionItem = useMemo(
() =>
OsqueryActionItem({
getOsqueryActionItem({
handleClick: handleOnOsqueryClick,
}),
[handleOnOsqueryClick]
@ -208,7 +217,7 @@ export const TakeActionDropdown = React.memo(
const alertsActionItems = useMemo(
() =>
!isEvent && actionsData.ruleId
? [...statusActionItems, ...exceptionActionItems]
? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems]
: isEndpointEvent && canCreateEndpointEventFilters
? eventFilterActionItems
: [],
@ -220,6 +229,7 @@ export const TakeActionDropdown = React.memo(
statusActionItems,
isEvent,
actionsData.ruleId,
alertTagsItems,
]
);
@ -237,7 +247,7 @@ export const TakeActionDropdown = React.memo(
refetch,
});
const items: React.ReactElement[] = useMemo(
const items: AlertTableContextMenuItem[] = useMemo(
() => [
...(tGridEnabled ? addToCaseActionItems : []),
...alertsActionItems,
@ -258,6 +268,14 @@ export const TakeActionDropdown = React.memo(
]
);
const panels = [
{
id: 0,
items,
},
...alertTagsPanels,
];
const takeActionButton = useMemo(
() => (
<GuidedOnboardingTourStep
@ -290,7 +308,12 @@ export const TakeActionDropdown = React.memo(
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel data-test-subj="takeActionPanelMenu" size="s" items={items} />
<EuiContextMenu
size="s"
initialPanelId={0}
panels={panels}
data-test-subj="takeActionPanelMenu"
/>
</EuiPopover>
) : null;
}

View file

@ -12,6 +12,7 @@ import { isEqual } from 'lodash';
import type { Filter } from '@kbn/es-query';
import { useCallback } from 'react';
import type { TableId } from '@kbn/securitysolution-data-table';
import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items';
import type { inputsModel, State } from '../../../common/store';
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
import { inputsSelectors } from '../../../common/store';
@ -88,5 +89,11 @@ export const getBulkActionHook =
refetch: refetchGlobalQuery,
});
return [...alertActions, timelineAction];
const { alertTagsItems, alertTagsPanels } = useBulkAlertTagsItems({
refetch: refetchGlobalQuery,
});
const items = [...alertActions, timelineAction, ...alertTagsItems];
return [{ id: 0, items }, ...alertTagsPanels];
};

View file

@ -0,0 +1,18 @@
/*
* 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 { AlertTags } from '../../../../../common/detection_engine/schemas/common';
import * as i18n from './translations';
export const validateAlertTagsArrays = (tags: AlertTags) => {
const { tags_to_add: tagsToAdd, tags_to_remove: tagsToRemove } = tags;
const duplicates = tagsToAdd.filter((tag) => tagsToRemove.includes(tag));
if (duplicates.length) {
return [i18n.ALERT_TAGS_VALIDATION_ERROR(JSON.stringify(duplicates))];
}
return [];
};

View file

@ -0,0 +1,90 @@
/*
* 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 { getSetAlertTagsRequestMock } from '../../../../../common/detection_engine/schemas/request/set_alert_tags_schema.mock';
import { DETECTION_ENGINE_ALERT_TAGS_URL } from '../../../../../common/constants';
import { requestContextMock, serverMock, requestMock } from '../__mocks__';
import { getSuccessfulSignalUpdateResponse } from '../__mocks__/request_responses';
import { setAlertTagsRoute } from './set_alert_tags_route';
describe('setAlertTagsRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let request: ReturnType<typeof requestMock.create>;
let { context } = requestContextMock.createTools();
beforeEach(() => {
server = serverMock.create();
({ context } = requestContextMock.createTools());
setAlertTagsRoute(server.router);
});
describe('happy path', () => {
test('returns 200 when adding/removing empty arrays of tags', async () => {
request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_ALERT_TAGS_URL,
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2']),
});
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse(
getSuccessfulSignalUpdateResponse()
);
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.status).toEqual(200);
});
});
describe('validation', () => {
test('returns 400 if duplicate tags are in both the add and remove arrays', async () => {
request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_ALERT_TAGS_URL,
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-1']),
});
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse(
getSuccessfulSignalUpdateResponse()
);
const response = await server.inject(request, requestContextMock.convertContext(context));
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue(
new Error('Test error')
);
expect(response.body).toEqual({
message: [
`Duplicate tags [\"tag-1\"] were found in the tags_to_add and tags_to_remove parameters.`,
],
status_code: 400,
});
});
});
describe('500s', () => {
test('returns 500 if ', async () => {
request = requestMock.create({
method: 'post',
path: DETECTION_ENGINE_ALERT_TAGS_URL,
body: getSetAlertTagsRequestMock(['tag-1'], ['tag-2']),
});
context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue(
new Error('Test error')
);
const response = await server.inject(request, requestContextMock.convertContext(context));
expect(response.body).toEqual({
message: 'Test error',
status_code: 500,
});
});
});
});

View file

@ -0,0 +1,102 @@
/*
* 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 { transformError } from '@kbn/securitysolution-es-utils';
import { uniq } from 'lodash/fp';
import type { SetAlertTagsSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/set_alert_tags_schema';
import { setAlertTagsSchema } from '../../../../../common/detection_engine/schemas/request/set_alert_tags_schema';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import {
DEFAULT_ALERTS_INDEX,
DETECTION_ENGINE_ALERT_TAGS_URL,
} from '../../../../../common/constants';
import { buildSiemResponse } from '../utils';
import { buildRouteValidation } from '../../../../utils/build_validation/route_validation';
import { validateAlertTagsArrays } from './helpers';
export const setAlertTagsRoute = (router: SecuritySolutionPluginRouter) => {
router.post(
{
path: DETECTION_ENGINE_ALERT_TAGS_URL,
validate: {
body: buildRouteValidation<typeof setAlertTagsSchema, SetAlertTagsSchemaDecoded>(
setAlertTagsSchema
),
},
options: {
tags: ['access:securitySolution'],
},
},
async (context, request, response) => {
const { tags, query } = request.body;
const core = await context.core;
const securitySolution = await context.securitySolution;
const esClient = core.elasticsearch.client.asCurrentUser;
const siemClient = securitySolution?.getAppClient();
const siemResponse = buildSiemResponse(response);
const validationErrors = validateAlertTagsArrays(tags);
const spaceId = securitySolution?.getSpaceId() ?? 'default';
if (validationErrors.length) {
return siemResponse.error({ statusCode: 400, body: validationErrors });
}
if (!siemClient) {
return siemResponse.error({ statusCode: 404 });
}
let queryObject;
if (query) {
queryObject = {
bool: {
filter: query,
},
};
}
const tagsToAdd = uniq(tags.tags_to_add);
const tagsToRemove = uniq(tags.tags_to_remove);
try {
const body = await esClient.updateByQuery({
index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`,
refresh: false,
body: {
script: {
params: { tagsToAdd, tagsToRemove },
source: `List newTagsArray = [];
if (ctx._source["kibana.alert.workflow_tags"] != null) {
for (tag in ctx._source["kibana.alert.workflow_tags"]) {
if (!params.tagsToRemove.contains(tag)) {
newTagsArray.add(tag);
}
}
for (tag in params.tagsToAdd) {
if (!newTagsArray.contains(tag)) {
newTagsArray.add(tag)
}
}
ctx._source["kibana.alert.workflow_tags"] = newTagsArray;
} else {
ctx._source["kibana.alert.workflow_tags"] = params.tagsToAdd;
}
`,
lang: 'painless',
},
query: queryObject,
},
ignore_unavailable: true,
});
return response.ok({ body });
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -0,0 +1,15 @@
/*
* 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 ALERT_TAGS_VALIDATION_ERROR = (duplicates: string) =>
i18n.translate('xpack.securitySolution.api.alertTags.validationError', {
values: { duplicates },
defaultMessage:
'Duplicate tags { duplicates } were found in the tags_to_add and tags_to_remove parameters.',
});

View file

@ -53,6 +53,7 @@ import {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
EVENT_KIND,
SPACE_IDS,
TIMESTAMP,
@ -320,6 +321,7 @@ export const sampleAlertDocAADNoSortId = (
],
},
[ALERT_URL]: 'http://example.com/docID',
[ALERT_WORKFLOW_TAGS]: [],
},
fields: {
someKey: ['someValue'],

View file

@ -20,6 +20,7 @@ import {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
EVENT_ACTION,
EVENT_KIND,
EVENT_MODULE,
@ -230,6 +231,7 @@ describe('buildAlert', () => {
[ALERT_DEPTH]: 1,
[ALERT_URL]: expectedAlertUrl,
[ALERT_UUID]: alertUuid,
[ALERT_WORKFLOW_TAGS]: [],
};
expect(alert).toEqual(expected);
});
@ -421,6 +423,7 @@ describe('buildAlert', () => {
[ALERT_DEPTH]: 1,
[ALERT_URL]: expectedAlertUrl,
[ALERT_UUID]: alertUuid,
[ALERT_WORKFLOW_TAGS]: [],
};
expect(alert).toEqual(expected);
});

View file

@ -37,6 +37,7 @@ import {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
EVENT_KIND,
SPACE_IDS,
TIMESTAMP,
@ -246,6 +247,7 @@ export const buildAlert = (
[ALERT_RULE_VERSION]: params.version,
[ALERT_URL]: alertUrl,
[ALERT_UUID]: alertUuid,
[ALERT_WORKFLOW_TAGS]: [],
...flattenWithPrefix(ALERT_RULE_META, params.meta),
// These fields don't exist in the mappings, but leaving here for now to limit changes to the alert building logic
'kibana.alert.rule.risk_score': params.riskScore,

View file

@ -41,6 +41,7 @@ import {
ALERT_URL,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
EVENT_KIND,
SPACE_IDS,
TIMESTAMP,
@ -94,6 +95,7 @@ export const createAlert = (
[ALERT_ORIGINAL_TIME]: undefined,
[ALERT_STATUS]: ALERT_STATUS_ACTIVE,
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_WORKFLOW_TAGS]: [],
[ALERT_DEPTH]: 1,
[ALERT_REASON]: 'reasonable reason',
[ALERT_SEVERITY]: 'high',

View file

@ -73,6 +73,7 @@ import {
import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_routes';
import { registerDashboardsRoutes } from '../lib/dashboards/routes';
import { registerTagsRoutes } from '../lib/tags/routes';
import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route';
import { riskScorePreviewRoute } from '../lib/risk_engine/routes';
export const initRoutes = (
@ -135,6 +136,7 @@ export const initRoutes = (
// POST /api/detection_engine/signals/status
// Example usage can be found in security_solution/server/lib/detection_engine/scripts/signals
setSignalsStatusRoute(router, logger, security, telemetrySender);
setAlertTagsRoute(router);
querySignalsRoute(router, ruleDataClient);
getSignalsMigrationStatusRoute(router);
createSignalsMigrationRoute(router, security);

View file

@ -34,6 +34,8 @@ import {
SHOW_RELATED_INTEGRATIONS_SETTING,
EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING,
EXTENDED_RULE_EXECUTION_LOGGING_MIN_LEVEL_SETTING,
DEFAULT_ALERT_TAGS_KEY,
DEFAULT_ALERT_TAGS_VALUE,
} from '../common/constants';
import type { ExperimentalFeatures } from '../common/experimental_features';
import { LogLevelSetting } from '../common/detection_engine/rule_monitoring';
@ -249,6 +251,20 @@ export const initUiSettings = (
requiresPageReload: true,
schema: schema.boolean(),
},
[DEFAULT_ALERT_TAGS_KEY]: {
name: i18n.translate('xpack.securitySolution.uiSettings.defaultAlertTagsLabel', {
defaultMessage: 'Alert tagging options',
}),
sensitive: true,
value: DEFAULT_ALERT_TAGS_VALUE,
description: i18n.translate('xpack.securitySolution.uiSettings.defaultAlertTagsDescription', {
defaultMessage:
'<p>List of tag options for use with alerts generated by Security Solution rules.</p>',
}),
category: [APP_ID],
requiresPageReload: true,
schema: schema.arrayOf(schema.string()),
},
...(experimentalFeatures.extendedRuleExecutionLoggingEnabled
? {
[EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING]: {

View file

@ -10,6 +10,7 @@ import {
ALERT_RISK_SCORE,
ALERT_SEVERITY,
ALERT_RULE_PARAMETERS,
ALERT_WORKFLOW_TAGS,
} from '@kbn/rule-data-utils';
import { ENRICHMENT_DESTINATION_PATH } from '../../../../../common/constants';
@ -51,6 +52,7 @@ export const TIMELINE_EVENTS_FIELDS = [
'@timestamp',
'kibana.alert.ancestors.index',
'kibana.alert.workflow_status',
ALERT_WORKFLOW_TAGS,
'kibana.alert.group.id',
'kibana.alert.original_time',
'kibana.alert.reason',

View file

@ -268,11 +268,16 @@ describe('AlertsTable', () => {
}),
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: () => {},
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: () => {},
},
],
},
],
useFieldBrowserOptions: () => {

View file

@ -182,43 +182,51 @@ describe('AlertsTable.BulkActions', () => {
alertsTableConfiguration: {
...alertsTableConfiguration,
useBulkActions: () =>
[
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: () => {},
},
{
label: 'Fake Bulk Action with clear selection',
key: 'fakeBulkActionClear',
'data-test-subj': 'fake-bulk-action-clear',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
clearSelection();
useBulkActions: () => [
{
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: () => {},
},
},
{
label: 'Fake Bulk Action with loading and clear selection',
key: 'fakeBulkActionLoadingClear',
'data-test-subj': 'fake-bulk-action-loading',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
setIsBulkActionLoading(true);
{
label: 'Fake Bulk Action with clear selection',
key: 'fakeBulkActionClear',
'data-test-subj': 'fake-bulk-action-clear',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
clearSelection();
},
},
},
{
label: 'Fake Bulk Action with refresh Action',
key: 'fakeBulkActionRefresh',
'data-test-subj': 'fake-bulk-action-refresh',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
refresh();
{
label: 'Fake Bulk Action with loading and clear selection',
key: 'fakeBulkActionLoadingClear',
'data-test-subj': 'fake-bulk-action-loading',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
setIsBulkActionLoading(true);
},
},
},
] as BulkActionsConfig[],
{
label: 'Fake Bulk Action with refresh Action',
key: 'fakeBulkActionRefresh',
'data-test-subj': 'fake-bulk-action-refresh',
disableOnQuery: false,
onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => {
refresh();
},
},
] as BulkActionsConfig[],
},
{
id: 1,
renderContent: () => <></>,
},
],
},
};
@ -337,11 +345,16 @@ describe('AlertsTable.BulkActions', () => {
...alertsTableConfiguration,
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
},
],
},
],
},
@ -371,6 +384,10 @@ describe('AlertsTable.BulkActions', () => {
field: 'kibana.alert.case_ids',
value: ['test-case'],
},
{
field: 'kibana.alert.workflow_tags',
value: [],
},
],
ecs: {
_id: 'alert0',
@ -576,11 +593,16 @@ describe('AlertsTable.BulkActions', () => {
...alertsTableConfiguration,
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
},
],
},
],
},
@ -609,6 +631,10 @@ describe('AlertsTable.BulkActions', () => {
field: 'kibana.alert.case_ids',
value: [],
},
{
field: 'kibana.alert.workflow_tags',
value: [],
},
],
ecs: {
_id: 'alert1',
@ -632,11 +658,16 @@ describe('AlertsTable.BulkActions', () => {
...alertsTableConfiguration,
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
},
],
},
],
},
@ -788,11 +819,16 @@ describe('AlertsTable.BulkActions', () => {
...alertsTableConfiguration,
useBulkActions: () => [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
id: 0,
items: [
{
label: 'Fake Bulk Action',
key: 'fakeBulkAction',
'data-test-subj': 'fake-bulk-action',
disableOnQuery: false,
onClick: mockedFn,
},
],
},
],
},
@ -823,6 +859,10 @@ describe('AlertsTable.BulkActions', () => {
field: 'kibana.alert.case_ids',
value: [],
},
{
field: 'kibana.alert.workflow_tags',
value: [],
},
],
ecs: {
_id: 'alert0',
@ -845,6 +885,10 @@ describe('AlertsTable.BulkActions', () => {
field: 'kibana.alert.case_ids',
value: [],
},
{
field: 'kibana.alert.workflow_tags',
value: [],
},
],
ecs: {
_id: 'alert1',

View file

@ -5,18 +5,28 @@
* 2.0.
*/
import { EuiPopover, EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { EuiPopover, EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import numeral from '@elastic/numeral';
import React, { useState, useCallback, useMemo, useContext, useEffect } from 'react';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { ALERT_CASE_IDS, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { Alerts, BulkActionsConfig, BulkActionsVerbs, RowSelection } from '../../../../../types';
import {
ALERT_CASE_IDS,
ALERT_RULE_NAME,
ALERT_RULE_UUID,
ALERT_WORKFLOW_TAGS,
} from '@kbn/rule-data-utils';
import {
Alerts,
BulkActionsPanelConfig,
BulkActionsVerbs,
RowSelection,
} from '../../../../../types';
import * as i18n from '../translations';
import { BulkActionsContext } from '../context';
interface BulkActionsProps {
totalItems: number;
items: BulkActionsConfig[];
panels: BulkActionsPanelConfig[];
alerts: Alerts;
setIsBulkActionsLoading: (loading: boolean) => void;
clearSelection: () => void;
@ -53,6 +63,7 @@ const selectedIdsToTimelineItemMapper = (
{ field: ALERT_RULE_NAME, value: alert[ALERT_RULE_NAME] },
{ field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] },
{ field: ALERT_CASE_IDS, value: alert[ALERT_CASE_IDS] ?? [] },
{ field: ALERT_WORKFLOW_TAGS, value: alert[ALERT_WORKFLOW_TAGS] ?? [] },
],
ecs: {
_id: alert._id,
@ -62,8 +73,8 @@ const selectedIdsToTimelineItemMapper = (
});
};
const useBulkActionsToMenuItemMapper = (
items: BulkActionsConfig[],
const useBulkActionsToMenuPanelMapper = (
panels: BulkActionsPanelConfig[],
// in case the action takes time, client can set the alerts to a loading
// state and back when done
setIsBulkActionsLoading: BulkActionsProps['setIsBulkActionsLoading'],
@ -71,43 +82,69 @@ const useBulkActionsToMenuItemMapper = (
clearSelection: BulkActionsProps['clearSelection'],
// In case bulk item action changes the alert data and need to refresh table page.
refresh: BulkActionsProps['refresh'],
alerts: Alerts
alerts: Alerts,
closeIfPopoverIsOpen: () => void
) => {
const [{ isAllSelected, rowSelection }] = useContext(BulkActionsContext);
const bulkActionsItems = useMemo(
() =>
items.map((item) => {
const isDisabled = isAllSelected && item.disableOnQuery;
return (
<EuiContextMenuItem
key={item.key}
data-test-subj={item['data-test-subj']}
disabled={isDisabled}
onClick={() => {
const selectedAlertIds = selectedIdsToTimelineItemMapper(alerts, rowSelection);
item.onClick(
selectedAlertIds,
isAllSelected,
setIsBulkActionsLoading,
clearSelection,
refresh
);
}}
>
{isDisabled && item.disabledLabel ? item.disabledLabel : item.label}
</EuiContextMenuItem>
);
}),
[alerts, isAllSelected, items, rowSelection, setIsBulkActionsLoading, clearSelection, refresh]
);
const bulkActionsPanels = useMemo(() => {
const bulkActionPanelsToReturn = [];
for (const panel of panels) {
const selectedAlertItems = selectedIdsToTimelineItemMapper(alerts, rowSelection);
if (panel.items) {
const newItems = panel.items.map((item) => {
const isDisabled = isAllSelected && item.disableOnQuery;
return {
key: item.key,
'data-test-subj': item['data-test-subj'],
disabled: isDisabled,
onClick: item.onClick
? () => {
closeIfPopoverIsOpen();
item.onClick?.(
selectedAlertItems,
isAllSelected,
setIsBulkActionsLoading,
clearSelection,
refresh
);
}
: undefined,
name: isDisabled && item.disabledLabel ? item.disabledLabel : item.label,
panel: item.panel,
};
});
bulkActionPanelsToReturn.push({ ...panel, items: newItems });
} else {
const ContentPanel = panel.renderContent({
alertItems: selectedAlertItems,
isAllSelected,
setIsBulkActionsLoading,
clearSelection,
refresh,
closePopoverMenu: closeIfPopoverIsOpen,
});
bulkActionPanelsToReturn.push({ ...panel, content: ContentPanel });
}
}
return bulkActionPanelsToReturn;
}, [
alerts,
clearSelection,
isAllSelected,
panels,
refresh,
rowSelection,
setIsBulkActionsLoading,
closeIfPopoverIsOpen,
]);
return bulkActionsItems;
return bulkActionsPanels;
};
const BulkActionsComponent: React.FC<BulkActionsProps> = ({
totalItems,
items,
panels,
alerts,
setIsBulkActionsLoading,
clearSelection,
@ -117,13 +154,6 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false);
const [defaultNumberFormat] = useUiSetting$<string>(DEFAULT_NUMBER_FORMAT);
const [showClearSelection, setShowClearSelectiong] = useState(false);
const bulkActionItems = useBulkActionsToMenuItemMapper(
items,
setIsBulkActionsLoading,
clearSelection,
refresh,
alerts
);
useEffect(() => {
setShowClearSelectiong(isAllSelected);
@ -154,6 +184,15 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
}
}, [isActionsPopoverOpen]);
const bulkActionPanels = useBulkActionsToMenuPanelMapper(
panels,
setIsBulkActionsLoading,
clearSelection,
refresh,
alerts,
closeIfPopoverIsOpen
);
const toggleSelectAll = useCallback(() => {
if (!showClearSelection) {
updateSelectedRows({ action: BulkActionsVerbs.selectAll });
@ -185,12 +224,7 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
);
return (
<div
style={containerStyles}
onClick={closeIfPopoverIsOpen}
data-test-subj="bulk-actions-button-container"
aria-hidden
>
<div style={containerStyles} data-test-subj="bulk-actions-button-container" aria-hidden>
<EuiPopover
isOpen={isActionsPopoverOpen}
anchorPosition="upCenter"
@ -210,7 +244,7 @@ const BulkActionsComponent: React.FC<BulkActionsProps> = ({
}
closePopover={closeActionPopover}
>
<EuiContextMenuPanel size="s" items={bulkActionItems} />
<EuiContextMenu size="s" initialPanelId={0} panels={bulkActionPanels} />
</EuiPopover>
<EuiButtonEmpty
size="xs"

View file

@ -262,20 +262,25 @@ describe('bulk action hooks', () => {
expect(result.current.bulkActions).toMatchInlineSnapshot(`
Array [
Object {
"data-test-subj": "attach-new-case",
"disableOnQuery": true,
"disabledLabel": "Add to new case",
"key": "attach-new-case",
"label": "Add to new case",
"onClick": [Function],
},
Object {
"data-test-subj": "attach-existing-case",
"disableOnQuery": true,
"disabledLabel": "Add to existing case",
"key": "attach-existing-case",
"label": "Add to existing case",
"onClick": [Function],
"id": 0,
"items": Array [
Object {
"data-test-subj": "attach-new-case",
"disableOnQuery": true,
"disabledLabel": "Add to new case",
"key": "attach-new-case",
"label": "Add to new case",
"onClick": [Function],
},
Object {
"data-test-subj": "attach-existing-case",
"disableOnQuery": true,
"disabledLabel": "Add to existing case",
"key": "attach-existing-case",
"label": "Add to existing case",
"onClick": [Function],
},
],
},
]
`);

View file

@ -12,6 +12,7 @@ import {
Alerts,
AlertsTableConfigurationRegistry,
BulkActionsConfig,
BulkActionsPanelConfig,
BulkActionsState,
BulkActionsVerbs,
UseBulkActionsRegistry,
@ -42,7 +43,7 @@ export interface UseBulkActions {
isBulkActionsColumnActive: boolean;
getBulkActionsLeadingControlColumn: GetLeadingControlColumn;
bulkActionsState: BulkActionsState;
bulkActions: BulkActionsConfig[];
bulkActions: BulkActionsPanelConfig[];
setIsBulkActionsLoading: (isLoading: boolean) => void;
clearSelection: () => void;
}
@ -71,6 +72,23 @@ const getCaseAttachments = ({
return groupAlertsByRule?.(filteredAlerts) ?? [];
};
const addItemsToInitialPanel = ({
panels,
items,
}: {
panels: BulkActionsPanelConfig[];
items: BulkActionsConfig[];
}) => {
if (panels.length > 0) {
if (panels[0].items) {
panels[0].items.push(...items);
}
return panels;
} else {
return [{ id: 0, items }];
}
};
export const useBulkAddToCaseActions = ({
casesConfig,
refresh,
@ -157,14 +175,20 @@ export function useBulkActions({
useBulkActionsConfig = () => [],
}: BulkActionsProps): UseBulkActions {
const [bulkActionsState, updateBulkActionsState] = useContext(BulkActionsContext);
const configBulkActions = useBulkActionsConfig(query);
const configBulkActionPanels = useBulkActionsConfig(query);
const clearSelection = () => {
updateBulkActionsState({ action: BulkActionsVerbs.clear });
};
const caseBulkActions = useBulkAddToCaseActions({ casesConfig, refresh, clearSelection });
const bulkActions = [...configBulkActions, ...caseBulkActions];
const bulkActions =
caseBulkActions.length !== 0
? addItemsToInitialPanel({
panels: configBulkActionPanels,
items: caseBulkActions,
})
: configBulkActionPanels;
const isBulkActionsColumnActive = bulkActions.length !== 0;

View file

@ -12,7 +12,12 @@ import {
import React, { lazy, Suspense } from 'react';
import { BrowserFields } from '@kbn/rule-registry-plugin/common';
import { AlertsCount } from './components/alerts_count/alerts_count';
import type { Alerts, BulkActionsConfig, GetInspectQuery, RowSelection } from '../../../../types';
import type {
Alerts,
BulkActionsPanelConfig,
GetInspectQuery,
RowSelection,
} from '../../../../types';
import { LastUpdatedAt } from './components/last_updated_at';
import { FieldBrowser } from '../../field_browser';
import { FieldBrowserOptions } from '../../field_browser/types';
@ -116,7 +121,7 @@ export const getToolbarVisibility = ({
showInspectButton,
toolbarVisiblityProp,
}: {
bulkActions: BulkActionsConfig[];
bulkActions: BulkActionsPanelConfig[];
alertsCount: number;
rowSelection: RowSelection;
alerts: Alerts;
@ -169,7 +174,7 @@ export const getToolbarVisibility = ({
<Suspense fallback={null}>
<BulkActionsToolbar
totalItems={alertsCount}
items={bulkActions}
panels={bulkActions}
alerts={alerts}
setIsBulkActionsLoading={setIsBulkActionsLoading}
clearSelection={clearSelection}

View file

@ -571,18 +571,46 @@ export interface BulkActionsConfig {
'data-test-subj'?: string;
disableOnQuery: boolean;
disabledLabel?: string;
onClick: (
onClick?: (
selectedIds: TimelineItem[],
isAllSelected: boolean,
setIsBulkActionsLoading: (isLoading: boolean) => void,
clearSelection: () => void,
refresh: () => void
) => void;
panel?: number;
}
interface PanelConfig {
id: number;
title?: string;
'data-test-subj'?: string;
}
export interface RenderContentPanelProps {
alertItems: TimelineItem[];
setIsBulkActionsLoading: (isLoading: boolean) => void;
isAllSelected?: boolean;
clearSelection?: () => void;
refresh?: () => void;
closePopoverMenu: () => void;
}
interface ContentPanelConfig extends PanelConfig {
renderContent: (args: RenderContentPanelProps) => JSX.Element;
items?: never;
}
interface ItemsPanelConfig extends PanelConfig {
content?: never;
items: BulkActionsConfig[];
}
export type BulkActionsPanelConfig = ItemsPanelConfig | ContentPanelConfig;
export type UseBulkActionsRegistry = (
query: Pick<QueryDslQueryContainer, 'bool' | 'ids'>
) => BulkActionsConfig[];
) => BulkActionsPanelConfig[];
export type UseCellActions = (props: {
columns: EuiDataGridColumn[];

View file

@ -38,5 +38,6 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./ignore_fields'));
loadTestFile(require.resolve('./migrations'));
loadTestFile(require.resolve('./risk_engine'));
loadTestFile(require.resolve('./set_alert_tags'));
});
};

View file

@ -20,7 +20,7 @@ import {
createSignalsIndex,
deleteAllAlerts,
setSignalStatus,
getSignalStatusEmptyResponse,
getAlertUpdateEmptyResponse,
getQuerySignalIds,
deleteAllRules,
createRule,
@ -51,7 +51,7 @@ export default ({ getService }: FtrProviderContext) => {
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getSignalStatusEmptyResponse());
expect(body).to.eql(getAlertUpdateEmptyResponse());
});
it('should not give errors when querying and the signals index does exist and is empty', async () => {
@ -65,7 +65,7 @@ export default ({ getService }: FtrProviderContext) => {
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getSignalStatusEmptyResponse());
expect(body).to.eql(getAlertUpdateEmptyResponse());
await deleteAllAlerts(supertest, log, es);
});

View file

@ -0,0 +1,248 @@
/*
* 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 expect from '@kbn/expect';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
DETECTION_ENGINE_QUERY_SIGNALS_URL,
DETECTION_ENGINE_ALERT_TAGS_URL,
} from '@kbn/security-solution-plugin/common/constants';
import { DetectionAlert } from '@kbn/security-solution-plugin/common/detection_engine/schemas/alerts';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
createSignalsIndex,
deleteAllAlerts,
getAlertUpdateEmptyResponse,
getQuerySignalIds,
deleteAllRules,
createRule,
waitForSignalsToBePresent,
getSignalsByIds,
waitForRuleSuccess,
getRuleForSignalTesting,
} from '../../utils';
import { buildAlertTagsQuery, setAlertTags } from '../../utils/set_alert_tags';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const log = getService('log');
const es = getService('es');
describe('set_alert_tags', () => {
describe('validation checks', () => {
it('should not give errors when querying and the signals index does not exist yet', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(setAlertTags({ tagsToAdd: [], tagsToRemove: [] }))
.expect(200);
// remove any server generated items that are indeterministic
delete body.took;
expect(body).to.eql(getAlertUpdateEmptyResponse());
});
it('should give errors when duplicate tags exist in both tags_to_add and tags_to_remove', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(setAlertTags({ tagsToAdd: ['test-1'], tagsToRemove: ['test-1'] }))
.expect(400);
expect(body).to.eql({
message: [
'Duplicate tags ["test-1"] were found in the tags_to_add and tags_to_remove parameters.',
],
status_code: 400,
});
});
});
describe.skip('tests with auditbeat data', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
beforeEach(async () => {
await deleteAllRules(supertest, log);
await createSignalsIndex(supertest, log);
});
afterEach(async () => {
await deleteAllAlerts(supertest, log, es);
});
it('should be able to add tags to multiple alerts', async () => {
const rule = {
...getRuleForSignalTesting(['auditbeat-*']),
query: 'process.executable: "/usr/bin/sudo"',
};
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccess({ supertest, log, id });
await waitForSignalsToBePresent(supertest, log, 10, [id]);
const alerts = await getSignalsByIds(supertest, log, [id]);
const alertIds = alerts.hits.hits.map((alert) => alert._id);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: ['tag-1'],
tagsToRemove: [],
query: buildAlertTagsQuery(alertIds),
})
)
.expect(200);
const { body }: { body: estypes.SearchResponse<DetectionAlert> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(alertIds))
.expect(200);
body.hits.hits.map((alert) => {
expect(alert._source?.['kibana.alert.workflow_tags']).to.eql(['tag-1']);
});
});
it('should be able to add tags to alerts that have tags already and not duplicate them', async () => {
const rule = {
...getRuleForSignalTesting(['auditbeat-*']),
query: 'process.executable: "/usr/bin/sudo"',
};
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccess({ supertest, log, id });
await waitForSignalsToBePresent(supertest, log, 10, [id]);
const alerts = await getSignalsByIds(supertest, log, [id]);
const alertIds = alerts.hits.hits.map((alert) => alert._id);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: ['tag-1'],
tagsToRemove: [],
query: buildAlertTagsQuery(alertIds.slice(0, 4)),
})
)
.expect(200);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: ['tag-1'],
tagsToRemove: [],
query: buildAlertTagsQuery(alertIds),
})
)
.expect(200);
const { body }: { body: estypes.SearchResponse<DetectionAlert> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(alertIds))
.expect(200);
body.hits.hits.map((alert) => {
expect(alert._source?.['kibana.alert.workflow_tags']).to.eql(['tag-1']);
});
});
it('should be able to remove tags', async () => {
const rule = {
...getRuleForSignalTesting(['auditbeat-*']),
query: 'process.executable: "/usr/bin/sudo"',
};
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccess({ supertest, log, id });
await waitForSignalsToBePresent(supertest, log, 10, [id]);
const alerts = await getSignalsByIds(supertest, log, [id]);
const alertIds = alerts.hits.hits.map((alert) => alert._id);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: ['tag-1', 'tag-2'],
tagsToRemove: [],
query: buildAlertTagsQuery(alertIds),
})
)
.expect(200);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: [],
tagsToRemove: ['tag-2'],
query: buildAlertTagsQuery(alertIds),
})
)
.expect(200);
const { body }: { body: estypes.SearchResponse<DetectionAlert> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(alertIds))
.expect(200);
body.hits.hits.map((alert) => {
expect(alert._source?.['kibana.alert.workflow_tags']).to.eql(['tag-1']);
});
});
it('should be able to remove tags that do not exist without breaking', async () => {
const rule = {
...getRuleForSignalTesting(['auditbeat-*']),
query: 'process.executable: "/usr/bin/sudo"',
};
const { id } = await createRule(supertest, log, rule);
await waitForRuleSuccess({ supertest, log, id });
await waitForSignalsToBePresent(supertest, log, 10, [id]);
const alerts = await getSignalsByIds(supertest, log, [id]);
const alertIds = alerts.hits.hits.map((alert) => alert._id);
await supertest
.post(DETECTION_ENGINE_ALERT_TAGS_URL)
.set('kbn-xsrf', 'true')
.send(
setAlertTags({
tagsToAdd: [],
tagsToRemove: ['tag-1'],
query: buildAlertTagsQuery(alertIds),
})
)
.expect(200);
const { body }: { body: estypes.SearchResponse<DetectionAlert> } = await supertest
.post(DETECTION_ENGINE_QUERY_SIGNALS_URL)
.set('kbn-xsrf', 'true')
.send(getQuerySignalIds(alertIds))
.expect(200);
body.hits.hits.map((alert) => {
expect(alert._source?.['kibana.alert.workflow_tags']).to.eql([]);
});
});
});
});
};

View file

@ -320,6 +320,7 @@ export default ({ getService }: FtrProviderContext) => {
],
'kibana.alert.status': 'active',
'kibana.alert.workflow_status': 'open',
'kibana.alert.workflow_tags': [],
'kibana.alert.depth': 2,
'kibana.alert.reason':
'event on security-linux-1 created high alert Signal Testing Query.',
@ -481,6 +482,7 @@ export default ({ getService }: FtrProviderContext) => {
],
'kibana.alert.status': 'active',
'kibana.alert.workflow_status': 'open',
'kibana.alert.workflow_tags': [],
'kibana.alert.depth': 2,
'kibana.alert.reason':
'event on security-linux-1 created high alert Signal Testing Query.',

View file

@ -10,6 +10,7 @@ import {
ALERT_REASON,
ALERT_RULE_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
EVENT_KIND,
} from '@kbn/rule-data-utils';
import { flattenWithPrefix } from '@kbn/securitysolution-rules';
@ -148,6 +149,7 @@ export default ({ getService }: FtrProviderContext) => {
[ALERT_RULE_UUID]: fullSignal[ALERT_RULE_UUID],
[ALERT_ORIGINAL_TIME]: fullSignal[ALERT_ORIGINAL_TIME],
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_WORKFLOW_TAGS]: [],
[ALERT_DEPTH]: 1,
[ALERT_ANCESTORS]: [
{

View file

@ -14,6 +14,7 @@ import {
ALERT_STATUS,
ALERT_UUID,
ALERT_WORKFLOW_STATUS,
ALERT_WORKFLOW_TAGS,
SPACE_IDS,
VERSION,
} from '@kbn/rule-data-utils';
@ -118,6 +119,7 @@ export default ({ getService }: FtrProviderContext) => {
'event.kind': 'signal',
[ALERT_ANCESTORS]: expect.any(Array),
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_WORKFLOW_TAGS]: [],
[ALERT_STATUS]: 'active',
[SPACE_IDS]: ['default'],
[ALERT_SEVERITY]: 'critical',

View file

@ -166,6 +166,7 @@ export default ({ getService }: FtrProviderContext) => {
],
'kibana.alert.status': 'active',
'kibana.alert.workflow_status': 'open',
'kibana.alert.workflow_tags': [],
'kibana.alert.depth': 1,
'kibana.alert.reason':
'authentication event with source 8.42.77.171 by root on zeek-newyork-sha-aa8df15 created high alert Query with a rule id.',

View file

@ -17,6 +17,7 @@ import {
ALERT_WORKFLOW_STATUS,
SPACE_IDS,
VERSION,
ALERT_WORKFLOW_TAGS,
} from '@kbn/rule-data-utils';
import { flattenWithPrefix } from '@kbn/securitysolution-rules';
import { ThreatMapping } from '@kbn/securitysolution-io-ts-alerting-types';
@ -284,6 +285,7 @@ export default ({ getService }: FtrProviderContext) => {
[ALERT_STATUS]: 'active',
[ALERT_UUID]: fullSignal[ALERT_UUID],
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_WORKFLOW_TAGS]: [],
[SPACE_IDS]: ['default'],
[VERSION]: fullSignal[VERSION],
threat: {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const getSignalStatusEmptyResponse = () => ({
export const getAlertUpdateEmptyResponse = () => ({
timed_out: false,
total: 0,
updated: 0,

View file

@ -0,0 +1,35 @@
/*
* 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 { AlertTagQuery } from '@kbn/security-solution-plugin/common/detection_engine/schemas/common';
import { SetAlertTagsSchema } from '@kbn/security-solution-plugin/common/detection_engine/schemas/request/set_alert_tags_schema';
export const setAlertTags = ({
tagsToAdd,
tagsToRemove,
query,
}: {
tagsToAdd: string[];
tagsToRemove: string[];
query?: AlertTagQuery;
}): SetAlertTagsSchema => ({
tags: {
tags_to_add: tagsToAdd,
tags_to_remove: tagsToRemove,
},
query,
});
export const buildAlertTagsQuery = (alertIds: string[]) => ({
bool: {
filter: {
terms: {
_id: alertIds,
},
},
},
});