mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Detection Alerts] Alert tagging (#157786)
This commit is contained in:
parent
bed4609afe
commit
0f572605a6
78 changed files with 2073 additions and 374 deletions
|
@ -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,
|
||||
|
|
|
@ -113,6 +113,7 @@ const AlertOptional = rt.partial({
|
|||
time_range: schemaDateRange,
|
||||
url: schemaString,
|
||||
workflow_status: schemaString,
|
||||
workflow_tags: schemaStringArray,
|
||||
}),
|
||||
version: schemaString,
|
||||
}),
|
||||
|
|
|
@ -236,6 +236,7 @@ const SecurityAlertOptional = rt.partial({
|
|||
url: schemaString,
|
||||
workflow_reason: schemaString,
|
||||
workflow_status: schemaString,
|
||||
workflow_tags: schemaStringArray,
|
||||
workflow_user: schemaString,
|
||||
}),
|
||||
version: schemaString,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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.' },
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -292,6 +292,9 @@ describe('mappingFromFieldMap', () => {
|
|||
workflow_status: {
|
||||
type: 'keyword',
|
||||
},
|
||||
workflow_tags: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
space_ids: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 } });
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
|
||||
import { alert_tag_query, alert_tags } from '../common/schemas';
|
||||
|
||||
export const setAlertTagsSchema = t.intersection([
|
||||
t.type({
|
||||
tags: alert_tags,
|
||||
}),
|
||||
t.partial({
|
||||
query: alert_tag_query,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type SetAlertTagsSchema = t.TypeOf<typeof setAlertTagsSchema>;
|
||||
export type SetAlertTagsSchemaDecoded = SetAlertTagsSchema;
|
26
x-pack/plugins/security_solution/common/translations.ts
Normal file
26
x-pack/plugins/security_solution/common/translations.ts
Normal 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',
|
||||
}
|
||||
);
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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"]';
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}, [])
|
||||
: [];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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];
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 [];
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
|
@ -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,
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
|
@ -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 [];
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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.',
|
||||
});
|
|
@ -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'],
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]: {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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.',
|
||||
|
|
|
@ -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]: [
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export const getSignalStatusEmptyResponse = () => ({
|
||||
export const getAlertUpdateEmptyResponse = () => ({
|
||||
timed_out: false,
|
||||
total: 0,
|
||||
updated: 0,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue