[Attack discovery] Optionally update the kibana.alert.workflow_status of alerts associated with Attack discoveries (#225029)

## [Attack discovery] Optionally update the `kibana.alert.workflow_status` of alerts associated with Attack discoveries

This PR introduces a new UI to optionally update the `kibana.alert.workflow_status` of alerts associated with Attack discoveries, as illustrated by the animated gif below:

![update_attack_discovery_alerts](https://github.com/user-attachments/assets/5974fc4e-50b4-43e9-9939-885f9577c153)

Users may (optionally) update all alerts for a single attack discovery, or just update the discovery itself:

![update_one_attack_discovery](https://github.com/user-attachments/assets/fd774ae7-976d-4649-a97d-b9bae8d359ad)

When multiple attack discoveries are selected, users may also (optionally) update the status of all their related alerts via the bulk action menu:

![update_multiple_discoveries](https://github.com/user-attachments/assets/71463945-f201-4810-9798-2646751dc919)

### Alert document enhancements

Attack discoveries generated via the Attack discovery page, and scheduled Attack discoveries (generated via the alerting framework), are persisted as alert documents.

To support the new UI, this PR populates Attack discovery alert documents with two additional (existing, but unused by Attack discovery) alert document fields:

1) `kibana.alert.start` - timestamp when Attack discoveries are created

2) `kibana.alert.workflow_status_updated_at` - timestamp when the `kibana.alert.workflow_status` was last updated

This PR introduces three new alert document fields to capture metadata about when alerts are updated. Attack discovery is the first implementation to use these new fields, however any consumer of the alerting framework may utilize them in the future:

1) `kibana.alert.updated_at` - timestamp when the alert was last updated

2) `kibana.alert.updated_by.user.id` - user id of the user that last updated the alert

3) `kibana.alert.updated_by.user.name` -  user name of the user that last updated the alert

The three new alert fields above are updated when Attack discovery users update:

- The `kibana.alert.workflow_status` status of Attack discoveries
- The visibility (sharing) status of Attack discoveries (`kibana.alert.attack_discovery.users`)

The three new fields above were added to the [alert_field_map](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts) and [alert_schema](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts).

Using the `default` space as an example, the fields above may be observed in the `.adhoc.alerts-security.attack.discovery.alerts-default` data stream for Attack discoveries generated on the Attack discovery page, and scheduled discoveries for the same space are found in the `.alerts-security.attack.discovery.alerts-default` data stream.

### @timestamp updated when sharing status changes

To ensure newly-shared Attack discoveries are bumped to the top of search results, the `@timestamp` field is updated when the visibility (sharing) status of Attack discoveries (`kibana.alert.attack_discovery.users`) is updated.

(The original time an Attack discovery was generated is represented by the `kibana.alert.start` field, which is not mutated.)

### Visibility menu changes

This PR disables the visibility menu items for shared Attack discoveries, as illustrated by the screenshot below:

