mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:  Users may (optionally) update all alerts for a single attack discovery, or just update the discovery itself:  When multiple attack discoveries are selected, users may also (optionally) update the status of all their related alerts via the bulk action menu:  ### 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:  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:  ### 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:
parent
6a85130cab
commit
ea7d174e3c
39 changed files with 2283 additions and 159 deletions
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
|
@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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]: [
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -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);
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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) => {
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue