diff --git a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts index 41519a34fd80c..16645324e2bd7 100644 --- a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts +++ b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts @@ -37,6 +37,9 @@ import { ALERT_START, ALERT_STATUS, ALERT_TIME_RANGE, + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_ASSIGNEE_IDS, @@ -213,6 +216,21 @@ export const alertFieldMap = { array: false, required: false, }, + [ALERT_UPDATED_AT]: { + type: 'date', + array: false, + required: false, + }, + [ALERT_UPDATED_BY_USER_ID]: { + type: 'keyword', + array: false, + required: false, + }, + [ALERT_UPDATED_BY_USER_NAME]: { + type: 'keyword', + array: false, + required: false, + }, [ALERT_URL]: { type: 'keyword', array: false, diff --git a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts index 36360d1ce2f14..ba244b9c714aa 100644 --- a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts +++ b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts @@ -107,6 +107,9 @@ const AlertOptional = rt.partial({ 'kibana.alert.severity_improving': schemaBoolean, 'kibana.alert.start': schemaDate, 'kibana.alert.time_range': schemaDateRange, + 'kibana.alert.updated_at': schemaDate, + 'kibana.alert.updated_by.user.id': schemaString, + 'kibana.alert.updated_by.user.name': schemaString, 'kibana.alert.url': schemaString, 'kibana.alert.workflow_assignee_ids': schemaStringArray, 'kibana.alert.workflow_status': schemaString, diff --git a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts index 1b87ec8134c48..41c20ce76afe4 100644 --- a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts +++ b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts @@ -207,6 +207,9 @@ const SecurityAlertOptional = rt.partial({ }) ), 'kibana.alert.time_range': schemaDateRange, + 'kibana.alert.updated_at': schemaDate, + 'kibana.alert.updated_by.user.id': schemaString, + 'kibana.alert.updated_by.user.name': schemaString, 'kibana.alert.url': schemaString, 'kibana.alert.user.criticality_level': schemaString, 'kibana.alert.workflow_assignee_ids': schemaStringArray, diff --git a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/streams_schema.ts b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/streams_schema.ts index 984ccb59e9bd0..fc9797d097747 100644 --- a/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/streams_schema.ts +++ b/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/streams_schema.ts @@ -107,6 +107,9 @@ const StreamsAlertOptional = rt.partial({ 'kibana.alert.severity_improving': schemaBoolean, 'kibana.alert.start': schemaDate, 'kibana.alert.time_range': schemaDateRange, + 'kibana.alert.updated_at': schemaDate, + 'kibana.alert.updated_by.user.id': schemaString, + 'kibana.alert.updated_by.user.name': schemaString, 'kibana.alert.url': schemaString, 'kibana.alert.workflow_assignee_ids': schemaStringArray, 'kibana.alert.workflow_status': schemaString, diff --git a/src/platform/packages/shared/kbn-rule-data-utils/src/default_alerts_as_data.ts b/src/platform/packages/shared/kbn-rule-data-utils/src/default_alerts_as_data.ts index 7a08a9f743a07..2917c124ffbdc 100644 --- a/src/platform/packages/shared/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/src/platform/packages/shared/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -71,6 +71,15 @@ const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const; // kibana.alert.start - timestamp when the alert is first active const ALERT_START = `${ALERT_NAMESPACE}.start` as const; +// kibana.alert.updated_at - timestamp when the alert was last updated +const ALERT_UPDATED_AT = `${ALERT_NAMESPACE}.updated_at` as const; + +// kibana.alert.updated_by.user.id - user id of the user that last updated the alert +const ALERT_UPDATED_BY_USER_ID = `${ALERT_NAMESPACE}.updated_by.user.id` as const; + +// kibana.alert.updated_by.user.name - user name of the user that last updated the alert +const ALERT_UPDATED_BY_USER_NAME = `${ALERT_NAMESPACE}.updated_by.user.name` as const; + // kibana.alert.status - active/recovered status of alert const ALERT_STATUS = `${ALERT_NAMESPACE}.status` as const; @@ -163,6 +172,9 @@ export const fields = { ALERT_RULE_UUID, ALERT_SEVERITY_IMPROVING, ALERT_START, + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, ALERT_STATUS, ALERT_TIME_RANGE, ALERT_URL, @@ -210,6 +222,9 @@ export { ALERT_RULE_UUID, ALERT_SEVERITY_IMPROVING, ALERT_START, + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, ALERT_STATUS, ALERT_TIME_RANGE, ALERT_URL, diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.gen.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.gen.ts index 053691aa1a303..3a55192552523 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.gen.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.gen.ts @@ -44,6 +44,26 @@ export const AttackDiscoveryAlert = z.object({ * The (human readable) name of the connector that generated the attack discovery */ connectorName: z.string(), + /** + * The optional time the attack discovery alert was created + */ + alertStart: z.string().optional(), + /** + * The optional time the attack discovery alert was last updated + */ + alertUpdatedAt: z.string().optional(), + /** + * The optional id of the user who last updated the attack discovery alert + */ + alertUpdatedByUserId: z.string().optional(), + /** + * The optional username of the user who updated the attack discovery alert + */ + alertUpdatedByUserName: z.string().optional(), + /** + * The optional time the attack discovery alert workflow status was last updated + */ + alertWorkflowStatusUpdatedAt: z.string().optional(), /** * Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data. */ diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.schema.yaml b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.schema.yaml index 321adf5068f3f..ae7ba327b23c7 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.schema.yaml +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/attack_discovery_alert.schema.yaml @@ -37,6 +37,21 @@ components: connectorName: description: The (human readable) name of the connector that generated the attack discovery type: string + alertStart: + description: The optional time the attack discovery alert was created + type: string + alertUpdatedAt: + description: The optional time the attack discovery alert was last updated + type: string + alertUpdatedByUserId: + description: The optional id of the user who last updated the attack discovery alert + type: string + alertUpdatedByUserName: + description: The optional username of the user who updated the attack discovery alert + type: string + alertWorkflowStatusUpdatedAt: + description: The optional time the attack discovery alert workflow status was last updated + type: string detailsMarkdown: description: Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data. type: string diff --git a/x-pack/platform/plugins/shared/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts b/x-pack/platform/plugins/shared/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts index fd6d37261bc24..8bed2944919e1 100644 --- a/x-pack/platform/plugins/shared/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts +++ b/x-pack/platform/plugins/shared/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts @@ -324,6 +324,23 @@ describe('mappingFromFieldMap', () => { type: 'date_range', format: 'epoch_millis||strict_date_optional_time', }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + user: { + properties: { + id: { + type: 'keyword', + }, + name: { + type: 'keyword', + }, + }, + }, + }, + }, url: { ignore_above: 2048, index: false, diff --git a/x-pack/platform/plugins/shared/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap b/x-pack/platform/plugins/shared/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap index 4a8749e2ef0c7..09b6aa598dd53 100644 --- a/x-pack/platform/plugins/shared/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap +++ b/x-pack/platform/plugins/shared/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap @@ -1477,6 +1477,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -2615,6 +2630,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -3753,6 +3783,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -4891,6 +4936,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -6029,6 +6089,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -7173,6 +7248,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -8311,6 +8401,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -9449,6 +9554,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, @@ -10165,6 +10285,21 @@ Object { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, diff --git a/x-pack/platform/plugins/shared/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/platform/plugins/shared/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index c4110de3c5ff3..a66160b3e9abe 100644 --- a/x-pack/platform/plugins/shared/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/platform/plugins/shared/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -324,6 +324,21 @@ it('matches snapshot', () => { "required": false, "type": "date_range", }, + "kibana.alert.updated_at": Object { + "array": false, + "required": false, + "type": "date", + }, + "kibana.alert.updated_by.user.id": Object { + "array": false, + "required": false, + "type": "keyword", + }, + "kibana.alert.updated_by.user.name": Object { + "array": false, + "required": false, + "type": "keyword", + }, "kibana.alert.url": Object { "array": false, "ignore_above": 2048, diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_dynamic_templates.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_dynamic_templates.ts index 7983a3d09c6ba..0ba0da8202aae 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_dynamic_templates.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_dynamic_templates.ts @@ -70,8 +70,8 @@ export default function createAlertsAsDataDynamicTemplatesTest({ getService }: F // there is no way to get the real number of fields from ES. // Eventhough we have only as many as alertFieldMap fields, // ES counts the each childs of the nested objects and multi_fields as seperate fields. - // therefore we add 9 to get the real number. - const nestedObjectsAndMultiFields = 9; + // therefore we add 11 to get the real number. + const nestedObjectsAndMultiFields = 11; // Number of free slots that we want to have, so we can add dynamic fields as many const numberofFreeSlots = 2; const totalFields = diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/attack_discovery_alert_document_response.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/attack_discovery_alert_document_response.ts new file mode 100644 index 0000000000000..dd73d8cf12a86 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/__mocks__/attack_discovery_alert_document_response.ts @@ -0,0 +1,210 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; + +import { AttackDiscoveryAlertDocument } from '../lib/attack_discovery/schedules/types'; + +export const getResponseMock = (): estypes.SearchResponse => ({ + took: 1, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1, + hits: [ + { + _index: '.internal.adhoc.alerts-security.attack.discovery.alerts-default-000001', + _id: '29ceb1fa1482f02a2eb6073991078544e529edfc633a5621b20a93eefbb63083', + _score: 1, + _source: { + 'kibana.alert.workflow_status_updated_at': '2025-06-23T15:16:52.984Z', + 'kibana.alert.status': 'active', + 'kibana.alert.updated_at': '2025-06-23T15:16:52.984Z', + 'kibana.alert.attack_discovery.api_config': { + action_type_id: '.gemini', + connector_id: 'gemini_2_5_pro', + name: 'Gemini 2.5 Pro', + }, + 'kibana.alert.attack_discovery.title_with_replacements': + 'Widespread Malware Campaign via Compromised Account', + 'kibana.alert.attack_discovery.summary_markdown_with_replacements': + 'A widespread campaign was conducted using the compromised account of user {{ user.name Administrator }}, deploying various malware including Sodinokibi, Emotet, Qakbot, and Bumblebee across multiple Windows hosts.', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.attack_discovery.alert_ids': [ + 'ee183cf525d7e9d0f47d1b2bb928d760a0f53756ffa61edcf0672f71c986ac21', + '46ebac989ca72439b14b57d32102543c17d5f33e0f6532d8a5c148949d8ff7b5', + '857f6434220ff27f807bef6829f32d1ad1c337026db016bc54e302eecf95cf93', + 'e892d2e28a1e10822385cd0bff1399d63d1014961e020d9b3251dd24764914e6', + '492c647e3c671a9d62567d100cdad1a503c749755db3b67ba3c08335acc79e18', + '9004764b04239eed88a61a6779c5b1dc82ec3ff6f05ab1dcd6892df75322958d', + '4f27dc19eee50707adfb1fc9710292bc2264d176fea671a26bb90b904d565547', + '5cbe0d15df86b6b080ace4139338eb2a8b3e9696dd28f8c7f586747a88652a38', + 'f9aea50da1ae3e4c157802f64eb090bbfa65fc95d1c1d39c4c16d9dc52338a67', + '713a8950584268a0c48fa85f60b71a4aa200043587f9136aa3b2e5269a01377b', + '624a2d4db0705ad13bceb19655dc23c89db1fcb3dfb1162f579495e291692528', + 'f3108492aabb7b342bf6880cff39060092f0465dd16e4413ae4da3d03ab9ce27', + '3f3e3169d8c4270ad33eb4b5943d6d52023a410b7f6e9ce9569ea85d6aa67dc4', + '7934c23154502cb585332d9540072f6678d18296aa4ce63ff46b5645114c1ece', + '3496beb26016dd31fb3bcaff1363d694f10819a0965447c5a13a2c5ef7e69e76', + 'f6c88010fa0ac0022c6c4270deeeea4dcf54d2cce9ca3a19e307726a703cbf97', + '4aa5b853e0284a1d3a7f5c55b9cc2dd3ff832cdfa617c8cb78159872c0524db4', + '4833ff1e37aa3f63b60cb360c89864210fa63224ff1eecdc8084d081795e0cf7', + 'a34a21fcb0a4b3ba10fc15575346b8f8122b14664f06a41929ca19fc6f07fd33', + '09ab33140c37cbadc84f75e833ca0f7846951938dbe75bbe6144b83083c0cc56', + 'cdf0665f5fb126bd53d17979ca8ecfc06b62325ad31e8784a0d171d31b12de7f', + '35ddcc81c91ef0a6db1b4243bfbb39d90a40123222a3d7ab8379ab9a83420c46', + '01566232ff1a19c907dd99bcfb5dca1525b8b1038f5ef35896b6419db55dc585', + '3bf858728a763b6c95846eb75393f2c61081390cf42043e41929a0762b272cd0', + '3efcb54f2f75e1b1386284db7a050fb5d002dd604a3078090e3164174377a7ee', + '7ee01f9f3928491be0184aea39ba21ee38864c54858d2d33de52b3316b8600dc', + 'b26078a6de3d1e023a78902dc966dc433c9125bb9b12db1c1b1d0c8512ef2853', + '859e93ffbafba637fe3bf36ff2288b7461c9b559656e13129ea878fca5f2486a', + 'c0e618e366e374f6a60ab32dc006356680904a618c7e0ee31a574a248aaf83cf', + '57f1eb796cd46413992f214bf89db53fc549b4fc6e8d3d8769c2c1a8dd8a3078', + '1021ef6fd529c9f45d3e2ac791f0a7de332514dd9dcc7640f839db617649cd75', + '7bb4d2d5168cc13d4e8a7558eb68d2d189833cddedaf03e7959e9a256038c9fe', + 'a14446fdfcb3559556ff08f1d9fc97d72435a8741ede1a59302211d4a2b1a7bd', + 'c8008b0d2af8987698f8b10d925c1e088c8b72571d32a74120ba8b7416c8b818', + 'b767e145aaa84bf01d80eed08c9135bc3f7c9c638593fd82e1ea8155a41317d3', + '2f18e88a345a3d183b2093cb15bd8d68fb37e7485e55a9938ec385386114a710', + 'aa9fa70573cbc8136171543179244abc563ca839890ec0f19c32aaee7d91d5ee', + 'd4a609ca641075862e9e94f6ca70b699c734279f424a39ae54498e74d57a9edf', + '1ca972204a9667f163f29c6d732ac6cafdf1a0793e029c8b2df9e84254619486', + '4a52df99422830601a2daa35f574e08214a4ec23ad82578c6a33a4bc24614177', + 'c720d533a8086db64aa19dc289ba7d8dba931c0c1e81c31b3a2381108bd54f80', + '1d0b42cc9bab440d30ae6ccf0b586fac1a3416e2470e84b0e7fe2fe336b3ecdb', + '152c39218c74e1f1efea9f6a592e85c9b3715ac4c9b36f2a67c3b66f7cda476a', + '41e37269498d007217b82e0e5e0bd2bc11cfb8472d22f2bf5d57bf559518bc90', + '96a013fe5840fa4ebf9e8b413ad9ba5c6b9da0406225e0bd6ad4d1a7110045c0', + '67bad3f1ee713789aa58a8f712e31e55816df15a08e4fef17f0a77f0dcea2a16', + 'bfc18b3ee2c15d34d80fc3781902b7e90f689f7298f368712c3dd5b8640e6f06', + '756e21cf69f49031f99fb60e05bbd5cf3b6101528cb84ea5f8cb6c328c727f68', + '1d1a2fc0aa17f818c49dcd1effbe3f0d3b937ceb2852f0614f272141a432683b', + 'efd78eda82be49976cba570675624475838d97462795fa427582c09a08914e24', + 'a5774ec28dceccdd88b4cedced5c09004023005c47438ceb538edf906a3a4976', + '0c19dd81b6abe18b3a3e72aa471c8ab9c08f0c76acf80441b1556f6520e63607', + '5f05a2d80b8b0b78c3b978b8b4143b863832d99f8f02c793aed23419d80889ae', + 'b189d3e05a0dd24cd5326150fdf95460be60914bf919da3cdad707827e360444', + 'ca88c363dae68e62a690e63e90728538431b30d35b3686adb32d7a973350a456', + '2612f23d1dc0c3fdc3679c2cf05b66e150fdef006b02d59fd317d70c76018d8c', + '168d23918d7561c7494a2d5b75a12a515ec6054c78801f26512a757b40e81e08', + 'ad590fd49fa67224d9a562ad33d4b7d8f8bca6f63ff5d8c1859127d43b49fb15', + '9be5996df1622222a96b4b3d6359f06922866cb4560c0cd8a806be8b828e7531', + '644e2c3a505baa9cdc047972ecff9e7ede214fa964b392efc2dcda4a80c9c60e', + '05a26a422f52e03b318e763ecd53d027b39bbf1920b53babc33fba9db0f10fc4', + 'fbf23653042416c886f12df972a885d847fe5681f466aff64a611aa01f9a5011', + 'b0c92ae7ecaa07702798fbb161ce189a80da259390876c14daace753d73896f9', + ], + 'kibana.alert.attack_discovery.entity_summary_markdown_with_replacements': + 'A widespread malware campaign using the compromised account of user {{ user.name Administrator }} impacted multiple Windows hosts, including {{ host.name SRVWIN02 }} and {{ host.name SRVWIN04 }}.', + 'kibana.alert.rule.rule_type_id': 'attack_discovery_ad_hoc_rule_type_id', + 'kibana.alert.instance.id': + '29ceb1fa1482f02a2eb6073991078544e529edfc633a5621b20a93eefbb63083', + 'kibana.alert.risk_score': 6237, + 'kibana.alert.rule.name': 'Attack discovery ad hoc (placeholder rule name)', + 'event.kind': 'signal', + 'kibana.alert.attack_discovery.details_markdown_with_replacements': + 'A widespread attack campaign was executed across multiple Windows hosts, unified by the use of a single compromised user account, {{ user.name Administrator }}. The attacker leveraged this access to deploy a variety of malware families using different initial access and execution techniques, culminating in a ransomware attack.\n* **Qakbot Infection:** On host {{ host.name SRVWIN04 }}, the attack began with a malicious OneNote file. This led to {{ process.name mshta.exe }} executing a script, which used {{ process.name curl.exe }} to download a payload from {{ source.ip 77.75.230.128 }}. The payload was executed via {{ process.name rundll32.exe }} and injected into {{ process.name AtBroker.exe }}, identified as the {{ rule.name Windows.Trojan.Qbot }} trojan.\n* **Emotet Infection:** On host {{ host.name SRVWIN03 }}, a malicious Excel document spawned {{ process.name regsvr32.exe }} to load a malicious DLL, ultimately leading to the execution of the {{ rule.name Windows.Trojan.Emotet }} trojan and the establishment of persistence via registry run keys.\n* **Bumblebee Trojan:** On host {{ host.name SRVWIN06 }}, the attacker used {{ process.parent.name msiexec.exe }} to proxy the execution of a malicious PowerShell script, which injected the {{ rule.name Windows.Trojan.Bumblebee }} trojan into its own memory and established C2 communication.\n* **Generic Droppers:** On other hosts, similar initial access vectors were used. On host {{ host.name SRVWIN07 }}, a Word document dropped and executed a VBScript, which then used PowerShell and created a scheduled task for persistence. On host {{ host.name SRVWIN01 }}, an Excel file used {{ process.name certutil.exe }} to decode and execute a payload.\n* **Ransomware Deployment:** The campaign culminated on host {{ host.name SRVWIN02 }} with the deployment of Sodinokibi (REvil) ransomware. A malicious executable used DLL side-loading to compromise the legitimate Microsoft Defender process, {{ process.name MsMpEng.exe }}, which then executed the ransomware and began encrypting files.', + 'kibana.alert.updated_by.user.name': 'elastic', + 'kibana.alert.attack_discovery.user.id': + 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + 'kibana.alert.attack_discovery.mitre_attack_tactics': [ + 'Initial Access', + 'Execution', + 'Persistence', + 'Defense Evasion', + 'Command and Control', + 'Impact', + ], + 'kibana.alert.attack_discovery.user.name': 'elastic', + 'kibana.alert.workflow_status': 'acknowledged', + 'kibana.alert.rule.uuid': 'attack_discovery_ad_hoc_rule_id', + 'kibana.alert.attack_discovery.alerts_context_count': 75, + 'kibana.alert.attack_discovery.summary_markdown': + 'A widespread campaign was conducted using the compromised account of user {{ user.name 6f53c297-f5cb-48c3-8aff-2e2d7a390169 }}, deploying various malware including Sodinokibi, Emotet, Qakbot, and Bumblebee across multiple Windows hosts.', + 'kibana.alert.attack_discovery.replacements': [ + { + uuid: 'e56f5c52-ebb0-4ec8-aad5-2659df2e0206', + value: 'root', + }, + { + uuid: '99612aef-0a5a-41da-9da4-b5b5ece226a4', + value: 'SRVMAC08', + }, + { + uuid: '02de873c-51e3-4c01-8a22-0986225775f3', + value: 'james', + }, + { + uuid: '9a98cc1d-a7a3-4924-b939-b17b2ec5dbdd', + value: 'SRVWIN07', + }, + { + uuid: '6f53c297-f5cb-48c3-8aff-2e2d7a390169', + value: 'Administrator', + }, + { + uuid: '4d9943f7-cbef-462b-a882-e39db5da7abd', + value: 'SRVWIN06', + }, + { + uuid: 'aa5e02c8-f542-4db9-8ade-87fd1283ddac', + value: 'SRVNIX05', + }, + { + uuid: '0d7534c9-79f5-46ed-9df9-3dfcff57e5ed', + value: 'SRVWIN04', + }, + { + uuid: 'deb5784c-55d3-4422-9d7c-06f1f71c04b3', + value: 'SRVWIN03', + }, + { + uuid: '6aece05f-675e-4dc0-b8fa-ba0f1a43d691', + value: 'SRVWIN02', + }, + { + uuid: '7c9a79a0-c029-4acb-b61c-d5831b409943', + value: 'SRVWIN01', + }, + ], + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.category': 'Attack discovery ad hoc (placeholder rule category)', + 'kibana.alert.start': '2025-06-23T14:25:24.104Z', + '@timestamp': '2025-06-23T14:25:24.104Z', + 'ecs.version': '8.11.0', + 'kibana.alert.attack_discovery.title': + 'Widespread Malware Campaign via Compromised Account', + 'kibana.alert.updated_by.user.id': 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + 'kibana.alert.attack_discovery.details_markdown': + 'A widespread attack campaign was executed across multiple Windows hosts, unified by the use of a single compromised user account, {{ user.name 6f53c297-f5cb-48c3-8aff-2e2d7a390169 }}. The attacker leveraged this access to deploy a variety of malware families using different initial access and execution techniques, culminating in a ransomware attack.\n* **Qakbot Infection:** On host {{ host.name 0d7534c9-79f5-46ed-9df9-3dfcff57e5ed }}, the attack began with a malicious OneNote file. This led to {{ process.name mshta.exe }} executing a script, which used {{ process.name curl.exe }} to download a payload from {{ source.ip 77.75.230.128 }}. The payload was executed via {{ process.name rundll32.exe }} and injected into {{ process.name AtBroker.exe }}, identified as the {{ rule.name Windows.Trojan.Qbot }} trojan.\n* **Emotet Infection:** On host {{ host.name deb5784c-55d3-4422-9d7c-06f1f71c04b3 }}, a malicious Excel document spawned {{ process.name regsvr32.exe }} to load a malicious DLL, ultimately leading to the execution of the {{ rule.name Windows.Trojan.Emotet }} trojan and the establishment of persistence via registry run keys.\n* **Bumblebee Trojan:** On host {{ host.name 4d9943f7-cbef-462b-a882-e39db5da7abd }}, the attacker used {{ process.parent.name msiexec.exe }} to proxy the execution of a malicious PowerShell script, which injected the {{ rule.name Windows.Trojan.Bumblebee }} trojan into its own memory and established C2 communication.\n* **Generic Droppers:** On other hosts, similar initial access vectors were used. On host {{ host.name 9a98cc1d-a7a3-4924-b939-b17b2ec5dbdd }}, a Word document dropped and executed a VBScript, which then used PowerShell and created a scheduled task for persistence. On host {{ host.name 7c9a79a0-c029-4acb-b61c-d5831b409943 }}, an Excel file used {{ process.name certutil.exe }} to decode and execute a payload.\n* **Ransomware Deployment:** The campaign culminated on host {{ host.name 6aece05f-675e-4dc0-b8fa-ba0f1a43d691 }} with the deployment of Sodinokibi (REvil) ransomware. A malicious executable used DLL side-loading to compromise the legitimate Microsoft Defender process, {{ process.name MsMpEng.exe }}, which then executed the ransomware and began encrypting files.', + 'kibana.alert.uuid': '29ceb1fa1482f02a2eb6073991078544e529edfc633a5621b20a93eefbb63083', + 'kibana.alert.rule.execution.uuid': 'c10c51a5-10d2-481d-853a-e7fd5f393b23', + 'kibana.space_ids': ['default'], + 'kibana.alert.attack_discovery.users': [ + { + name: 'elastic', + id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + }, + ], + 'kibana.alert.attack_discovery.entity_summary_markdown': + 'A widespread malware campaign using the compromised account of user {{ user.name 6f53c297-f5cb-48c3-8aff-2e2d7a390169 }} impacted multiple Windows hosts, including {{ host.name 6aece05f-675e-4dc0-b8fa-ba0f1a43d691 }} and {{ host.name 0d7534c9-79f5-46ed-9df9-3dfcff57e5ed }}.', + 'kibana.alert.rule.revision': 1, + }, + }, + ], + }, +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.test.ts new file mode 100644 index 0000000000000..d5fa90008bf6d --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; + +import { getUpdateAttackDiscoveryAlertsQuery } from '.'; +import { mockAuthenticatedUser } from '../../../__mocks__/mock_authenticated_user'; + +interface ScriptParams { + authenticatedUser: { profile_uid: string; username: string }; + kibanaAlertWorkflowStatus?: 'acknowledged' | 'closed' | 'open'; + visibility?: 'not_shared' | 'shared'; +} + +describe('getUpdateAttackDiscoveryAlertsQuery', () => { + const defaultProps = { + authenticatedUser: mockAuthenticatedUser, + ids: ['alert-1', 'alert-2'], + indexPattern: '.adhoc.alerts-security.attack.discovery.alerts-default', + kibanaAlertWorkflowStatus: 'acknowledged' as const, + visibility: 'not_shared' as const, + }; + + let result: estypes.UpdateByQueryRequest; + + beforeEach(() => { + result = getUpdateAttackDiscoveryAlertsQuery(defaultProps); + }); + + it('returns an UpdateByQueryRequest with the correct index', () => { + expect(result.index).toEqual(['.adhoc.alerts-security.attack.discovery.alerts-default']); + }); + + it('returns the correct ids in the query', () => { + expect(result.query).toEqual({ ids: { values: ['alert-1', 'alert-2'] } }); + }); + + it('sets the correct script param for authenticatedUser.profile_uid', () => { + expect( + (result.script as { params: ScriptParams }).params.authenticatedUser.profile_uid + ).toEqual(mockAuthenticatedUser.profile_uid); + }); + + it('sets the correct script param for authenticatedUser.username', () => { + expect((result.script as { params: ScriptParams }).params.authenticatedUser.username).toEqual( + mockAuthenticatedUser.username + ); + }); + + it('sets the correct script param for kibanaAlertWorkflowStatus', () => { + expect((result.script as { params: ScriptParams }).params.kibanaAlertWorkflowStatus).toEqual( + 'acknowledged' + ); + }); + + it('sets the correct script param for visibility', () => { + expect((result.script as { params: ScriptParams }).params.visibility).toEqual('not_shared'); + }); + + describe('visibility param', () => { + it.each([ + { visibility: 'shared' as const }, + { visibility: 'not_shared' as const }, + { visibility: undefined }, + ])('sets visibility param to $visibility', ({ visibility }) => { + const res = getUpdateAttackDiscoveryAlertsQuery({ + ...defaultProps, + visibility, + }); + expect((res.script as { params: ScriptParams }).params.visibility).toEqual(visibility); + }); + }); + + describe('kibanaAlertWorkflowStatus param', () => { + it.each([ + { kibanaAlertWorkflowStatus: 'acknowledged' as const }, + { kibanaAlertWorkflowStatus: 'closed' as const }, + { kibanaAlertWorkflowStatus: 'open' as const }, + { kibanaAlertWorkflowStatus: undefined }, + ])( + 'sets kibanaAlertWorkflowStatus param to $kibanaAlertWorkflowStatus', + ({ kibanaAlertWorkflowStatus }) => { + const res = getUpdateAttackDiscoveryAlertsQuery({ + ...defaultProps, + kibanaAlertWorkflowStatus, + }); + expect((res.script as { params: ScriptParams }).params.kibanaAlertWorkflowStatus).toEqual( + kibanaAlertWorkflowStatus + ); + } + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.ts index cbc08f8bc964a..1fc70acfbddab 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/get_update_attack_discovery_alerts_query/index.ts @@ -7,7 +7,14 @@ import type { estypes } from '@elastic/elasticsearch'; import { AuthenticatedUser } from '@kbn/core-security-common'; -import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils'; +import { + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, + ALERT_WORKFLOW_STATUS, + ALERT_WORKFLOW_STATUS_UPDATED_AT, + TIMESTAMP, +} from '@kbn/rule-data-utils'; import { ALERT_ATTACK_DISCOVERY_USERS } from '../schedules/fields'; @@ -35,8 +42,11 @@ export const getUpdateAttackDiscoveryAlertsQuery = ({ }, script: { source: ` + def now = new Date(); + if (params.kibanaAlertWorkflowStatus != null) { ctx._source['${ALERT_WORKFLOW_STATUS}'] = params.kibanaAlertWorkflowStatus; + ctx._source['${ALERT_WORKFLOW_STATUS_UPDATED_AT}'] = now; } if (params.visibility == 'not_shared') { @@ -47,9 +57,17 @@ export const getUpdateAttackDiscoveryAlertsQuery = ({ user.put('name', params.authenticatedUser.username); ctx._source['${ALERT_ATTACK_DISCOVERY_USERS}'].add(user); + + ctx._source['${TIMESTAMP}'] = now; } else if (params.visibility == 'shared') { ctx._source['${ALERT_ATTACK_DISCOVERY_USERS}'] = new ArrayList(); + + ctx._source['${TIMESTAMP}'] = now; } + + ctx._source['${ALERT_UPDATED_AT}'] = now; + ctx._source['${ALERT_UPDATED_BY_USER_ID}'] = params.authenticatedUser.profile_uid; + ctx._source['${ALERT_UPDATED_BY_USER_NAME}'] = params.authenticatedUser.username; `, params: { authenticatedUser: { diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.test.ts new file mode 100644 index 0000000000000..ddb8609fc3aa7 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.test.ts @@ -0,0 +1,215 @@ +/* + * 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 { + ALERT_RULE_EXECUTION_UUID, + ALERT_START, + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, + ALERT_WORKFLOW_STATUS_UPDATED_AT, +} from '@kbn/rule-data-utils'; +import type { Logger } from '@kbn/core/server'; + +import { transformSearchResponseToAlerts } from '.'; +import { getResponseMock } from '../../../../../__mocks__/attack_discovery_alert_document_response'; +import { ALERT_ATTACK_DISCOVERY_REPLACEMENTS } from '../../../schedules/fields/field_names'; + +// Manual logger mock implementing all Logger methods +const createLoggerMock = (): Logger => + ({ + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + get: jest.fn(() => createLoggerMock()), + isLevelEnabled: jest.fn(() => true), + } as unknown as Logger); + +describe('transformSearchResponseToAlerts', () => { + let logger: Logger; + beforeEach(() => { + logger = createLoggerMock(); + }); + + it('returns alerts from a valid search response', () => { + const response = getResponseMock(); + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.data.length).toBeGreaterThan(0); + }); + + it('skips hits with missing required fields and calls logger.warn', () => { + const response = getResponseMock(); + response.hits.hits[0]._source = undefined; + transformSearchResponseToAlerts({ logger, response }); + + expect(logger.warn).toHaveBeenCalled(); + }); + + it('returns uniqueAlertIdsCount from aggregation if present', () => { + const response = getResponseMock(); + response.aggregations = { + unique_alert_ids_count: { value: 42 }, + } as Record; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.uniqueAlertIdsCount).toBe(42); + }); + + it('returns 0 for uniqueAlertIdsCount if aggregation is missing', () => { + const response = getResponseMock(); + response.aggregations = undefined; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.uniqueAlertIdsCount).toBe(0); + }); + + it('returns sorted connectorNames from aggregation if present', () => { + const response = getResponseMock(); + response.aggregations = { + api_config_name: { + buckets: [ + { key: 'b', doc_count: 1 }, + { key: 'a', doc_count: 2 }, + ], + }, + } as unknown as Record }>; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.connectorNames).toEqual(['a', 'b']); + }); + + it('returns empty connectorNames if aggregation is missing', () => { + const response = getResponseMock(); + response.aggregations = undefined; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.connectorNames).toEqual([]); + }); + + it('returns empty data if all hits are missing required fields', () => { + const response = getResponseMock(); + response.hits.hits = [ + { + _id: '1', + _index: 'foo', + _source: undefined, + }, + { + _id: '2', + _index: 'foo', + _source: undefined, + }, + ]; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.data).toEqual([]); + }); + + it('returns empty data if hits is empty', () => { + const response = getResponseMock(); + response.hits.hits = []; + const result = transformSearchResponseToAlerts({ logger, response }); + + expect(result.data).toEqual([]); + }); + + it('handles invalid/missing dates and falls back to current date for timestamp', () => { + const response = getResponseMock(); + // Set invalid @timestamp and alert_start + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source['@timestamp'] = 'not-a-date'; + response.hits.hits[0]._source[ALERT_START] = 'not-a-date'; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(new Date(result.data[0].timestamp).toString()).not.toBe('Invalid Date'); + expect(result.data[0].alertStart).toBeUndefined(); + }); + + it('handles replacements array with missing uuid/value', () => { + const response = getResponseMock(); + // Only use valid string values for uuid/value to match type + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_ATTACK_DISCOVERY_REPLACEMENTS] = [ + { uuid: 'a', value: 'A' }, + // skip invalid entries, only valid ones allowed by type + ]; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].replacements).toEqual({ a: 'A' }); + }); + + it('uses _id as id if present, otherwise falls back to generationUuid', () => { + const response = getResponseMock(); + const hit = response.hits.hits[0]; + hit._id = 'my-id'; + if (hit._source) { + hit._source[ALERT_RULE_EXECUTION_UUID] = 'gen-uuid'; + } + let result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].id).toBe('my-id'); + + // Simulate fallback: create a new hit with _id set to generationUuid and check + const fallbackHit = { ...hit, _id: 'gen-uuid' }; + response.hits.hits = [fallbackHit]; + result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].id).toBe('gen-uuid'); + }); + + it('correctly transforms ALERT_START field', () => { + const response = getResponseMock(); + const testDate = '2024-01-01T12:00:00.000Z'; + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_START] = testDate; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].alertStart).toBe(testDate); + }); + + it('correctly transforms ALERT_UPDATED_AT field', () => { + const response = getResponseMock(); + const testDate = '2024-02-02T15:30:00.000Z'; + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_UPDATED_AT] = testDate; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].alertUpdatedAt).toBe(testDate); + }); + + it('correctly transforms ALERT_UPDATED_BY_USER_ID field', () => { + const response = getResponseMock(); + const testId = 'user-123'; + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_UPDATED_BY_USER_ID] = testId; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].alertUpdatedByUserId).toBe(testId); + }); + + it('correctly transforms ALERT_UPDATED_BY_USER_NAME field', () => { + const response = getResponseMock(); + const testName = 'testuser'; + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_UPDATED_BY_USER_NAME] = testName; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].alertUpdatedByUserName).toBe(testName); + }); + + it('correctly transforms ALERT_WORKFLOW_STATUS_UPDATED_AT field', () => { + const response = getResponseMock(); + const testDate = '2024-03-03T10:20:30.000Z'; + if (response.hits.hits[0]._source) { + response.hits.hits[0]._source[ALERT_WORKFLOW_STATUS_UPDATED_AT] = testDate; + } + const result = transformSearchResponseToAlerts({ logger, response }); + expect(result.data[0].alertWorkflowStatusUpdatedAt).toBe(testDate); + }); +}); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.ts index e2cccfbee281b..87add65a375da 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_search_response_to_alerts/index.ts @@ -11,7 +11,12 @@ import { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; import { ALERT_RULE_EXECUTION_UUID, ALERT_RULE_UUID, + ALERT_START, + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, ALERT_WORKFLOW_STATUS, + ALERT_WORKFLOW_STATUS_UPDATED_AT, } from '@kbn/rule-data-utils'; import moment from 'moment'; @@ -78,7 +83,18 @@ export const transformSearchResponseToAlerts = ({ return { alertIds: source[ALERT_ATTACK_DISCOVERY_ALERT_IDS] ?? [], // required field alertRuleUuid: source[ALERT_RULE_UUID], + alertStart: moment(source[ALERT_START]).isValid() + ? moment(source[ALERT_START]).toISOString() + : undefined, // optional field + alertUpdatedAt: moment(source[ALERT_UPDATED_AT]).isValid() + ? moment(source[ALERT_UPDATED_AT]).toISOString() + : undefined, // optional field + alertUpdatedByUserId: source[ALERT_UPDATED_BY_USER_ID], + alertUpdatedByUserName: source[ALERT_UPDATED_BY_USER_NAME], alertWorkflowStatus: source[ALERT_WORKFLOW_STATUS], + alertWorkflowStatusUpdatedAt: moment(source[ALERT_WORKFLOW_STATUS_UPDATED_AT]).isValid() + ? moment(source[ALERT_WORKFLOW_STATUS_UPDATED_AT]).toISOString() + : undefined, // optional field connectorId: source[ALERT_ATTACK_DISCOVERY_API_CONFIG].connector_id, // required field connectorName: source[ALERT_ATTACK_DISCOVERY_API_CONFIG].name, detailsMarkdown: source[ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN] ?? '', // required field @@ -121,7 +137,7 @@ export const transformSearchResponseToAlerts = ({ connectorNamesAggregation?.buckets?.flatMap((bucket) => bucket.key ?? []) ?? []; return { - connectorNames: connectorNames.sort(), // mutation + connectorNames: [...connectorNames].sort(), // mutation data, uniqueAlertIdsCount, }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.test.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.test.ts index b3407554da19e..77d4786b2ddbc 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.test.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.test.ts @@ -6,9 +6,9 @@ */ import { - replaceAnonymizedValuesWithOriginalValues, - ATTACK_DISCOVERY_AD_HOC_RULE_TYPE_ID, ATTACK_DISCOVERY_AD_HOC_RULE_ID, + ATTACK_DISCOVERY_AD_HOC_RULE_TYPE_ID, + replaceAnonymizedValuesWithOriginalValues, type CreateAttackDiscoveryAlertsParams, } from '@kbn/elastic-assistant-common'; import { @@ -20,6 +20,15 @@ import { ALERT_URL, ALERT_UUID, } from '@kbn/rule-data-utils'; + +import { + generateAttackDiscoveryAlertHash, + transformToAlertDocuments, + transformToBaseAlertDocument, +} from '.'; +import { mockAttackDiscoveries } from '../../../evaluation/__mocks__/mock_attack_discoveries'; +import { mockAuthenticatedUser } from '../../../../../__mocks__/mock_authenticated_user'; +import { mockCreateAttackDiscoveryAlertsParams } from '../../../../../__mocks__/mock_create_attack_discovery_alerts_params'; import { ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN_WITH_REPLACEMENTS, ALERT_ATTACK_DISCOVERY_ENTITY_SUMMARY_MARKDOWN_WITH_REPLACEMENTS, @@ -32,15 +41,6 @@ import { ALERT_ATTACK_DISCOVERY_ALERTS_CONTEXT_COUNT, } from '../../../schedules/fields/field_names'; -import { - generateAttackDiscoveryAlertHash, - transformToAlertDocuments, - transformToBaseAlertDocument, -} from '.'; -import { mockAuthenticatedUser } from '../../../../../__mocks__/mock_authenticated_user'; -import { mockCreateAttackDiscoveryAlertsParams } from '../../../../../__mocks__/mock_create_attack_discovery_alerts_params'; -import { mockAttackDiscoveries } from '../../../evaluation/__mocks__/mock_attack_discoveries'; - describe('Transform attack discoveries to alert documents', () => { describe('transformToAlertDocuments', () => { const mockNow = new Date('2025-04-24T17:36:25.812Z'); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.ts index fda0f1410d5c9..afa88eeee1db9 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/persistence/transforms/transform_to_alert_documents/index.ts @@ -26,6 +26,7 @@ import { ALERT_RULE_REVISION, ALERT_RULE_TYPE_ID, ALERT_RULE_UUID, + ALERT_START, ALERT_STATUS, ALERT_URL, ALERT_UUID, @@ -210,6 +211,7 @@ export const transformToAlertDocuments = ({ ...baseAlertDocument, '@timestamp': now.toISOString(), + [ALERT_START]: now.toISOString(), [ALERT_ATTACK_DISCOVERY_USER_ID]: authenticatedUser.profile_uid, [ALERT_ATTACK_DISCOVERY_USER_NAME]: authenticatedUser.username, [ALERT_ATTACK_DISCOVERY_USERS]: [ diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/fields/field_map.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/fields/field_map.ts index 06e3a84faafd1..4a1dcdd646ee4 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/fields/field_map.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/fields/field_map.ts @@ -6,6 +6,7 @@ */ import { FieldMap, alertFieldMap } from '@kbn/alerts-as-data-utils'; +import { ALERT_WORKFLOW_STATUS_UPDATED_AT } from '@kbn/rule-data-utils'; import { ALERT_ATTACK_DISCOVERY_ALERTS_CONTEXT_COUNT, ALERT_ATTACK_DISCOVERY_ALERT_IDS, @@ -50,6 +51,11 @@ export const attackDiscoveryAlertFieldMap: FieldMap = { array: false, required: false, }, + [ALERT_WORKFLOW_STATUS_UPDATED_AT]: { + type: 'date', + array: false, + required: false, + }, /** * Attack discovery fields diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/types.ts index b2c5204d7de90..898f2fff40f62 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/types.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/types.ts @@ -9,6 +9,7 @@ import type { estypes } from '@elastic/elasticsearch'; import { RuleExecutorOptions, RuleType, RuleTypeState } from '@kbn/alerting-plugin/server'; import { SecurityAttackDiscoveryAlert } from '@kbn/alerts-as-data-utils'; import { AttackDiscoveryScheduleParams } from '@kbn/elastic-assistant-common'; +import { ALERT_WORKFLOW_STATUS_UPDATED_AT } from '@kbn/rule-data-utils'; import { ALERT_ATTACK_DISCOVERY_API_CONFIG, ALERT_ATTACK_DISCOVERY_REPLACEMENTS, @@ -51,6 +52,7 @@ export type AttackDiscoveryAlertDocument = Omit< id?: string; name: string; }>; + [ALERT_WORKFLOW_STATUS_UPDATED_AT]?: string; }; export type AttackDiscoveryExecutorOptions = RuleExecutorOptions< diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/alerts/8.19.0/index.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/alerts/8.19.0/index.ts index 50938320cc650..46abb347681aa 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/alerts/8.19.0/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/model/alerts/8.19.0/index.ts @@ -5,6 +5,11 @@ * 2.0. */ +import type { + ALERT_UPDATED_AT, + ALERT_UPDATED_BY_USER_ID, + ALERT_UPDATED_BY_USER_NAME, +} from '@kbn/rule-data-utils'; import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; import type { Ancestor8180, @@ -33,6 +38,9 @@ export interface BaseFields8190 extends BaseFields8180 { [ALERT_ORIGINAL_DATA_STREAM_DATASET]?: string; [ALERT_ORIGINAL_DATA_STREAM_NAMESPACE]?: string; [ALERT_ORIGINAL_DATA_STREAM_TYPE]?: string; + [ALERT_UPDATED_AT]?: string; + [ALERT_UPDATED_BY_USER_ID]?: string; + [ALERT_UPDATED_BY_USER_NAME]?: string; } export interface WrappedFields8190 { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.test.tsx new file mode 100644 index 0000000000000..927c3ad999b0b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.test.tsx @@ -0,0 +1,239 @@ +/* + * 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 { AttackDiscoveryAlert } from '@kbn/elastic-assistant-common'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SharedBadge } from '.'; +import { TestProviders } from '../../../../../../../../common/mock'; + +// Local mock for AttackDiscoveryAlert (all required fields for type safety) +const mockAttackDiscoveryAlert: AttackDiscoveryAlert = { + id: 'alert-id-1', + users: [{ id: 'user1' }, { id: 'user2' }], + alertIds: [], + connectorId: 'connector-id', + connectorName: 'Connector', + detailsMarkdown: '', + generationUuid: 'gen-uuid', + summaryMarkdown: '', + timestamp: '', + title: '', +}; + +const mockAttackDiscoveryAlertSingleUser: AttackDiscoveryAlert = { + id: 'alert-id-2', + users: [{ id: 'user1' }], + alertIds: [], + connectorId: 'connector-id', + connectorName: 'Connector', + detailsMarkdown: '', + generationUuid: 'gen-uuid', + summaryMarkdown: '', + timestamp: '', + title: '', +}; + +// Use a minimal object for a non-alert, typed as AttackDiscovery (all required fields) +const mockAttackDiscoveryNotAlert = { + id: 'not-alert-id', + alertIds: [], + connectorId: 'connector-id', + connectorName: 'Connector', + detailsMarkdown: '', + generationUuid: 'gen-uuid', + summaryMarkdown: '', + timestamp: '', + title: '', +}; + +const mockMutateAsync = jest.fn(); +const mockIsAttackDiscoveryAlert = jest.fn(); + +jest.mock('../../../../../../use_attack_discovery_bulk', () => ({ + useAttackDiscoveryBulk: () => ({ mutateAsync: mockMutateAsync }), +})); + +jest.mock('../../../../../../use_kibana_feature_flags', () => ({ + useKibanaFeatureFlags: () => ({ attackDiscoveryAlertsEnabled: true }), +})); + +jest.mock('../../../../../../utils/is_attack_discovery_alert', () => ({ + isAttackDiscoveryAlert: (...args: unknown[]) => mockIsAttackDiscoveryAlert(...args), +})); + +describe('SharedBadge', () => { + const defaultProps = { attackDiscovery: mockAttackDiscoveryAlert }; + + beforeEach(() => { + jest.clearAllMocks(); + mockMutateAsync.mockClear(); + mockIsAttackDiscoveryAlert.mockImplementation( + (obj) => obj === mockAttackDiscoveryAlert || obj === mockAttackDiscoveryAlertSingleUser + ); + }); + + it('opens the popover when the badge is clicked', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + + expect(screen.getByTestId('sharedBadge')).toBeInTheDocument(); + }); + + it('disables the shared option when shared', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + + expect(screen.getByTestId('shared')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('disables the notShared option when shared', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + + expect(screen.getByTestId('notShared')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('calls mutateAsync when changing visibility', async () => { + mockIsAttackDiscoveryAlert.mockReturnValue(true); + + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + await userEvent.click(screen.getByTestId('shared')); + + expect(mockMutateAsync).toHaveBeenCalled(); + }); + + it('renders not shared when only one user', () => { + render( + + + + ); + + expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared'); + }); + + it('renders not shared when not an alert', () => { + mockIsAttackDiscoveryAlert.mockReturnValue(false); + + render( + + + + ); + + expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared'); + }); + + it('renders shared and disables shared option after changing to shared', async () => { + mockIsAttackDiscoveryAlert.mockReturnValue(true); + + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // Click the enabled shared option + await userEvent.click(screen.getByTestId('shared')); + // Re-open the popover to check the disabled state + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // Assert the shared option is disabled + const sharedOption = await screen.findByTestId('shared'); + + expect(sharedOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders shared and disables notShared option after changing to shared', async () => { + mockIsAttackDiscoveryAlert.mockReturnValue(true); + + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // Click the enabled shared option + await userEvent.click(screen.getByTestId('shared')); + // Re-open the popover to check the disabled state + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // Assert the notShared option is disabled + const notSharedOption = await screen.findByTestId('notShared'); + expect(notSharedOption).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders the tooltip when the popover is open and isShared is true', async () => { + mockIsAttackDiscoveryAlert.mockReturnValue(true); + + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + await userEvent.hover(screen.getByTestId('sharedBadgeButton')); + const tooltip = await screen.findByText((content, element) => + content.includes('The visibility of shared') + ); + expect(tooltip).toBeInTheDocument(); + }); + + it('returns the first label when no items are checked', () => { + mockIsAttackDiscoveryAlert.mockReturnValue(false); + + render( + + + + ); + expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared'); + }); + + it('closes the popover when closePopover is called (by toggling badge button)', async () => { + render( + + + + ); + + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // The popover should be open + expect(screen.getByTestId('sharedBadge')).toBeInTheDocument(); + // Click the badge button again to close the popover + await userEvent.click(screen.getByTestId('sharedBadgeButton')); + // Wait for the popover to close + await waitFor(() => { + expect(screen.queryByTestId('sharedBadge')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx index 5efc06b4722ea..20b79f3cb8e6f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx @@ -14,6 +14,7 @@ import { EuiPopover, EuiSelectable, EuiText, + EuiToolTip, useGeneratedHtmlId, } from '@elastic/eui'; import { css } from '@emotion/react'; @@ -21,6 +22,7 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; import { useAttackDiscoveryBulk } from '../../../../../../use_attack_discovery_bulk'; +import { useInvalidateFindAttackDiscoveries } from '../../../../../../use_find_attack_discoveries'; import { isAttackDiscoveryAlert } from '../../../../../../utils/is_attack_discovery_alert'; import { useKibanaFeatureFlags } from '../../../../../../use_kibana_feature_flags'; import * as i18n from './translations'; @@ -41,6 +43,7 @@ interface SharedBadgeOptionData { const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { const { attackDiscoveryAlertsEnabled } = useKibanaFeatureFlags(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const invalidateFindAttackDiscoveries = useInvalidateFindAttackDiscoveries(); const onBadgeButtonClick = useCallback(() => { setIsPopoverOpen((isOpen) => !isOpen); @@ -73,6 +76,7 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { description: i18n.ONLY_VISIBLE_TO_YOU, }, 'data-test-subj': 'notShared', + disabled: isShared, label: i18n.NOT_SHARED, }, { @@ -81,6 +85,7 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { description: i18n.VISIBLE_TO_YOUR_TEAM, }, 'data-test-subj': 'shared', + disabled: isShared, label: i18n.SHARED, }, ]); @@ -155,40 +160,65 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { ids: [attackDiscovery.id], visibility, }); + + // disable all options if the new visibility is 'shared' + if (visibility === 'shared') { + setItems( + newOptions.map((item) => ({ + ...item, + disabled: true, // prevent further changes + })) + ); + } + + invalidateFindAttackDiscoveries(); } }, - [attackDiscovery, attackDiscoveryAlertsEnabled, attackDiscoveryBulk] + [ + attackDiscovery, + attackDiscoveryAlertsEnabled, + attackDiscoveryBulk, + invalidateFindAttackDiscoveries, + ] ); + const allItemsDisabled = useMemo(() => items.every((item) => item.disabled), [items]); + return ( - - - {(list) => ( -
- {list} -
- )} -
-
+ + {(list) => ( +
+ {list} +
+ )} +
+ + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/translations.ts index 57ab5853ee288..ebf3d799ad8b3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/translations.ts @@ -35,6 +35,13 @@ export const SHARED = i18n.translate( } ); +export const THE_VISIBILITY_OF_SHARED = i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.attackDiscoveryPanel.panelHeader.badges.sharedBadge.theVisibilityOfSharedTooltip', + { + defaultMessage: 'The visibility of shared discoveries cannot be changed', + } +); + export const VISIBILITY = i18n.translate( 'xpack.securitySolution.attackDiscovery.results.attackDiscoveryPanel.panelHeader.badges.sharedBadge.visibilityDropdownLabel', { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx index 2c5efbc61ba38..302df6c888c33 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.test.tsx @@ -5,7 +5,16 @@ * 2.0. */ -import { render } from '@testing-library/react'; +// Mocks must be at the top, before imports that use them +jest.mock('../../../../../../common/lib/kibana', () => ({ useKibana: jest.fn() })); +jest.mock('../../../../../../detections/components/alerts_table', () => ({ + DetectionEngineAlertsTable: () =>
, +})); +jest.mock('./ai_for_soc/wrapper', () => ({ + AiForSOCAlertsTab: () =>
, +})); + +import { render, screen } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../../../common/mock'; @@ -14,15 +23,9 @@ import { AlertsTab } from '.'; import { useKibana } from '../../../../../../common/lib/kibana'; import { SECURITY_FEATURE_ID } from '../../../../../../../common'; -jest.mock('../../../../../../common/lib/kibana'); -jest.mock('../../../../../../detections/components/alerts_table', () => ({ - DetectionEngineAlertsTable: () =>
, -})); -jest.mock('./ai_for_soc/wrapper', () => ({ - AiForSOCAlertsTab: () =>
, -})); - describe('AlertsTab', () => { + const defaultProps = { attackDiscovery: mockAttackDiscovery }; + beforeEach(() => { jest.clearAllMocks(); }); @@ -40,14 +43,13 @@ describe('AlertsTab', () => { }, }); - const { getByTestId } = render( + render( - + ); - expect(getByTestId('alertsTab')).toBeInTheDocument(); - expect(getByTestId('detection-engine-alerts-table')).toBeInTheDocument(); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); }); it('renders the alerts tab with AI4DSOC alerts table', () => { @@ -63,13 +65,125 @@ describe('AlertsTab', () => { }, }); - const { getByTestId } = render( + render( + + + + ); + + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + }); + + it('renders DetectionEngineAlertsTable when AIForSOC is false', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + }, + }, + }, + }); + + render( + + + + ); + + expect(screen.getAllByTestId('detection-engine-alerts-table').length).toBe(2); + }); + + it('renders AiForSOCAlertsTab when AIForSOC is true', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: true, + }, + }, + }, + }, + }); + + render( + + + + ); + + expect(screen.getAllByTestId('ai4dsoc-alerts-table').length).toBe(2); + }); + + it('renders with replacements mapping alertIds', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + }, + }, + }, + }); + const replacements = { + [mockAttackDiscovery.alertIds[0]]: 'replacement-id-1', + [mockAttackDiscovery.alertIds[1]]: 'replacement-id-2', + }; + render( + + + + ); + + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + }); + + it('renders with replacements missing mapping for some alertIds', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + }, + }, + }, + }); + const replacements = { + [mockAttackDiscovery.alertIds[0]]: 'replacement-id-1', + }; + render( - + ); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + }); - expect(getByTestId('alertsTab')).toBeInTheDocument(); - expect(getByTestId('ai4dsoc-alerts-table')).toBeInTheDocument(); + it('renders with empty alertIds', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + capabilities: { + [SECURITY_FEATURE_ID]: { + configurations: false, + }, + }, + }, + }, + }); + const emptyAttackDiscovery = { ...mockAttackDiscovery, alertIds: [] }; + render( + + + + ); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx index bb767cb81a071..dd441852bc923 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/alerts_tab/index.tsx @@ -5,15 +5,16 @@ * 2.0. */ -import React, { useMemo } from 'react'; import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; import { SECURITY_SOLUTION_RULE_TYPE_IDS } from '@kbn/securitysolution-rules'; +import React, { useMemo } from 'react'; import { TableId } from '@kbn/securitysolution-data-table'; import { AiForSOCAlertsTab } from './ai_for_soc/wrapper'; import { useKibana } from '../../../../../../common/lib/kibana'; import { SECURITY_FEATURE_ID } from '../../../../../../../common'; import { DetectionEngineAlertsTable } from '../../../../../../detections/components/alerts_table'; +import { getColumns } from '../../../../../../detections/configurations/security_solution_detections/columns'; interface Props { attackDiscovery: AttackDiscovery; @@ -49,6 +50,20 @@ const AlertsTabComponent: React.FC = ({ attackDiscovery, replacements }) const id = useMemo(() => `attack-discovery-alerts-${attackDiscovery.id}`, [attackDiscovery.id]); + // add workflow_status as the 2nd column in the table: + const columns = useMemo(() => { + const defaultColumns = getColumns(); + + return [ + ...defaultColumns.slice(0, 1), + { + columnHeaderType: 'not-filtered', + id: 'kibana.alert.workflow_status', + }, + ...defaultColumns.slice(1), + ]; + }, []); + return (
{AIForSOC ? ( @@ -58,6 +73,7 @@ const AlertsTabComponent: React.FC = ({ attackDiscovery, replacements }) ) : (
{ - beforeEach(() => { + const defaultProps = { + attackDiscovery: mockAttackDiscovery, + replacements: undefined, + showAnonymized: false, + }; + + const renderTabs = (props = {}) => render( - + ); - }); it('renders the attack discovery tab', () => { - const attackDiscoveryTab = screen.getByTestId('attackDiscoveryTab'); + renderTabs(); - expect(attackDiscoveryTab).toBeInTheDocument(); + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); }); it("renders the alerts tab when it's selected", () => { + renderTabs(); const alertsTabButton = screen.getByText('Alerts'); fireEvent.click(alertsTabButton); - const alertsTab = screen.getByTestId('alertsTab'); - expect(alertsTab).toBeInTheDocument(); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + }); + + it('renders with replacements', () => { + renderTabs({ replacements: { foo: 'bar' } }); + + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); + }); + + it('renders with showAnonymized true', () => { + renderTabs({ showAnonymized: true }); + + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); + }); + + it('resets the selected tab when the attackDiscovery changes', () => { + const { rerender } = render( + + + + ); + + fireEvent.click(screen.getByText('Alerts')); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); + }); + + it('renders the correct tab content when switching tabs', () => { + renderTabs(); + + fireEvent.click(screen.getByText('Alerts')); + expect(screen.getByTestId('alertsTab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Attack discovery')); + expect(screen.getByTestId('attackDiscoveryTab')).toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx index c11851fd15778..da49e0e62bfec 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/tabs/index.tsx @@ -7,7 +7,7 @@ import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common'; import { EuiTabs, EuiTab } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { getTabs } from './get_tabs'; @@ -35,6 +35,12 @@ const TabsComponent: React.FC = ({ const onSelectedTabChanged = useCallback((id: string) => setSelectedTabId(id), []); + useEffect(() => { + // Reset to the first tab if the attack discovery changes, + // because (for example) the workflow status of the alerts may have changed: + setSelectedTabId(tabs[0].id); + }, [attackDiscovery, tabs]); + return ( <> diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx index 5bf22f4c816e8..aeddb01a07b70 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.test.tsx @@ -8,43 +8,145 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../../common/mock'; +import { TestProviders } from '../../../../common/mock/test_providers'; import { mockAttackDiscovery } from '../../mock/mock_attack_discovery'; import { TakeAction } from '.'; +// Mocks for hooks and dependencies +jest.mock('../../use_kibana_feature_flags', () => ({ + useKibanaFeatureFlags: () => ({ attackDiscoveryAlertsEnabled: true }), +})); +jest.mock('../../use_attack_discovery_bulk', () => ({ + useAttackDiscoveryBulk: () => ({ mutateAsync: jest.fn().mockResolvedValue({}) }), +})); +jest.mock('./use_update_alerts_status', () => ({ + useUpdateAlertsStatus: () => ({ mutateAsync: jest.fn().mockResolvedValue({}) }), +})); +jest.mock('./use_add_to_case', () => ({ + useAddToNewCase: () => ({ disabled: false, onAddToNewCase: jest.fn() }), +})); +jest.mock('./use_add_to_existing_case', () => ({ + useAddToExistingCase: () => ({ onAddToExistingCase: jest.fn() }), +})); +jest.mock('../attack_discovery_panel/view_in_ai_assistant/use_view_in_ai_assistant', () => ({ + useViewInAiAssistant: () => ({ showAssistantOverlay: jest.fn(), disabled: false }), +})); +jest.mock('../../utils/is_attack_discovery_alert', () => ({ + isAttackDiscoveryAlert: (ad: { alertWorkflowStatus?: string }) => + ad && ad.alertWorkflowStatus !== undefined, +})); + +const defaultProps = { + attackDiscoveries: [mockAttackDiscovery], + setSelectedAttackDiscoveries: jest.fn(), +}; + describe('TakeAction', () => { beforeEach(() => { jest.clearAllMocks(); + }); + it('renders the Add to new case action', () => { render( - + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + expect(screen.getByTestId('addToCase')).toBeInTheDocument(); + }); - const takeActionButtons = screen.getAllByTestId('takeActionPopoverButton'); - - fireEvent.click(takeActionButtons[0]); // open the popover + it('renders the Add to existing case action', () => { + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + expect(screen.getByTestId('addToExistingCase')).toBeInTheDocument(); }); - it('renders the Add to new case action', () => { - const addToCase = screen.getByTestId('addToCase'); + it('renders the View in AI Assistant action', () => { + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + expect(screen.getByTestId('viewInAiAssistant')).toBeInTheDocument(); + }); - expect(addToCase).toBeInTheDocument(); + it('does not render View in AI Assistant when multiple discoveries', () => { + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + expect(screen.queryByTestId('viewInAiAssistant')).toBeNull(); }); - it('renders the Add to existing case action', () => { - const addToCase = screen.getByTestId('addToExistingCase'); + it('renders mark as open/acknowledged/closed actions when alertWorkflowStatus is set', () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'acknowledged' }; + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + expect(screen.getByTestId('markAsOpen')).toBeInTheDocument(); + expect(screen.getByTestId('markAsClosed')).toBeInTheDocument(); + }); - expect(addToCase).toBeInTheDocument(); + it('shows UpdateAlertsModal when mark as closed is clicked', async () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' }; + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + fireEvent.click(screen.getByTestId('markAsClosed')); + expect(await screen.findByTestId('confirmModal')).toBeInTheDocument(); }); - it('renders the View in AI Assistant action', () => { - const addToCase = screen.getByTestId('viewInAiAssistant'); + it('calls setSelectedAttackDiscoveries and closes modal on confirm', async () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' }; + const setSelectedAttackDiscoveries = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + fireEvent.click(screen.getByTestId('markAsClosed')); + expect(await screen.findByTestId('confirmModal')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('markDiscoveriesOnly')); + // Wait for setSelectedAttackDiscoveries to be called + await screen.findByTestId('takeActionPopoverButton'); + expect(setSelectedAttackDiscoveries).toHaveBeenCalledWith({}); + }); - expect(addToCase).toBeInTheDocument(); + it('closes modal on cancel', async () => { + const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' }; + render( + + + + ); + fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]); + fireEvent.click(screen.getByTestId('markAsClosed')); + expect(await screen.findByTestId('confirmModal')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('cancel')); + // Wait for modal to close + await screen.findByTestId('takeActionPopoverButton'); + expect(screen.queryByTestId('confirmModal')).toBeNull(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx index b9659e58a6713..0332a960ed985 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/index.tsx @@ -26,8 +26,10 @@ import { useViewInAiAssistant } from '../attack_discovery_panel/view_in_ai_assis import { APP_ID } from '../../../../../common'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; +import { UpdateAlertsModal } from './update_alerts_modal'; import { useAttackDiscoveryBulk } from '../../use_attack_discovery_bulk'; import { useKibanaFeatureFlags } from '../../use_kibana_feature_flags'; +import { useUpdateAlertsStatus } from './use_update_alerts_status'; import { isAttackDiscoveryAlert } from '../../utils/is_attack_discovery_alert'; interface Props { @@ -47,6 +49,10 @@ const TakeActionComponent: React.FC = ({ replacements, setSelectedAttackDiscoveries, }) => { + const [pendingAction, setPendingAction] = useState<'open' | 'acknowledged' | 'closed' | null>( + null + ); + const { services: { cases }, } = useKibana(); @@ -101,67 +107,26 @@ const TakeActionComponent: React.FC = ({ ); const { mutateAsync: attackDiscoveryBulk } = useAttackDiscoveryBulk(); + const { mutateAsync: updateAlertStatus } = useUpdateAlertsStatus(); // click handlers for the popover actions: const onClickMarkAsAcknowledged = useCallback(async () => { closePopover(); - await attackDiscoveryBulk({ - attackDiscoveryAlertsEnabled, - ids: attackDiscoveryIds, - kibanaAlertWorkflowStatus: 'acknowledged', - }); - - setSelectedAttackDiscoveries({}); - refetchFindAttackDiscoveries?.(); - }, [ - attackDiscoveryAlertsEnabled, - attackDiscoveryBulk, - attackDiscoveryIds, - closePopover, - refetchFindAttackDiscoveries, - setSelectedAttackDiscoveries, - ]); + setPendingAction('acknowledged'); + }, [closePopover]); const onClickMarkAsClosed = useCallback(async () => { closePopover(); - await attackDiscoveryBulk({ - attackDiscoveryAlertsEnabled, - ids: attackDiscoveryIds, - kibanaAlertWorkflowStatus: 'closed', - }); - - refetchFindAttackDiscoveries?.(); - setSelectedAttackDiscoveries({}); - }, [ - attackDiscoveryAlertsEnabled, - attackDiscoveryBulk, - attackDiscoveryIds, - closePopover, - refetchFindAttackDiscoveries, - setSelectedAttackDiscoveries, - ]); + setPendingAction('closed'); + }, [closePopover]); const onClickMarkAsOpen = useCallback(async () => { closePopover(); - await attackDiscoveryBulk({ - attackDiscoveryAlertsEnabled, - ids: attackDiscoveryIds, - kibanaAlertWorkflowStatus: 'open', - }); - - setSelectedAttackDiscoveries({}); - refetchFindAttackDiscoveries?.(); - }, [ - attackDiscoveryAlertsEnabled, - attackDiscoveryBulk, - attackDiscoveryIds, - closePopover, - refetchFindAttackDiscoveries, - setSelectedAttackDiscoveries, - ]); + setPendingAction('open'); + }, [closePopover]); const onClickAddToNewCase = useCallback(async () => { closePopover(); @@ -322,18 +287,69 @@ const TakeActionComponent: React.FC = ({ onClickMarkAsOpen, ]); + const onConfirm = useCallback( + async (updateAlerts: boolean) => { + if (pendingAction !== null) { + setPendingAction(null); + + await attackDiscoveryBulk({ + attackDiscoveryAlertsEnabled, + ids: attackDiscoveryIds, + kibanaAlertWorkflowStatus: pendingAction, + }); + + if (updateAlerts && alertIds.length > 0) { + await updateAlertStatus({ + ids: alertIds, + kibanaAlertWorkflowStatus: pendingAction, + }); + } + + setSelectedAttackDiscoveries({}); + refetchFindAttackDiscoveries?.(); + } + }, + [ + alertIds, + attackDiscoveryAlertsEnabled, + attackDiscoveryBulk, + attackDiscoveryIds, + pendingAction, + refetchFindAttackDiscoveries, + setSelectedAttackDiscoveries, + updateAlertStatus, + ] + ); + + const onCloseOrCancel = useCallback(() => { + setPendingAction(null); + }, []); + return ( - - - + <> + + + + + {pendingAction != null && ( + + )} + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.test.tsx new file mode 100644 index 0000000000000..2b312ca98cf4d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { UpdateAlertsModal } from '.'; + +// Mock EUI hooks and components as needed (see history/index.test.tsx for style) +jest.mock('@elastic/eui', () => { + const actual = jest.requireActual('@elastic/eui'); + return { + ...actual, + useEuiTheme: () => ({ euiTheme: { size: { m: '8px', xxxl: '32px' } } }), + useGeneratedHtmlId: jest.fn(() => 'generated-id'), + }; +}); + +const defaultProps = { + alertsCount: 2, + attackDiscoveriesCount: 3, + onCancel: jest.fn(), + onClose: jest.fn(), + onConfirm: jest.fn(), + workflowStatus: 'acknowledged' as const, +}; + +describe('UpdateAlertsModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the modal body with correct counts', () => { + render(); + + expect(screen.getByTestId('modalBody')).toHaveTextContent( + 'Update 2 alerts associated with 3 attack discoveries?' + ); + }); + + it('calls onCancel when cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('cancel')); + + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('calls onConfirm(false) when markDiscoveriesOnly is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('markDiscoveriesOnly')); + + expect(defaultProps.onConfirm).toHaveBeenCalledWith(false); + }); + + it('calls onConfirm(true) when markAlertsAndDiscoveries is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('markAlertsAndDiscoveries')); + + expect(defaultProps.onConfirm).toHaveBeenCalledWith(true); + }); + + it.each([ + { workflowStatus: 'open', expected: 'Mark alerts & discoveries as open' }, + { workflowStatus: 'acknowledged', expected: 'Mark alerts & discoveries as acknowledged' }, + { workflowStatus: 'closed', expected: 'Mark alerts & discoveries as closed' }, + ])( + 'renders the correct button text for workflowStatus: $workflowStatus', + ({ workflowStatus, expected }) => { + render( + + ); + expect(screen.getByTestId('markAlertsAndDiscoveries')).toHaveTextContent(expected); + } + ); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.tsx new file mode 100644 index 0000000000000..ed6111bb6dce5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/index.tsx @@ -0,0 +1,146 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { useCallback, useMemo } from 'react'; + +import * as i18n from './translations'; + +interface Props { + alertsCount: number; + attackDiscoveriesCount: number; + onCancel: () => void; + onClose: () => void; + onConfirm: (updateAlerts: boolean) => void; + workflowStatus: 'open' | 'acknowledged' | 'closed'; +} + +const UpdateAlertsModalComponent: React.FC = ({ + alertsCount, + attackDiscoveriesCount, + onCancel, + onClose, + onConfirm, + workflowStatus, +}) => { + const { euiTheme } = useEuiTheme(); + const modalId = useGeneratedHtmlId({ prefix: 'confirmModal' }); + const titleId = useGeneratedHtmlId(); + + const markDiscoveriesOnly = useCallback(() => { + onConfirm(false); + }, [onConfirm]); + + const markAlertsAndDiscoveries = useCallback(() => { + onConfirm(true); + }, [onConfirm]); + + const confirmButtons = useMemo( + () => ( + + + + {i18n.MARK_DISCOVERIES_ONLY({ + attackDiscoveriesCount, + workflowStatus, + })} + + + + + + {i18n.MARK_ALERTS_AND_DISCOVERIES({ + alertsCount, + attackDiscoveriesCount, + workflowStatus, + })} + + + + ), + [ + alertsCount, + attackDiscoveriesCount, + euiTheme.size.m, + markAlertsAndDiscoveries, + markDiscoveriesOnly, + workflowStatus, + ] + ); + + return ( + + + {i18n.UPDATE_ALERTS} + + + +
+ {i18n.UPDATE_ALERTS_ASSOCIATED({ + alertsCount, + attackDiscoveriesCount, + })} +
+
+ + + + + + {i18n.CANCEL} + + + + {confirmButtons} + + +
+ ); +}; + +UpdateAlertsModalComponent.displayName = 'UpdateAlertsModal'; + +export const UpdateAlertsModal = React.memo(UpdateAlertsModalComponent); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/translations.ts new file mode 100644 index 0000000000000..5f1723fa76134 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/update_alerts_modal/translations.ts @@ -0,0 +1,85 @@ +/* + * 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 CANCEL = i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.confirmModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } +); + +export const MARK_ALERTS_AND_DISCOVERIES = ({ + alertsCount, + attackDiscoveriesCount, + workflowStatus, +}: { + alertsCount: number; + attackDiscoveriesCount: number; + workflowStatus: 'open' | 'acknowledged' | 'closed'; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.confirmModal.markDiscoveriesAndAlertsButtonLabel', + { + defaultMessage: + 'Mark {alertsCount, plural, =1 {alert} other {alerts}} & {attackDiscoveriesCount, plural, =1 {discovery} other {discoveries}} as {workflowStatus}', + values: { + alertsCount, + attackDiscoveriesCount, + workflowStatus, + }, + } + ); +}; + +export const MARK_DISCOVERIES_ONLY = ({ + attackDiscoveriesCount, + workflowStatus, +}: { + attackDiscoveriesCount: number; + workflowStatus: 'open' | 'acknowledged' | 'closed'; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.confirmModal.markDiscoveriesOnlyButtonLabel', + { + defaultMessage: + 'Mark {attackDiscoveriesCount, plural, =1 {discovery} other {discoveries}} as {workflowStatus}', + values: { + attackDiscoveriesCount, + workflowStatus, + }, + } + ); +}; + +export const UPDATE_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.confirmModal.updateAlertsTitle', + { + defaultMessage: 'Update alerts?', + } +); + +export const UPDATE_ALERTS_ASSOCIATED = ({ + alertsCount, + attackDiscoveriesCount, +}: { + alertsCount: number; + attackDiscoveriesCount: number; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.confirmModal.updateAlertsAssociatedModalBody', + { + defaultMessage: + 'Update {alertsCount} alerts associated with {attackDiscoveriesCount, plural, =1 {the attack discovery} other {{attackDiscoveriesCount} attack discoveries}}?', + values: { + alertsCount, + attackDiscoveriesCount, + }, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.test.tsx new file mode 100644 index 0000000000000..cf68a96e10309 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useUpdateAlertsStatus } from '.'; + +import * as updateAlertsModule from '../../../../../common/components/toolbar/bulk_actions/update_alerts'; +import * as appToastsModule from '../../../../../common/hooks/use_app_toasts'; + +jest.mock('../../../../../common/components/toolbar/bulk_actions/update_alerts'); +jest.mock('../../../../../common/hooks/use_app_toasts'); +jest.mock('../../../use_find_attack_discoveries', () => ({ + useInvalidateFindAttackDiscoveries: () => jest.fn(), +})); +jest.mock('./translations', () => ({ + SUCCESSFULLY_MARKED_ALERTS: jest.fn(() => 'success'), + UPDATED_ALERTS_WITH_VERSION_CONFLICTS: jest.fn(() => 'version conflict'), + PARTIALLY_UPDATED_ALERTS: jest.fn(() => 'partial'), + ERROR_UPDATING_ALERTS: 'error', +})); + +describe('useUpdateAlertsStatus', () => { + let addSuccess: jest.Mock; + let addError: jest.Mock; + let addWarning: jest.Mock; + let queryClient: QueryClient; + + beforeEach(() => { + addSuccess = jest.fn(); + addError = jest.fn(); + addWarning = jest.fn(); + jest.spyOn(appToastsModule, 'useAppToasts').mockReturnValue({ + addError, + addSuccess, + addWarning, + addInfo: jest.fn(), + remove: jest.fn(), + api: { + add: jest.fn(), + addDanger: jest.fn(), + addError: jest.fn(), + addInfo: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + get$: jest.fn(), + remove: jest.fn(), + }, + }); + (updateAlertsModule.updateAlertStatus as jest.Mock).mockReset(); + queryClient = new QueryClient(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('returns a mutation that calls updateAlertStatus and addSuccess on full update', async () => { + (updateAlertsModule.updateAlertStatus as jest.Mock).mockResolvedValue({ + updated: 2, + version_conflicts: 0, + }); + + const { result } = renderHook(() => useUpdateAlertsStatus(), { wrapper }); + + await act(async () => { + result.current.mutate({ ids: ['1', '2'], kibanaAlertWorkflowStatus: 'open' }); + }); + + expect(addSuccess).toHaveBeenCalledWith('success'); + }); + + it('returns a mutation that calls addWarning on version conflict', async () => { + (updateAlertsModule.updateAlertStatus as jest.Mock).mockResolvedValue({ + updated: 1, + version_conflicts: 1, + }); + + const { result } = renderHook(() => useUpdateAlertsStatus(), { wrapper }); + + await act(async () => { + result.current.mutate({ ids: ['1', '2'], kibanaAlertWorkflowStatus: 'closed' }); + }); + + expect(addWarning).toHaveBeenCalledWith('version conflict'); + }); + + it('returns a mutation that calls addWarning on partial update with no version conflict', async () => { + (updateAlertsModule.updateAlertStatus as jest.Mock).mockResolvedValue({ + updated: 1, + version_conflicts: 0, + }); + + const { result } = renderHook(() => useUpdateAlertsStatus(), { wrapper }); + + await act(async () => { + result.current.mutate({ ids: ['1', '2'], kibanaAlertWorkflowStatus: 'acknowledged' }); + }); + + expect(addWarning).toHaveBeenCalledWith('partial'); + }); + + it('returns a mutation that calls addError on error', async () => { + (updateAlertsModule.updateAlertStatus as jest.Mock).mockRejectedValue(new Error('fail')); + + const { result } = renderHook(() => useUpdateAlertsStatus(), { wrapper }); + + await act(async () => { + result.current.mutate({ ids: ['1', '2'], kibanaAlertWorkflowStatus: 'open' }); + }); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.tsx new file mode 100644 index 0000000000000..afe621a802a11 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/index.tsx @@ -0,0 +1,74 @@ +/* + * 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 { UpdateByQueryResponse } from '@elastic/elasticsearch/lib/api/types'; +import { useMutation } from '@tanstack/react-query'; + +import { updateAlertStatus } from '../../../../../common/components/toolbar/bulk_actions/update_alerts'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import * as i18n from './translations'; +import { useInvalidateFindAttackDiscoveries } from '../../../use_find_attack_discoveries'; + +interface UpdatedAlertsResponse { + updated: number; + version_conflicts: UpdateByQueryResponse['version_conflicts']; +} + +interface UpdateAlertsStatusParams { + ids: string[]; + kibanaAlertWorkflowStatus: 'open' | 'acknowledged' | 'closed'; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} + +export const useUpdateAlertsStatus = () => { + const { addError, addSuccess, addWarning } = useAppToasts(); + + const invalidateFindAttackDiscoveries = useInvalidateFindAttackDiscoveries(); + + return useMutation( + ({ ids, kibanaAlertWorkflowStatus }) => + updateAlertStatus({ + status: kibanaAlertWorkflowStatus, + signalIds: ids, + }), + { + onSuccess: (data: UpdatedAlertsResponse, variables: UpdateAlertsStatusParams) => { + const { ids, kibanaAlertWorkflowStatus } = variables; + const { updated, version_conflicts } = data; // eslint-disable-line @typescript-eslint/naming-convention + + const alertsCount = ids.length; // total alerts + const allAlertsUpdated = updated === alertsCount; + + invalidateFindAttackDiscoveries(); + + if (allAlertsUpdated) { + addSuccess(i18n.SUCCESSFULLY_MARKED_ALERTS({ updated, kibanaAlertWorkflowStatus })); + } else if (version_conflicts != null && version_conflicts > 0) { + addWarning( + i18n.UPDATED_ALERTS_WITH_VERSION_CONFLICTS({ + kibanaAlertWorkflowStatus, + updated, + versionConflicts: version_conflicts, + }) + ); + } else { + addWarning( + i18n.PARTIALLY_UPDATED_ALERTS({ + alertsCount, + kibanaAlertWorkflowStatus, + updated, + }) + ); + } + }, + onError: (error) => { + addError(error, { title: i18n.ERROR_UPDATING_ALERTS }); + }, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/translations.ts new file mode 100644 index 0000000000000..9493c95fc155a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/take_action/use_update_alerts_status/translations.ts @@ -0,0 +1,82 @@ +/* + * 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 ERROR_UPDATING_ALERTS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.useUpdateAlerts.errorUpdatingAlertsTitle', + { + defaultMessage: 'Unable to update alerts', + } +); + +export const PARTIALLY_UPDATED_ALERTS = ({ + alertsCount, + kibanaAlertWorkflowStatus, + updated, +}: { + alertsCount: number; + kibanaAlertWorkflowStatus: 'open' | 'acknowledged' | 'closed'; + updated: number; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.useUpdateAlerts.partiallyUpdatedAlertsMessage', + { + defaultMessage: + 'Marked {updated} out of {alertsCount} alerts as {kibanaAlertWorkflowStatus}. {notUpdated, plural, =1 {1 alert could not be updated.} other {{notUpdated} alerts could not be updated.}}', + values: { + alertsCount, + kibanaAlertWorkflowStatus, + notUpdated: alertsCount - updated, + updated, + }, + } + ); +}; + +export const SUCCESSFULLY_MARKED_ALERTS = ({ + kibanaAlertWorkflowStatus, + updated, +}: { + kibanaAlertWorkflowStatus: 'open' | 'acknowledged' | 'closed'; + updated: number; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.useUpdateAlerts.successfullyMarkedAlertsMessage', + { + defaultMessage: + 'Marked {updated, plural, =1 {1 alert} other {{updated} alerts}} as {kibanaAlertWorkflowStatus}', + values: { + updated, + kibanaAlertWorkflowStatus, + }, + } + ); +}; + +export const UPDATED_ALERTS_WITH_VERSION_CONFLICTS = ({ + kibanaAlertWorkflowStatus, + updated, + versionConflicts, +}: { + kibanaAlertWorkflowStatus: 'open' | 'acknowledged' | 'closed'; + updated: number; + versionConflicts: number; +}) => { + return i18n.translate( + 'xpack.securitySolution.attackDiscovery.results.takeAction.useUpdateAlerts.updatedAlertsWithVersionConflictsMessage', + { + defaultMessage: + '{updated, plural, =0 {No alerts were marked as {kibanaAlertWorkflowStatus}} =1 {Marked 1 alert as {kibanaAlertWorkflowStatus}} other {Marked {updated} alerts as {kibanaAlertWorkflowStatus}}} {versionConflicts, plural, =1 {1 alert could not be updated due to a version conflict.} other {{versionConflicts} alerts could not be updated due to version conflicts.}}', + values: { + kibanaAlertWorkflowStatus, + updated, + versionConflicts, + }, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.test.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.test.ts new file mode 100644 index 0000000000000..8e1a7b6266dcb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.test.ts @@ -0,0 +1,124 @@ +/* + * 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 { renderHook, act } from '@testing-library/react'; +import { useAttackDiscoveryBulk } from '.'; +import { TestProviders } from '../../../common/mock'; +import * as featureFlagsModule from '../use_kibana_feature_flags'; +import * as appToastsModule from '../../../common/hooks/use_app_toasts'; +import * as invalidateModule from '../use_find_attack_discoveries'; +import * as kibanaModule from '../../../common/lib/kibana'; + +jest.mock('../use_kibana_feature_flags'); +jest.mock('../../../common/hooks/use_app_toasts'); +jest.mock('../use_find_attack_discoveries'); +jest.mock('../../../common/lib/kibana'); + +const mockAddSuccess = jest.fn(); +const mockAddError = jest.fn(); +const mockInvalidate = jest.fn(); +const mockHttpPost = jest.fn(); + +const defaultIds = ['id1', 'id2']; +const defaultStatus = 'closed'; +const defaultVisibility = 'shared'; + +const getHook = () => + renderHook(() => useAttackDiscoveryBulk(), { + wrapper: TestProviders, + }); + +describe('useAttackDiscoveryBulk', () => { + beforeEach(() => { + jest.clearAllMocks(); + (featureFlagsModule.useKibanaFeatureFlags as jest.Mock).mockReturnValue({ + attackDiscoveryAlertsEnabled: true, + }); + (appToastsModule.useAppToasts as jest.Mock).mockReturnValue({ + addSuccess: mockAddSuccess, + addError: mockAddError, + }); + (invalidateModule.useInvalidateFindAttackDiscoveries as jest.Mock).mockReturnValue( + mockInvalidate + ); + (kibanaModule.KibanaServices.get as jest.Mock).mockReturnValue({ + http: { post: mockHttpPost }, + }); + }); + + it('returns a mutation that succeeds and calls addSuccess', async () => { + mockHttpPost.mockResolvedValueOnce({ data: [{ id: 'foo' }] }); + const { result } = getHook(); + + await act(async () => { + await result.current.mutateAsync({ + attackDiscoveryAlertsEnabled: true, + ids: defaultIds, + kibanaAlertWorkflowStatus: defaultStatus, + visibility: defaultVisibility, + }); + }); + + expect(mockAddSuccess).toHaveBeenCalled(); + }); + + it('returns a mutation that calls addError on error', async () => { + mockHttpPost.mockRejectedValueOnce(new Error('fail')); + const { result } = getHook(); + + await act(async () => { + try { + await result.current.mutateAsync({ + attackDiscoveryAlertsEnabled: true, + ids: defaultIds, + kibanaAlertWorkflowStatus: defaultStatus, + visibility: defaultVisibility, + }); + } catch (e) { + // expected error + } + }); + + expect(mockAddError).toHaveBeenCalled(); + }); + + it('does not call addSuccess or addError if feature flag is disabled', async () => { + (featureFlagsModule.useKibanaFeatureFlags as jest.Mock).mockReturnValue({ + attackDiscoveryAlertsEnabled: false, + }); + mockHttpPost.mockResolvedValueOnce({ data: [] }); + const { result } = getHook(); + + await act(async () => { + await result.current.mutateAsync({ + attackDiscoveryAlertsEnabled: false, + ids: defaultIds, + kibanaAlertWorkflowStatus: defaultStatus, + visibility: defaultVisibility, + }); + }); + + expect(mockAddSuccess).not.toHaveBeenCalled(); + expect(mockAddError).not.toHaveBeenCalled(); + }); + + it('calls invalidateFindAttackDiscoveries on success if status is set', async () => { + mockHttpPost.mockResolvedValueOnce({ data: [{ id: 'foo' }] }); + const { result } = getHook(); + + await act(async () => { + await result.current.mutateAsync({ + attackDiscoveryAlertsEnabled: true, + ids: defaultIds, + kibanaAlertWorkflowStatus: defaultStatus, + visibility: defaultVisibility, + }); + }); + + expect(mockInvalidate).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.ts index 6564e484419b8..e88a661a805cd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/index.ts @@ -73,10 +73,17 @@ export const useAttackDiscoveryBulk = () => { }), { mutationKey: ATTACK_DISCOVERY_BULK_MUTATION_KEY, - onSuccess: () => { - if (attackDiscoveryAlertsEnabled) { + onSuccess: (_: PostAttackDiscoveryBulkResponse, variables: AttackDiscoveryBulkParams) => { + const { ids, kibanaAlertWorkflowStatus } = variables; + + if (attackDiscoveryAlertsEnabled && kibanaAlertWorkflowStatus != null) { invalidateFindAttackDiscoveries(); - addSuccess(i18n.ATTACK_DISCOVERIES_SUCCESSFULLY_UPDATED); + addSuccess( + i18n.MARKED_ATTACK_DISCOVERIES({ + attackDiscoveries: ids.length, + kibanaAlertWorkflowStatus, + }) + ); } }, onError: (error) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/translations.ts index 0c157d3209b1b..9bfd8ebe4f7e3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery_bulk/translations.ts @@ -7,12 +7,21 @@ import { i18n } from '@kbn/i18n'; -export const ATTACK_DISCOVERIES_SUCCESSFULLY_UPDATED = i18n.translate( - 'xpack.securitySolution.attackDiscovery.useAttackDiscoveryBulk.attackDiscoveriesSuccessfullyUpdatedToast', - { - defaultMessage: 'Attack discoveries successfully updated', - } -); +export const MARKED_ATTACK_DISCOVERIES = ({ + attackDiscoveries, + kibanaAlertWorkflowStatus, +}: { + attackDiscoveries: number; + kibanaAlertWorkflowStatus: 'open' | 'acknowledged' | 'closed'; +}) => + i18n.translate( + 'xpack.securitySolution.attackDiscovery.useAttackDiscoveryBulk.markedAttackDiscoveriesToast', + { + defaultMessage: + 'Marked {attackDiscoveries, plural, one {attack discovery} other {# attack discoveries}} as {kibanaAlertWorkflowStatus}', + values: { attackDiscoveries, kibanaAlertWorkflowStatus }, + } + ); export const ERROR_UPDATING_ATTACK_DISCOVERIES = i18n.translate( 'xpack.securitySolution.attackDiscovery.useAttackDiscoveryBulk.errorUpdatingAttackDiscoveriesErrorToast',