![visibility_menu_disabled](https://github.com/user-attachments/assets/168db75c-de8f-4bf1-9490-7e3995faed9d)

The disabled menu has a tooltip that reads:

```
The visibility of shared discoveries cannot be changed
```

Note: The internal Attack discovery bulk API still (intentionally) allows changes to the visibility of shared attack discoveries.

### `kibana.alert.workflow_status` added to default `Alerts` tab columns

The `kibana.alert.workflow_status` field was added to default `Alerts` tab columns, as illustrated by the screenshot below:

![alerts_tab_workflow_status](https://github.com/user-attachments/assets/264647d0-5782-444f-ad0e-c5485fae1e96)

### Summary of field updates

The following table describes when fields are updated (via this PR):

| Field                                     | Updated when                                                                                                                         | Description                                                                                  |
|-------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| `kibana.alert.start`                      | - Attack discoveries are created                                                                                                     | timestamp when Attack discoveries are created                                                |
| `kibana.alert.workflow_status_updated_at` | - Workflow status (`kibana.alert.workflow_status`) is updated                                                                        | timestamp when `kibana.alert.workflow_status` was last updated                               |
| `kibana.alert.updated_at`                 | - Workflow status (`kibana.alert.workflow_status`) is updated<br>- Sharing status (`kibana.alert.attack_discovery.users`) is updated | timestamp when the alert was last updated                                                    |
| `kibana.alert.updated_by.user.id`         | - Workflow status (`kibana.alert.workflow_status`) is updated<br>- Sharing status (`kibana.alert.attack_discovery.users`) is updated | user id of the user that last updated the alert                                              |
| `kibana.alert.updated_by.user.name`       | - Workflow status (`kibana.alert.workflow_status`) is updated<br>- Sharing status (`kibana.alert.attack_discovery.users`) is updated | user name of the user that last updated the alert                                            |
| `@timestamp`                              | - Attack discoveries are created<br>- Sharing status (`kibana.alert.attack_discovery.users`) is updated                              | ECS [`@timestamp`](https://www.elastic.co/docs/reference/ecs/ecs-base#field-timestamp) field |

### Feature flags

The _required_ feature flag below is necessary to desk test with Ad hoc attack discoveries. The _recommended_ feature flag below enables testing with scheduled Attack discoveries.

### required: `securitySolution.attackDiscoveryAlertsEnabled`

Enable the required `securitySolution.attackDiscoveryAlertsEnabled` feature flag in `config/kibana.dev.yml`:

```yaml
feature_flags.overrides:
  securitySolution.attackDiscoveryAlertsEnabled: true
```

### recommended: `securitySolution.assistantAttackDiscoverySchedulingEnabled: true`

Also enable the recommended `assistantAttackDiscoverySchedulingEnabled` feature flag in `config/kibana.dev.yml`:

```yaml
feature_flags.overrides:
  securitySolution.attackDiscoveryAlertsEnabled: true
  securitySolution.assistantAttackDiscoverySchedulingEnabled: true
```
This commit is contained in:
Andrew Macri 2025-06-24 20:47:10 -04:00 committed by GitHub
parent 6a85130cab
commit ea7d174e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2283 additions and 159 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AttackDiscoveryAlertDocument> => ({
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,
},
},
],
},
});

View file

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

View file

@ -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: {

View file

@ -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<string, { value: number }>;
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<string, { buckets: Array<{ key: string; doc_count: number }> }>;
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);
});
});

View file

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

View file

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

View file

@ -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]: [

View file

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

View file

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

View file

@ -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<T extends BaseFields8190> {

View file

@ -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(
<TestProviders>
<SharedBadge {...defaultProps} />
</TestProviders>
);
await userEvent.click(screen.getByTestId('sharedBadgeButton'));
expect(screen.getByTestId('sharedBadge')).toBeInTheDocument();
});
it('disables the shared option when shared', async () => {
render(
<TestProviders>
<SharedBadge {...defaultProps} />
</TestProviders>
);
await userEvent.click(screen.getByTestId('sharedBadgeButton'));
expect(screen.getByTestId('shared')).toHaveAttribute('aria-disabled', 'true');
});
it('disables the notShared option when shared', async () => {
render(
<TestProviders>
<SharedBadge {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryAlertSingleUser} />
</TestProviders>
);
await userEvent.click(screen.getByTestId('sharedBadgeButton'));
await userEvent.click(screen.getByTestId('shared'));
expect(mockMutateAsync).toHaveBeenCalled();
});
it('renders not shared when only one user', () => {
render(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryAlertSingleUser} />
</TestProviders>
);
expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared');
});
it('renders not shared when not an alert', () => {
mockIsAttackDiscoveryAlert.mockReturnValue(false);
render(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryNotAlert} />
</TestProviders>
);
expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared');
});
it('renders shared and disables shared option after changing to shared', async () => {
mockIsAttackDiscoveryAlert.mockReturnValue(true);
render(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryAlertSingleUser} />
</TestProviders>
);
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(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryAlertSingleUser} />
</TestProviders>
);
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(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryAlert} />
</TestProviders>
);
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(
<TestProviders>
<SharedBadge attackDiscovery={mockAttackDiscoveryNotAlert} />
</TestProviders>
);
expect(screen.getByTestId('sharedBadgeButton')).toHaveTextContent('Not shared');
});
it('closes the popover when closePopover is called (by toggling badge button)', async () => {
render(
<TestProviders>
<SharedBadge {...defaultProps} />
</TestProviders>
);
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();
});
});
});

