diff --git a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts index 87c40e617f90c..1b78ce21021fc 100644 --- a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts +++ b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts @@ -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, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts index f6cd7660f2a79..112d41c243386 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts @@ -113,6 +113,7 @@ const AlertOptional = rt.partial({ time_range: schemaDateRange, url: schemaString, workflow_status: schemaString, + workflow_tags: schemaStringArray, }), version: schemaString, }), diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts index 4ae6a1606fafc..03124f6bef160 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts @@ -236,6 +236,7 @@ const SecurityAlertOptional = rt.partial({ url: schemaString, workflow_reason: schemaString, workflow_status: schemaString, + workflow_tags: schemaStringArray, workflow_user: schemaString, }), version: schemaString, diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index c5ba5f59fb384..3b2ea148591dc 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -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, diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 15449b5331e9c..5f0570fa9542e 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -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, diff --git a/packages/kbn-securitysolution-ecs/src/signal/index.ts b/packages/kbn-securitysolution-ecs/src/signal/index.ts index 4eedc5797ec06..679ab70264d26 100644 --- a/packages/kbn-securitysolution-ecs/src/signal/index.ts +++ b/packages/kbn-securitysolution-ecs/src/signal/index.ts @@ -23,6 +23,7 @@ export type SignalEcsAAD = Exclude & { severity?: string[]; building_block_type?: string[]; workflow_status?: string[]; + workflow_tags?: string[]; suppression?: { docs_count: string[]; }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index d9615dfcb3c35..84079703affc9 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -18,6 +18,10 @@ export const stackManagementSchema: MakeSchemaFrom = { 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.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index bea34e4d2fcd0..a48a3905c8705 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -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; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 5dabe95f027b2..1dcebbc2b31d6 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -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": { diff --git a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts index 5aff2411e07f9..879d1f8212cb2 100644 --- a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts +++ b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts @@ -292,6 +292,9 @@ describe('mappingFromFieldMap', () => { workflow_status: { type: 'keyword', }, + workflow_tags: { + type: 'keyword', + }, }, }, space_ids: { diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index 4efe4d876a3bc..4d25b41b6db0e 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -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, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2d88a0bb0066d..0218b99e7240c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -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; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.9.0/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.9.0/index.ts new file mode 100644 index 0000000000000..45074947e10ad --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.9.0/index.ts @@ -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 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert890 = AlertWithCommonFields800; + +export type EqlShellFields890 = EqlShellFields880 & BaseFields890; + +export type EqlBuildingBlockFields890 = EqlBuildingBlockFields880 & BaseFields890; + +export type NewTermsFields890 = NewTermsFields880 & BaseFields890; + +export type NewTermsAlert890 = NewTermsFields880 & BaseFields890; + +export type EqlBuildingBlockAlert890 = AlertWithCommonFields800; + +export type EqlShellAlert890 = AlertWithCommonFields800; + +export type DetectionAlert890 = + | GenericAlert890 + | EqlShellAlert890 + | EqlBuildingBlockAlert890 + | NewTermsAlert890; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts index 1d3e3f0d35f4f..d3718c4f07db9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts @@ -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, }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index b6f90f4cf7025..cb4de5c5c03a4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -45,6 +45,9 @@ export type SignalIds = t.TypeOf; // 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; + export const fields = t.array(t.string); export type Fields = t.TypeOf; export const fieldsOrUndefined = t.union([fields, t.undefined]); @@ -125,3 +128,10 @@ export const privilege = t.type({ }); export type Privilege = t.TypeOf; + +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; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.mock.ts new file mode 100644 index 0000000000000..d66ecbeee20eb --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.mock.ts @@ -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 } }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.ts new file mode 100644 index 0000000000000..54e5a0b0cd1b6 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_alert_tags_schema.ts @@ -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; +export type SetAlertTagsSchemaDecoded = SetAlertTagsSchema; diff --git a/x-pack/plugins/security_solution/common/translations.ts b/x-pack/plugins/security_solution/common/translations.ts new file mode 100644 index 0000000000000..d0e80c6b7cc8f --- /dev/null +++ b/x-pack/plugins/security_solution/common/translations.ts @@ -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', + } +); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alert_tags.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alert_tags.cy.ts new file mode 100644 index 0000000000000..2c11f7276785a --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alert_tags.cy.ts @@ -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'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 5843bbb776d71..4677f3b6d9ad4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 4b5688ac7d805..c5d2aebee7c54 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -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(); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx index c0da50cad6e75..748b2d6a4779f 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/overview/status_popover_button.tsx @@ -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( 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( data-test-subj="alertStatus" > {CHANGE_ALERT_STATUS} - ); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.test.tsx new file mode 100644 index 0000000000000..0a426a468198c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.test.tsx @@ -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( + + {}} + closePopoverMenu={() => {}} + /> + + ); + + expect(wrapper.getByTestId('alert-tags-selectable-menu')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.tsx new file mode 100644 index 0000000000000..28abb87bc79c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_tags.tsx @@ -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 = ({ + alertItems, + refresh, + refetchQuery, + setIsLoading, + clearSelection, + closePopoverMenu, +}) => { + const [defaultAlertTagOptions] = useUiSetting$(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 = useMemo(() => new Set(), []); + const tagsToRemove: Set = useMemo(() => new Set(), []); + + const [selectableAlertTags, setSelectableAlertTags] = + useState(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 ( + <> + + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+ + {i18n.ALERT_TAGS_UPDATE_BUTTON_MESSAGE} + + + ); +}; + +export const BulkAlertTagsPanel = memo(BulkAlertTagsPanelComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.test.ts new file mode 100644 index 0000000000000..7b437147d71a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.test.ts @@ -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", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.ts b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.ts new file mode 100644 index 0000000000000..39608b4fc7f2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/helpers.ts @@ -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); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/index.tsx index 1ff2ff31102e1..0690620f00ef7 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/index.tsx @@ -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 = ({ } }, [onClearSelection, onSelectAll, showClearSelection]); + const panels = useMemo( + () => [ + { + id: 0, + items: bulkActionItems, + }, + ], + [bulkActionItems] + ); + return ( = ({ } closePopover={closeActionPopover} > - + + 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', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx index d5f4f21ec4248..d2647023539e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx @@ -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( - onClickUpdate(FILTER_OPEN as AlertWorkflowStatus)} - > - {i18n.BULK_ACTION_OPEN_SELECTED} - - ); + 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( - onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus)} - > - {i18n.BULK_ACTION_ACKNOWLEDGED_SELECTED} - - ); + 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( - onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus)} - > - {i18n.BULK_ACTION_CLOSE_SELECTED} - - ); + 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((acc, action) => { + ? customBulkActions.reduce((acc, action) => { const isDisabled = !!(query && action.disableOnQuery); - acc.push( - action.onClick(eventIds)} - > - {action.label} - - ); + 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; }, []) : []; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx new file mode 100644 index 0000000000000..7a9aa1deaf67e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_tags_items.tsx @@ -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) => ( + + ), + }, + ]; + + return { + alertTagsItems, + alertTagsPanels, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx new file mode 100644 index 0000000000000..4805c8dc996fb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_tags.tsx @@ -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; +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().services; + const { addSuccess, addError, addWarning } = useAppToasts(); + const setAlertTagsRef = useRef(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 = getUpdateAlertsQuery(ids).query; + try { + setIsLoading(true); + setTableLoading(true); + const response = await http.fetch( + 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]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_update_alerts.ts b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_update_alerts.ts index cacc4df1a3820..13ecf12b4479c 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_update_alerts.ts +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_update_alerts.ts @@ -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. diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index d301d6e9736f5..d2c03d7ae6f40 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -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', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index e5b4640adf8fe..57b0491446121 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -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 !isEvent && ruleId ? [ ...addToCaseActionItems, ...statusActionItems, + ...alertTagsItems, ...exceptionActionItems, ...(agentId ? osqueryActionItems : []), ...alertDetailsActionItems, @@ -246,9 +256,21 @@ const AlertContextMenuComponent: React.FC [ + { + id: 0, + items, + }, + ...alertTagsPanels, + ], + [alertTagsPanels, items] + ); + const osqueryFlyout = useMemo(() => { return ( - + diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx index e63cbcc4c22d8..98b36187f9925 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -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 ? [] : [ - - {ACTION_ADD_ENDPOINT_EXCEPTION} - , - - - {ACTION_ADD_EXCEPTION} - , + { + 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, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx index c117b0f1ba4e1..de33379f48aba 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx @@ -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( { closePopover={() => {}} button={<>} > - + ); }; @@ -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' ); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 0873202336100..ea781bf47ab0e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -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 - - {ADD_TO_EXISTING_CASE} - , + { + '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 - - {ADD_TO_NEW_CASE} - , + { + '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 []; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx new file mode 100644 index 0000000000000..46db4547b399f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_tags_actions.tsx @@ -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, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx index 4327c5a69a949..5babe386b6537 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx @@ -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( - () => [ - - {ACTION_ADD_EVENT_FILTER} - , + (): 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] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx index 21ec523d7d28c..417774d7b2bc3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.test.tsx @@ -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( + {}} + button={<>} + > + + + ); +}; + 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')); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx index c59c7e7c93006..cf4fec024f808 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -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( () => [ - - {ACTION_INVESTIGATE_IN_TIMELINE} - , + { + 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] ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_open_alert_details.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_open_alert_details.tsx index 4b3c1c1de9106..953b26594aaca 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_open_alert_details.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_open_alert_details.tsx @@ -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( - - {ACTION_OPEN_ALERT_DETAILS_PAGE} - - ); + alertDetailsActionItems.push({ + key: 'open-alert-details-item', + 'data-test-subj': 'open-alert-details-page-menu-item', + onClick, + name: ACTION_OPEN_ALERT_DETAILS_PAGE, + }); } return { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index 65712c9b8b265..9bb0944e38871 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -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; diff --git a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/responder_context_menu_item.tsx b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/responder_context_menu_item.tsx deleted file mode 100644 index 9d50884b68643..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/responder_context_menu_item.tsx +++ /dev/null @@ -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( - ({ endpointId, onClick }) => { - const { handleResponseActionsClick, isDisabled, tooltip } = useResponderActionData({ - endpointId, - onClick, - }); - - return ( - - - - ); - } -); -ResponderContextMenuItem.displayName = 'ResponderContextMenuItem'; diff --git a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_item.tsx index 9a9d8e7d7470d..21a4bebcf4a37 100644 --- a/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_item.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/endpoint_responder/use_responder_action_item.tsx @@ -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( - - ); + actions.push({ + key: 'endpointResponseActions-action-item', + 'data-test-subj': 'endpointResponseActions-action-item', + disabled: isDisabled, + toolTipContent: tooltip, + size: 's', + onClick: handleResponseActionsClick, + name: ( + + ), + }); } return actions; - }, [canAccessResponseConsole, endpointId, isAlert, isAuthzLoading, isEndpointAlert, onClick]); + }, [ + canAccessResponseConsole, + handleResponseActionsClick, + isAlert, + isAuthzLoading, + isDisabled, + tooltip, + ]); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx index 62debf400a387..531053629e62b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx @@ -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 ? [ - - {isolateHostTitle} - , + { + key: 'isolate-host-action-item', + 'data-test-subj': 'isolate-host-action-item', + disabled: agentStatus === HostStatus.UNENROLLED, + onClick: isolateHostHandler, + name: isolateHostTitle, + }, ] : [], [ diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx index 1b8c022cccd8d..ec55fa849443e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/osquery_action_item.tsx @@ -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) => ( - - {ACTION_OSQUERY} - -); +export const getOsqueryActionItem = ({ handleClick }: IProps): AlertTableContextMenuItem => ({ + key: 'osquery-action-item', + 'data-test-subj': 'osquery-action-item', + onClick: handleClick, + size: 's', + name: ACTION_OSQUERY, +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx b/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx index 41a78eb32619f..742fda29c344e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/osquery/use_osquery_context_action_item.tsx @@ -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( - () => , - [handleClick] - ); + const osqueryActionItem = useMemo(() => getOsqueryActionItem({ handleClick }), [handleClick]); const permissions = useKibana().services.application.capabilities.osquery; return { diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx index 8d11a2a50b327..666db98405454 100644 --- a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -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( () => ( - + ) : null; } diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx index 371d22a6576d5..30e86f6185c33 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx @@ -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]; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts new file mode 100644 index 0000000000000..ffa28ad832620 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts @@ -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 []; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.test.ts new file mode 100644 index 0000000000000..b6476cd80e36d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.test.ts @@ -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; + let request: ReturnType; + 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, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts new file mode 100644 index 0000000000000..00e85bf29f598 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_tags_route.ts @@ -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( + 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, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts new file mode 100644 index 0000000000000..74c662caa1cb5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts @@ -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.', + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts index f852bfff48873..93c93eb631fa2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts @@ -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'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 8525c63ce8c87..06b0dc5b90514 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -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); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index 5d53380a736f5..5604c0f9a9a11 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -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, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts index 3d3dc8872f261..c1d80cc3975ec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts @@ -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', diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 3d6027b6b5143..2457503d260b3 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -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); diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 935b383268513..0ee1d4e9e676e 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -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: + '

List of tag options for use with alerts generated by Security Solution rules.

', + }), + category: [APP_ID], + requiresPageReload: true, + schema: schema.arrayOf(schema.string()), + }, ...(experimentalFeatures.extendedRuleExecutionLoggingEnabled ? { [EXTENDED_RULE_EXECUTION_LOGGING_ENABLED_SETTING]: { diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts index bd63e464bf64f..e68f73624185d 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/helpers/constants.ts @@ -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', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx index c823b8d4156f8..9daeffc544cd7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx @@ -267,11 +267,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: () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx index e1b630d56a5d7..63347d5c99a17 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx @@ -179,43 +179,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: () => <>, + }, + ], }, }; @@ -334,11 +342,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, + }, + ], }, ], }, @@ -368,6 +381,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.case_ids', value: ['test-case'], }, + { + field: 'kibana.alert.workflow_tags', + value: [], + }, ], ecs: { _id: 'alert0', @@ -573,11 +590,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, + }, + ], }, ], }, @@ -606,6 +628,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.case_ids', value: [], }, + { + field: 'kibana.alert.workflow_tags', + value: [], + }, ], ecs: { _id: 'alert1', @@ -629,11 +655,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, + }, + ], }, ], }, @@ -785,11 +816,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, + }, + ], }, ], }, @@ -820,6 +856,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.case_ids', value: [], }, + { + field: 'kibana.alert.workflow_tags', + value: [], + }, ], ecs: { _id: 'alert0', @@ -842,6 +882,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.case_ids', value: [], }, + { + field: 'kibana.alert.workflow_tags', + value: [], + }, ], ecs: { _id: 'alert1', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx index 4459bd09b23cd..f75dbc43c1fe0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx @@ -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 ( - { - const selectedAlertIds = selectedIdsToTimelineItemMapper(alerts, rowSelection); - item.onClick( - selectedAlertIds, - isAllSelected, - setIsBulkActionsLoading, - clearSelection, - refresh - ); - }} - > - {isDisabled && item.disabledLabel ? item.disabledLabel : item.label} - - ); - }), - [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 = ({ totalItems, - items, + panels, alerts, setIsBulkActionsLoading, clearSelection, @@ -117,13 +154,6 @@ const BulkActionsComponent: React.FC = ({ const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); const [defaultNumberFormat] = useUiSetting$(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 = ({ } }, [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 = ({ ); return ( -
+
= ({ } closePopover={closeActionPopover} > - + { 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], + }, + ], }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts index 4234de7ac62ab..fdb2d886e0e07 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts @@ -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; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx index a8abe4fc983d7..c2af838b1ebe6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx @@ -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 = ({ 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 -) => BulkActionsConfig[]; +) => BulkActionsPanelConfig[]; export type UseCellActions = (props: { columns: EuiDataGridColumn[]; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts index 1758ce0e99c07..c8501050fc25c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts @@ -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')); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/open_close_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/open_close_signals.ts index f27e58185d225..c9fb6334ab8c5 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/open_close_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/open_close_signals.ts @@ -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); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/set_alert_tags.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/set_alert_tags.ts new file mode 100644 index 0000000000000..c3334cbe1c504 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/set_alert_tags.ts @@ -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 } = 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 } = 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 } = 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 } = 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([]); + }); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts index 1768d44ece15a..963ae79d0d551 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group6/alerts/alerts_compatibility.ts @@ -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.', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts index 494908a3ad3f9..6959358efda6c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/eql.ts @@ -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]: [ { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts index fcfadcf476ff5..788d0c09d425d 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/machine_learning.ts @@ -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', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts index 654d43da9c0d6..aa54019d64fbd 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/new_terms.ts @@ -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.', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts index 50faff2e243f0..85e9bb1809129 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts @@ -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: { diff --git a/x-pack/test/detection_engine_api_integration/utils/get_signal_status_empty_response.ts b/x-pack/test/detection_engine_api_integration/utils/get_signal_status_empty_response.ts index bcbc286a9f088..3a7d612fea854 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_signal_status_empty_response.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_signal_status_empty_response.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const getSignalStatusEmptyResponse = () => ({ +export const getAlertUpdateEmptyResponse = () => ({ timed_out: false, total: 0, updated: 0, diff --git a/x-pack/test/detection_engine_api_integration/utils/set_alert_tags.ts b/x-pack/test/detection_engine_api_integration/utils/set_alert_tags.ts new file mode 100644 index 0000000000000..cff9524fbb337 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/utils/set_alert_tags.ts @@ -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, + }, + }, + }, +});