View file

@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ attackDiscovery }) => {
description: i18n.VISIBLE_TO_YOUR_TEAM,
},
'data-test-subj': 'shared',
disabled: isShared,
label: i18n.SHARED,
},
]);
@ -155,40 +160,65 @@ const SharedBadgeComponent: React.FC<Props> = ({ 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 (
<EuiPopover
button={button}
closePopover={closePopover}
data-test-subj="sharedBadgePopover"
id={filterGroupPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
<EuiToolTip
content={isPopoverOpen && allItemsDisabled ? i18n.THE_VISIBILITY_OF_SHARED : undefined}
data-test-subj="sharedBadgeTooltip"
position="top"
>
<EuiSelectable
aria-label={i18n.VISIBILITY}
data-test-subj="sharedBadge"
listProps={LIST_PROPS}
options={items}
onChange={onSelectableChange}
renderOption={renderOption}
singleSelection={true}
<EuiPopover
button={button}
closePopover={closePopover}
data-test-subj="sharedBadgePopover"
id={filterGroupPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
{(list) => (
<div
css={css`
width: 230px;
`}
>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
<EuiSelectable
aria-label={i18n.VISIBILITY}
data-test-subj="sharedBadge"
listProps={LIST_PROPS}
options={items}
onChange={onSelectableChange}
renderOption={renderOption}
singleSelection={true}
>
{(list) => (
<div
css={css`
width: 230px;
`}
>
{list}
</div>
)}
</EuiSelectable>
</EuiPopover>
</EuiToolTip>
);
};

View file

@ -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',
{

View file

@ -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: () => <div data-test-subj="detection-engine-alerts-table" />,
}));
jest.mock('./ai_for_soc/wrapper', () => ({
AiForSOCAlertsTab: () => <div data-test-subj="ai4dsoc-alerts-table" />,
}));
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: () => <div />,
}));
jest.mock('./ai_for_soc/wrapper', () => ({
AiForSOCAlertsTab: () => <div />,
}));
describe('AlertsTab', () => {
const defaultProps = { attackDiscovery: mockAttackDiscovery };
beforeEach(() => {
jest.clearAllMocks();
});
@ -40,14 +43,13 @@ describe('AlertsTab', () => {
},
});
const { getByTestId } = render(
render(
<TestProviders>
<AlertsTab attackDiscovery={mockAttackDiscovery} />
<AlertsTab {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<AlertsTab attackDiscovery={mockAttackDiscovery} />
<AlertsTab {...defaultProps} />
</TestProviders>
);
expect(getByTestId('alertsTab')).toBeInTheDocument();
expect(getByTestId('ai4dsoc-alerts-table')).toBeInTheDocument();
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(
<TestProviders>
<AlertsTab {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<AlertsTab {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<AlertsTab {...defaultProps} replacements={replacements} />
</TestProviders>
);
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(
<TestProviders>
<AlertsTab {...defaultProps} replacements={replacements} />
</TestProviders>
);
expect(screen.getByTestId('alertsTab')).toBeInTheDocument();
});
it('renders with empty alertIds', () => {
(useKibana as jest.Mock).mockReturnValue({
services: {
application: {
capabilities: {
[SECURITY_FEATURE_ID]: {
configurations: false,
},
},
},
},
});
const emptyAttackDiscovery = { ...mockAttackDiscovery, alertIds: [] };
render(
<TestProviders>
<AlertsTab attackDiscovery={emptyAttackDiscovery} />
</TestProviders>
);
expect(screen.getByTestId('alertsTab')).toBeInTheDocument();
});
});

View file

@ -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<Props> = ({ 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 (
<div data-test-subj="alertsTab">
{AIForSOC ? (
@ -58,6 +73,7 @@ const AlertsTabComponent: React.FC<Props> = ({ attackDiscovery, replacements })
) : (
<div data-test-subj="detection-engine-alerts-table">
<DetectionEngineAlertsTable
columns={columns}
id={id}
tableType={TableId.alertsOnCasePage}
ruleTypeIds={SECURITY_SOLUTION_RULE_TYPE_IDS}

View file

@ -9,30 +9,75 @@ import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { Tabs } from '.';
import { TestProviders } from '../../../../../common/mock';
import { TestProviders } from '../../../../../common/mock/test_providers';
import { mockAttackDiscovery } from '../../../mock/mock_attack_discovery';
describe('Tabs', () => {
beforeEach(() => {
const defaultProps = {
attackDiscovery: mockAttackDiscovery,
replacements: undefined,
showAnonymized: false,
};
const renderTabs = (props = {}) =>
render(
<TestProviders>
<Tabs attackDiscovery={mockAttackDiscovery} />
<Tabs {...defaultProps} {...props} />
</TestProviders>
);
});
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(
<TestProviders>
<Tabs {...defaultProps} />
</TestProviders>
);
fireEvent.click(screen.getByText('Alerts'));
expect(screen.getByTestId('alertsTab')).toBeInTheDocument();
rerender(
<TestProviders>
<Tabs {...defaultProps} attackDiscovery={{ ...mockAttackDiscovery, id: 'new-id' }} />
</TestProviders>
);
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();
});
});

View file

@ -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<Props> = ({
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 (
<>
<EuiTabs data-test-subj="tabs">

View file

@ -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();
render(
<TestProviders>
<TakeAction
attackDiscoveries={[mockAttackDiscovery]}
setSelectedAttackDiscoveries={jest.fn()}
/>
</TestProviders>
);
const takeActionButtons = screen.getAllByTestId('takeActionPopoverButton');
fireEvent.click(takeActionButtons[0]); // open the popover
});
it('renders the Add to new case action', () => {
const addToCase = screen.getByTestId('addToCase');
expect(addToCase).toBeInTheDocument();
render(
<TestProviders>
<TakeAction {...defaultProps} />
</TestProviders>
);
fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]);
expect(screen.getByTestId('addToCase')).toBeInTheDocument();
});
it('renders the Add to existing case action', () => {
const addToCase = screen.getByTestId('addToExistingCase');
expect(addToCase).toBeInTheDocument();
render(
<TestProviders>
<TakeAction {...defaultProps} />
</TestProviders>
);
fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]);
expect(screen.getByTestId('addToExistingCase')).toBeInTheDocument();
});
it('renders the View in AI Assistant action', () => {
const addToCase = screen.getByTestId('viewInAiAssistant');
render(
<TestProviders>
<TakeAction {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<TakeAction
{...defaultProps}
attackDiscoveries={[mockAttackDiscovery, mockAttackDiscovery]}
/>
</TestProviders>
);
fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]);
expect(screen.queryByTestId('viewInAiAssistant')).toBeNull();
});
it('renders mark as open/acknowledged/closed actions when alertWorkflowStatus is set', () => {
const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'acknowledged' };
render(
<TestProviders>
<TakeAction {...defaultProps} attackDiscoveries={[alert]} />
</TestProviders>
);
fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]);
expect(screen.getByTestId('markAsOpen')).toBeInTheDocument();
expect(screen.getByTestId('markAsClosed')).toBeInTheDocument();
});
it('shows UpdateAlertsModal when mark as closed is clicked', async () => {
const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' };
render(
<TestProviders>
<TakeAction {...defaultProps} attackDiscoveries={[alert]} />
</TestProviders>
);
fireEvent.click(screen.getAllByTestId('takeActionPopoverButton')[0]);
fireEvent.click(screen.getByTestId('markAsClosed'));
expect(await screen.findByTestId('confirmModal')).toBeInTheDocument();
});
it('calls setSelectedAttackDiscoveries and closes modal on confirm', async () => {
const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' };
const setSelectedAttackDiscoveries = jest.fn();
render(
<TestProviders>
<TakeAction
{...defaultProps}
attackDiscoveries={[alert]}
setSelectedAttackDiscoveries={setSelectedAttackDiscoveries}
/>
</TestProviders>
);
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({});
});
it('closes modal on cancel', async () => {
const alert = { ...mockAttackDiscovery, alertWorkflowStatus: 'open', id: 'id1' };
render(
<TestProviders>
<TakeAction {...defaultProps} attackDiscoveries={[alert]} />
</TestProviders>
);
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();
});
});

View file

@ -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<Props> = ({
replacements,
setSelectedAttackDiscoveries,
}) => {
const [pendingAction, setPendingAction] = useState<'open' | 'acknowledged' | 'closed' | null>(
null
);
const {
services: { cases },
} = useKibana();
@ -101,67 +107,26 @@ const TakeActionComponent: React.FC<Props> = ({
);
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<Props> = ({
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 (
<EuiPopover
anchorPosition="downCenter"
button={button}
closePopover={closePopover}
data-test-subj="takeAction"
id={takeActionContextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel size="s" items={allItems} />
</EuiPopover>
<>
<EuiPopover
anchorPosition="downCenter"
button={button}
closePopover={closePopover}
data-test-subj="takeAction"
id={takeActionContextMenuPopoverId}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel size="s" items={allItems} />
</EuiPopover>
{pendingAction != null && (
<UpdateAlertsModal
alertsCount={alertIds.length}
attackDiscoveriesCount={attackDiscoveryIds.length}
onCancel={onCloseOrCancel}
onClose={onCloseOrCancel}
onConfirm={onConfirm}
workflowStatus={pendingAction}
/>
)}
</>
);
};

View file

@ -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(<UpdateAlertsModal {...defaultProps} />);
expect(screen.getByTestId('modalBody')).toHaveTextContent(
'Update 2 alerts associated with 3 attack discoveries?'
);
});
it('calls onCancel when cancel button is clicked', () => {
render(<UpdateAlertsModal {...defaultProps} />);
fireEvent.click(screen.getByTestId('cancel'));
expect(defaultProps.onCancel).toHaveBeenCalled();
});
it('calls onConfirm(false) when markDiscoveriesOnly is clicked', () => {
render(<UpdateAlertsModal {...defaultProps} />);
fireEvent.click(screen.getByTestId('markDiscoveriesOnly'));
expect(defaultProps.onConfirm).toHaveBeenCalledWith(false);
});
it('calls onConfirm(true) when markAlertsAndDiscoveries is clicked', () => {
render(<UpdateAlertsModal {...defaultProps} />);
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(
<UpdateAlertsModal
{...defaultProps}
workflowStatus={workflowStatus as 'open' | 'acknowledged' | 'closed'}
/>
);
expect(screen.getByTestId('markAlertsAndDiscoveries')).toHaveTextContent(expected);
}
);
});

View file

@ -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<Props> = ({
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(
() => (
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false} wrap={true}>
<EuiFlexItem
css={css`
margin-right: ${euiTheme.size.m};
`}
grow={false}
>
<EuiButton data-test-subj="markDiscoveriesOnly" onClick={markDiscoveriesOnly}>
{i18n.MARK_DISCOVERIES_ONLY({
attackDiscoveriesCount,
workflowStatus,
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="markAlertsAndDiscoveries"
color="primary"
fill
onClick={markAlertsAndDiscoveries}
>
{i18n.MARK_ALERTS_AND_DISCOVERIES({
alertsCount,
attackDiscoveriesCount,
workflowStatus,
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
[
alertsCount,
attackDiscoveriesCount,
euiTheme.size.m,
markAlertsAndDiscoveries,
markDiscoveriesOnly,
workflowStatus,
]
);
return (
<EuiModal
aria-labelledby={titleId}
data-test-subj="confirmModal"
id={modalId}
onClose={onClose}
>
<EuiModalHeader>
<EuiModalHeaderTitle title={titleId}>{i18n.UPDATE_ALERTS}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<div data-test-subj="modalBody">
{i18n.UPDATE_ALERTS_ASSOCIATED({
alertsCount,
attackDiscoveriesCount,
})}
</div>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
responsive={false}
wrap={true}
>
<EuiFlexItem
css={css`
margin-right: ${euiTheme.size.xxxl};
`}
grow={false}
>
<EuiButtonEmpty data-test-subj="cancel" flush="left" onClick={onCancel}>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{confirmButtons}</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
};
UpdateAlertsModalComponent.displayName = 'UpdateAlertsModal';
export const UpdateAlertsModal = React.memo(UpdateAlertsModalComponent);

View file

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

View file

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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();
});
});

View file

@ -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<UpdatedAlertsResponse, Error, UpdateAlertsStatusParams>(
({ 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 });
},
}
);
};

View file

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

View file

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

View file

@ -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) => {

View file

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