[Security solution] Attack discovery background task and persistence (#184949)

This commit is contained in:
Steph Milovic 2024-06-25 09:43:39 -06:00 committed by GitHub
parent 8e76b0b113
commit 48c0e0dd7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 4826 additions and 1472 deletions

View file

@ -0,0 +1,34 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Cancel Attack Discovery API endpoint
* version: 1
*/
import { z } from 'zod';
import { NonEmptyString } from '../common_attributes.gen';
import { AttackDiscoveryResponse } from './common_attributes.gen';
export type AttackDiscoveryCancelRequestParams = z.infer<typeof AttackDiscoveryCancelRequestParams>;
export const AttackDiscoveryCancelRequestParams = z.object({
/**
* The connector id for which to cancel a pending attack discovery
*/
connectorId: NonEmptyString,
});
export type AttackDiscoveryCancelRequestParamsInput = z.input<
typeof AttackDiscoveryCancelRequestParams
>;
export type AttackDiscoveryCancelResponse = z.infer<typeof AttackDiscoveryCancelResponse>;
export const AttackDiscoveryCancelResponse = AttackDiscoveryResponse;

View file

@ -0,0 +1,41 @@
openapi: 3.0.0
info:
title: Cancel Attack Discovery API endpoint
version: '1'
paths:
/internal/elastic_assistant/attack_discovery/cancel/{connectorId}:
put:
operationId: AttackDiscoveryCancel
x-codegen-enabled: true
description: Cancel relevant data for performing an attack discovery like pending requests
summary: Cancel relevant data for performing an attack discovery
tags:
- attack_discovery
parameters:
- name: 'connectorId'
in: path
required: true
description: The connector id for which to cancel a pending attack discovery
schema:
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: './common_attributes.schema.yaml#/components/schemas/AttackDiscoveryResponse'
'400':
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string

View file

@ -0,0 +1,199 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Common Attack Discovery Attributes
* version: not applicable
*/
import { z } from 'zod';
import { NonEmptyString, User } from '../common_attributes.gen';
import { Replacements, ApiConfig } from '../conversations/common_attributes.gen';
/**
* An attack discovery generated from one or more alerts
*/
export type AttackDiscovery = z.infer<typeof AttackDiscovery>;
export const AttackDiscovery = z.object({
/**
* The alert IDs that the attack discovery is based on
*/
alertIds: z.array(z.string()),
/**
* UUID of attack discovery
*/
id: z.string().optional(),
/**
* Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data.
*/
detailsMarkdown: z.string(),
/**
* A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax
*/
entitySummaryMarkdown: z.string(),
/**
* An array of MITRE ATT&CK tactic for the attack discovery
*/
mitreAttackTactics: z.array(z.string()).optional(),
/**
* A markdown summary of attack discovery, using the same syntax
*/
summaryMarkdown: z.string(),
/**
* A title for the attack discovery, in plain text
*/
title: z.string(),
/**
* The time the attack discovery was generated
*/
timestamp: NonEmptyString,
});
/**
* Array of attack discoveries
*/
export type AttackDiscoveries = z.infer<typeof AttackDiscoveries>;
export const AttackDiscoveries = z.array(AttackDiscovery);
/**
* The status of the attack discovery.
*/
export type AttackDiscoveryStatus = z.infer<typeof AttackDiscoveryStatus>;
export const AttackDiscoveryStatus = z.enum(['running', 'succeeded', 'failed', 'canceled']);
export type AttackDiscoveryStatusEnum = typeof AttackDiscoveryStatus.enum;
export const AttackDiscoveryStatusEnum = AttackDiscoveryStatus.enum;
/**
* Run durations for the attack discovery
*/
export type GenerationInterval = z.infer<typeof GenerationInterval>;
export const GenerationInterval = z.object({
/**
* The time the attack discovery was generated
*/
date: z.string(),
/**
* The duration of the attack discovery generation
*/
durationMs: z.number().int(),
});
export type AttackDiscoveryResponse = z.infer<typeof AttackDiscoveryResponse>;
export const AttackDiscoveryResponse = z.object({
id: NonEmptyString,
timestamp: NonEmptyString.optional(),
/**
* The last time attack discovery was updated.
*/
updatedAt: z.string().optional(),
/**
* The number of alerts in the context.
*/
alertsContextCount: z.number().int().optional(),
/**
* The time attack discovery was created.
*/
createdAt: z.string(),
replacements: Replacements.optional(),
users: z.array(User),
/**
* The status of the attack discovery.
*/
status: AttackDiscoveryStatus,
/**
* The attack discoveries.
*/
attackDiscoveries: AttackDiscoveries,
/**
* LLM API configuration.
*/
apiConfig: ApiConfig,
/**
* Kibana space
*/
namespace: z.string(),
/**
* The backing index required for update requests.
*/
backingIndex: z.string(),
/**
* The most 5 recent generation intervals
*/
generationIntervals: z.array(GenerationInterval),
/**
* The average generation interval in milliseconds
*/
averageIntervalMs: z.number().int(),
/**
* The reason for a status of failed.
*/
failureReason: z.string().optional(),
});
export type AttackDiscoveryUpdateProps = z.infer<typeof AttackDiscoveryUpdateProps>;
export const AttackDiscoveryUpdateProps = z.object({
id: NonEmptyString,
/**
* LLM API configuration.
*/
apiConfig: ApiConfig.optional(),
/**
* The number of alerts in the context.
*/
alertsContextCount: z.number().int().optional(),
/**
* The attack discoveries.
*/
attackDiscoveries: AttackDiscoveries.optional(),
/**
* The status of the attack discovery.
*/
status: AttackDiscoveryStatus,
replacements: Replacements.optional(),
/**
* The most 5 recent generation intervals
*/
generationIntervals: z.array(GenerationInterval).optional(),
/**
* The backing index required for update requests.
*/
backingIndex: z.string(),
/**
* The reason for a status of failed.
*/
failureReason: z.string().optional(),
});
export type AttackDiscoveryCreateProps = z.infer<typeof AttackDiscoveryCreateProps>;
export const AttackDiscoveryCreateProps = z.object({
/**
* The attack discovery id.
*/
id: z.string().optional(),
/**
* The status of the attack discovery.
*/
status: AttackDiscoveryStatus,
/**
* The number of alerts in the context.
*/
alertsContextCount: z.number().int().optional(),
/**
* The attack discoveries.
*/
attackDiscoveries: AttackDiscoveries,
/**
* LLM API configuration.
*/
apiConfig: ApiConfig,
replacements: Replacements.optional(),
});

View file

@ -0,0 +1,197 @@
openapi: 3.0.0
info:
title: Common Attack Discovery Attributes
version: 'not applicable'
paths: {}
components:
x-codegen-enabled: true
schemas:
AttackDiscovery:
type: object
description: An attack discovery generated from one or more alerts
required:
- 'alertIds'
- 'detailsMarkdown'
- 'entitySummaryMarkdown'
- 'summaryMarkdown'
- 'timestamp'
- 'title'
properties:
alertIds:
description: The alert IDs that the attack discovery is based on
items:
type: string
type: array
id:
description: UUID of attack discovery
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
entitySummaryMarkdown:
description: A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax
type: string
mitreAttackTactics:
description: An array of MITRE ATT&CK tactic for the attack discovery
items:
type: string
type: array
summaryMarkdown:
description: A markdown summary of attack discovery, using the same syntax
type: string
title:
description: A title for the attack discovery, in plain text
type: string
timestamp:
description: The time the attack discovery was generated
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
AttackDiscoveries:
type: array
description: Array of attack discoveries
items:
$ref: '#/components/schemas/AttackDiscovery'
AttackDiscoveryStatus:
type: string
description: The status of the attack discovery.
enum:
- running
- succeeded
- failed
- canceled
GenerationInterval:
type: object
description: Run durations for the attack discovery
required:
- 'date'
- 'durationMs'
properties:
date:
description: The time the attack discovery was generated
type: string
durationMs:
description: The duration of the attack discovery generation
type: integer
AttackDiscoveryResponse:
type: object
required:
- apiConfig
- id
- createdAt
- users
- namespace
- attackDiscoveries
- status
- backingIndex
- generationIntervals
- averageIntervalMs
properties:
id:
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
'timestamp':
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
updatedAt:
description: The last time attack discovery was updated.
type: string
alertsContextCount:
type: integer
description: The number of alerts in the context.
createdAt:
description: The time attack discovery was created.
type: string
replacements:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
users:
type: array
items:
$ref: '../common_attributes.schema.yaml#/components/schemas/User'
status:
$ref: '#/components/schemas/AttackDiscoveryStatus'
description: The status of the attack discovery.
attackDiscoveries:
$ref: '#/components/schemas/AttackDiscoveries'
description: The attack discoveries.
apiConfig:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
description: LLM API configuration.
namespace:
type: string
description: Kibana space
backingIndex:
type: string
description: The backing index required for update requests.
generationIntervals:
type: array
description: The most 5 recent generation intervals
items:
$ref: '#/components/schemas/GenerationInterval'
averageIntervalMs:
type: integer
description: The average generation interval in milliseconds
failureReason:
type: string
description: The reason for a status of failed.
AttackDiscoveryUpdateProps:
type: object
required:
- id
- status
- backingIndex
properties:
id:
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
apiConfig:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
description: LLM API configuration.
alertsContextCount:
type: integer
description: The number of alerts in the context.
attackDiscoveries:
$ref: '#/components/schemas/AttackDiscoveries'
description: The attack discoveries.
status:
$ref: '#/components/schemas/AttackDiscoveryStatus'
description: The status of the attack discovery.
replacements:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
generationIntervals:
type: array
description: The most 5 recent generation intervals
items:
$ref: '#/components/schemas/GenerationInterval'
backingIndex:
type: string
description: The backing index required for update requests.
failureReason:
type: string
description: The reason for a status of failed.
AttackDiscoveryCreateProps:
type: object
required:
- attackDiscoveries
- apiConfig
- status
properties:
id:
type: string
description: The attack discovery id.
status:
$ref: '#/components/schemas/AttackDiscoveryStatus'
description: The status of the attack discovery.
alertsContextCount:
type: integer
description: The number of alerts in the context.
attackDiscoveries:
$ref: '#/components/schemas/AttackDiscoveries'
description: The attack discoveries.
apiConfig:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
description: LLM API configuration.
replacements:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'

View file

@ -0,0 +1,38 @@
/*
* 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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Get Attack Discovery API endpoint
* version: 1
*/
import { z } from 'zod';
import { NonEmptyString } from '../common_attributes.gen';
import { AttackDiscoveryResponse } from './common_attributes.gen';
export type AttackDiscoveryGetRequestParams = z.infer<typeof AttackDiscoveryGetRequestParams>;
export const AttackDiscoveryGetRequestParams = z.object({
/**
* The connector id for which to get the attack discovery
*/
connectorId: NonEmptyString,
});
export type AttackDiscoveryGetRequestParamsInput = z.input<typeof AttackDiscoveryGetRequestParams>;
export type AttackDiscoveryGetResponse = z.infer<typeof AttackDiscoveryGetResponse>;
export const AttackDiscoveryGetResponse = z.object({
data: AttackDiscoveryResponse.optional(),
/**
* Indicates if an attack discovery exists for the given connectorId
*/
entryExists: z.boolean(),
});

View file

@ -0,0 +1,48 @@
openapi: 3.0.0
info:
title: Get Attack Discovery API endpoint
version: '1'
paths:
/internal/elastic_assistant/attack_discovery/{connectorId}:
get:
operationId: AttackDiscoveryGet
x-codegen-enabled: true
description: Get relevant data for performing an attack discovery like pending requests
summary: Get relevant data for performing an attack discovery
tags:
- attack_discovery
parameters:
- name: 'connectorId'
in: path
required: true
description: The connector id for which to get the attack discovery
schema:
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
data:
$ref: './common_attributes.schema.yaml#/components/schemas/AttackDiscoveryResponse'
entryExists:
type: boolean
description: Indicates if an attack discovery exists for the given connectorId
required:
- entryExists
'400':
description: Generic Error
content:
application/json:
schema:
type: object
properties:
statusCode:
type: number
error:
type: string
message:
type: string

View file

@ -10,52 +10,24 @@
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Attack discovery API endpoint
* title: Post Attack discovery API endpoint
* version: 1
*/
import { z } from 'zod';
import { AnonymizationFieldResponse } from '../anonymization_fields/bulk_crud_anonymization_fields_route.gen';
import { Replacements, TraceData } from '../conversations/common_attributes.gen';
/**
* An attack discovery generated from one or more alerts
*/
export type AttackDiscovery = z.infer<typeof AttackDiscovery>;
export const AttackDiscovery = z.object({
/**
* The alert IDs that the attack discovery is based on
*/
alertIds: z.array(z.string()),
/**
* Details of the attack with bulleted markdown that always uses special syntax for field names and values from the source data.
*/
detailsMarkdown: z.string(),
/**
* A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax
*/
entitySummaryMarkdown: z.string(),
/**
* An array of MITRE ATT&CK tactic for the attack discovery
*/
mitreAttackTactics: z.array(z.string()).optional(),
/**
* A markdown summary of attack discovery, using the same syntax
*/
summaryMarkdown: z.string(),
/**
* A title for the attack discovery, in plain text
*/
title: z.string(),
});
import { ApiConfig, Replacements } from '../conversations/common_attributes.gen';
import { AttackDiscoveryResponse } from './common_attributes.gen';
export type AttackDiscoveryPostRequestBody = z.infer<typeof AttackDiscoveryPostRequestBody>;
export const AttackDiscoveryPostRequestBody = z.object({
alertsIndexPattern: z.string(),
anonymizationFields: z.array(AnonymizationFieldResponse),
connectorId: z.string(),
actionTypeId: z.string(),
/**
* LLM API configuration.
*/
apiConfig: ApiConfig,
langSmithProject: z.string().optional(),
langSmithApiKey: z.string().optional(),
model: z.string().optional(),
@ -66,11 +38,4 @@ export const AttackDiscoveryPostRequestBody = z.object({
export type AttackDiscoveryPostRequestBodyInput = z.input<typeof AttackDiscoveryPostRequestBody>;
export type AttackDiscoveryPostResponse = z.infer<typeof AttackDiscoveryPostResponse>;
export const AttackDiscoveryPostResponse = z.object({
connector_id: z.string().optional(),
alertsContextCount: z.number().optional(),
attackDiscoveries: z.array(AttackDiscovery).optional(),
replacements: Replacements.optional(),
status: z.string().optional(),
trace_data: TraceData.optional(),
});
export const AttackDiscoveryPostResponse = AttackDiscoveryResponse;

View file

@ -1,43 +1,9 @@
openapi: 3.0.0
info:
title: Attack discovery API endpoint
title: Post Attack discovery API endpoint
version: '1'
components:
x-codegen-enabled: true
schemas:
AttackDiscovery:
type: object
description: An attack discovery generated from one or more alerts
required:
- 'alertIds'
- 'detailsMarkdown'
- 'entitySummaryMarkdown'
- 'summaryMarkdown'
- 'title'
properties:
alertIds:
description: The alert IDs that the attack discovery is based on
items:
type: string
type: array
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
entitySummaryMarkdown:
description: A short (no more than a sentence) summary of the attack discovery featuring only the host.name and user.name fields (when they are applicable), using the same syntax
type: string
mitreAttackTactics:
description: An array of MITRE ATT&CK tactic for the attack discovery
items:
type: string
type: array
summaryMarkdown:
description: A markdown summary of attack discovery, using the same syntax
type: string
title:
description: A title for the attack discovery, in plain text
type: string
paths:
/internal/elastic_assistant/attack_discovery:
@ -56,10 +22,9 @@ paths:
schema:
type: object
required:
- actionTypeId
- apiConfig
- alertsIndexPattern
- anonymizationFields
- connectorId
- size
- subAction
properties:
@ -69,10 +34,9 @@ paths:
items:
$ref: '../anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse'
type: array
connectorId:
type: string
actionTypeId:
type: string
apiConfig:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
description: LLM API configuration.
langSmithProject:
type: string
langSmithApiKey:
@ -94,22 +58,7 @@ paths:
content:
application/json:
schema:
type: object
properties:
connector_id:
type: string
alertsContextCount:
type: number
attackDiscoveries:
type: array
items:
$ref: '#/components/schemas/AttackDiscovery'
replacements:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
status:
type: string
trace_data:
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/TraceData'
$ref: './common_attributes.schema.yaml#/components/schemas/AttackDiscoveryResponse'
'400':
description: Bad request
content:

View file

@ -108,11 +108,11 @@ export const Message = z.object({
export type ApiConfig = z.infer<typeof ApiConfig>;
export const ApiConfig = z.object({
/**
* connector Id
* connector id
*/
connectorId: z.string(),
/**
* action type Id
* action type id
*/
actionTypeId: z.string(),
/**

View file

@ -93,10 +93,10 @@ components:
properties:
connectorId:
type: string
description: connector Id
description: connector id
actionTypeId:
type: string
description: action type Id
description: action type id
defaultSystemPromptId:
type: string
description: defaultSystemPromptId

View file

@ -22,7 +22,10 @@ export const INTERNAL_API_ACCESS = 'internal';
export * from './common_attributes.gen';
// Attack discovery Schemas
export * from './attack_discovery/common_attributes.gen';
export * from './attack_discovery/get_attack_discovery_route.gen';
export * from './attack_discovery/post_attack_discovery_route.gen';
export * from './attack_discovery/cancel_attack_discovery_route.gen';
// Evaluation Schemas
export * from './evaluation/post_evaluate_route.gen';

View file

@ -14,6 +14,8 @@ export const POST_ACTIONS_CONNECTOR_EXECUTE = `${BASE_PATH}/actions/connector/{c
// Attack discovery
export const ATTACK_DISCOVERY = `${BASE_PATH}/attack_discovery`;
export const ATTACK_DISCOVERY_BY_CONNECTOR_ID = `${ATTACK_DISCOVERY}/{connectorId}`;
export const ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID = `${ATTACK_DISCOVERY}/cancel/{connectorId}`;
// Model Evaluation
export const EVALUATE = `${BASE_PATH}/evaluate`;

View file

@ -0,0 +1,128 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types';
export const getAttackDiscoverySearchEsMock = () => {
const searchResponse: estypes.SearchResponse<EsAttackDiscoverySchema> = {
took: 3,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 1,
relation: 'eq',
},
max_score: 0,
hits: [
{
_index: 'foo',
_id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
_source: {
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
'@timestamp': '2024-06-07T18:56:17.357Z',
created_at: '2024-06-07T18:56:17.357Z',
users: [
{
id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
name: 'elastic',
},
],
status: 'succeeded',
api_config: {
action_type_id: '.gen-ai',
connector_id: 'my-gpt4o-ai',
},
attack_discoveries: [
{
summary_markdown:
'Critical malware detected on {{ host.name cd854ec0-1096-4ca6-a7b8-582655d6b970 }} involving {{ user.name f19e1a0a-de3b-496c-8ace-dd91229e1084 }}. The malware, identified as {{ file.name My Go Application.app }}, was executed with the command line {{ process.command_line xpcproxy application.Appify by Machine Box.My Go Application.20.23 }}.',
id: 'a45bc1af-e652-4f3b-b8ce-408028f29824',
title: 'Critical Malware Detection',
mitre_attack_tactics: ['Execution', 'Persistence', 'Privilege Escalation'],
alert_ids: [
'094e59adc680420aeb1e0f872b52e17bd2f61aaddde521d53600f0576062ac4d',
'fdcb45018d3aac5e7a529a455aedc9276ef89b386ca4dbae1d721dd383577d21',
'82baa43f7514ee7fb107ae032606d33afc6092a9c9a9caeffd1fe120a7640698',
'aef4302768e19c5413c53203c14624bdf9d0656fa3d1d439c633c9880a2f3f6e',
'04cbafe6d7f965908a9155ae0bc559ce537faaf06266df732d7bd6897c83e77e',
'6f73d978ea02a471eba8d82772dc16f26622628b93fa0a651ce847fe7baf9e64',
'7ff1cd151bfdd2678d9efd4e22bfaf15dbfd89a81f40ea2160769c143ecca082',
'dee8604204be00bc61112fe81358089a5e4d494ac28c95937758383f391a8cec',
'4c49b1fbcb6f9a4cfb355f56edfbc0d5320cd65f9f720546dd99e51d8d6eef84',
],
details_markdown: `"""- **Host**: {{ host.name cd854ec0-1096-4ca6-a7b8-582655d6b970 }}\n- **User**: {{ user.name f19e1a0a-de3b-496c-8ace-dd91229e1084 }}\n- **Malware**: {{ file.name My Go Application.app }}\n- **Path**: {{ file.path /private/var/folders/_b/rmcpc65j6nv11ygrs50ctcjr0000gn/T/AppTranslocation/37D933EC-334D-410A-A741-0F730D6AE3FD/d/Setup.app/Contents/MacOS/My Go Application.app }}\n- **Command Line**: {{ process.command_line xpcproxy application.Appify by Machine Box.My Go Application.20.23 }}\n- **SHA256**: {{ process.hash.sha256 2c63ba2b1a5131b80e567b7a1a93997a2de07ea20d0a8f5149701c67b832c097 }}\n- **Parent Process**: {{ process.parent.name launchd }}\n- **Parent Command Line**: {{ process.parent.command_line /sbin/launchd }}\n- **Code Signature**: {{ process.code_signature.status code failed to satisfy specified code requirement(s) }}"""`,
entity_summary_markdown:
'{{ host.name cd854ec0-1096-4ca6-a7b8-582655d6b970 }} and {{ user.name f19e1a0a-de3b-496c-8ace-dd91229e1084 }} involved in critical malware detection.',
timestamp: '2024-06-07T21:19:08.090Z',
},
],
updated_at: '2024-06-07T21:19:08.090Z',
replacements: [
{
uuid: 'f19e1a0a-de3b-496c-8ace-dd91229e1084',
value: 'root',
},
{
uuid: 'cd854ec0-1096-4ca6-a7b8-582655d6b970',
value: 'SRVMAC08',
},
{
uuid: '3517f073-7f5e-42b4-9c42-e8a25dc9e27e',
value: 'james',
},
{
uuid: 'f04af949-504e-4374-a31e-447e7d5b252e',
value: 'Administrator',
},
{
uuid: '7eecfdbb-373a-4cbb-9bf7-e91a0be73b29',
value: 'SRVWIN07-PRIV',
},
{
uuid: '8b73ea51-4c7a-4caa-a424-5b2495eabd2a',
value: 'SRVWIN07',
},
{
uuid: '908405b1-fc8b-4fef-9bdf-35895896a1e3',
value: 'SRVWIN06',
},
{
uuid: '7e8a2687-74d6-47d2-951c-522e21a44853',
value: 'SRVNIX05',
},
],
namespace: 'default',
generation_intervals: [
{
date: '2024-06-07T21:19:08.089Z',
duration_ms: 110906,
},
{
date: '2024-06-07T20:04:35.715Z',
duration_ms: 104593,
},
{
date: '2024-06-07T18:58:27.880Z',
duration_ms: 130526,
},
],
alerts_context_count: 20,
average_interval_ms: 115341,
},
},
],
},
};
return searchResponse;
};

View file

@ -8,9 +8,12 @@
import type { PublicMethodsOf } from '@kbn/utility-types';
import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations';
import { AIAssistantDataClient } from '../ai_assistant_data_clients';
import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery';
type ConversationsDataClientContract = PublicMethodsOf<AIAssistantConversationsDataClient>;
export type ConversationsDataClientMock = jest.Mocked<ConversationsDataClientContract>;
type AttackDiscoveryDataClientContract = PublicMethodsOf<AttackDiscoveryDataClient>;
export type AttackDiscoveryDataClientMock = jest.Mocked<AttackDiscoveryDataClientContract>;
const createConversationsDataClientMock = () => {
const mocked: ConversationsDataClientMock = {
@ -32,6 +35,22 @@ export const conversationsDataClientMock: {
create: createConversationsDataClientMock,
};
const createAttackDiscoveryDataClientMock = (): AttackDiscoveryDataClientMock => ({
getAttackDiscovery: jest.fn(),
createAttackDiscovery: jest.fn(),
findAttackDiscoveryByConnectorId: jest.fn(),
updateAttackDiscovery: jest.fn(),
getReader: jest.fn(),
getWriter: jest.fn().mockResolvedValue({ bulk: jest.fn() }),
findDocuments: jest.fn(),
});
export const attackDiscoveryDataClientMock: {
create: () => AttackDiscoveryDataClientMock;
} = {
create: createAttackDiscoveryDataClientMock,
};
type AIAssistantDataClientContract = PublicMethodsOf<AIAssistantDataClient>;
export type AIAssistantDataClientMock = jest.Mocked<AIAssistantDataClientContract>;

View file

@ -5,8 +5,15 @@
* 2.0.
*/
import { httpServerMock } from '@kbn/core/server/mocks';
import { CAPABILITIES, EVALUATE } from '../../common/constants';
import {
ATTACK_DISCOVERY,
ATTACK_DISCOVERY_BY_CONNECTOR_ID,
ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
CAPABILITIES,
EVALUATE,
} from '../../common/constants';
import {
AttackDiscoveryPostRequestBody,
ConversationCreateProps,
ConversationUpdateProps,
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_BULK_ACTION,
@ -188,3 +195,24 @@ export const getAnonymizationFieldsBulkActionRequest = (
},
},
});
export const getCancelAttackDiscoveryRequest = (connectorId: string) =>
requestMock.create({
method: 'put',
path: ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
params: { connectorId },
});
export const getAttackDiscoveryRequest = (connectorId: string) =>
requestMock.create({
method: 'get',
path: ATTACK_DISCOVERY_BY_CONNECTOR_ID,
params: { connectorId },
});
export const postAttackDiscoveryRequest = (body: AttackDiscoveryPostRequestBody) =>
requestMock.create({
method: 'post',
path: ATTACK_DISCOVERY,
body,
});

View file

@ -14,11 +14,16 @@ import {
ElasticAssistantRequestHandlerContext,
} from '../types';
import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
import { conversationsDataClientMock, dataClientMock } from './data_clients.mock';
import {
attackDiscoveryDataClientMock,
conversationsDataClientMock,
dataClientMock,
} from './data_clients.mock';
import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations';
import { AIAssistantDataClient } from '../ai_assistant_data_clients';
import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base';
import { defaultAssistantFeatures } from '@kbn/elastic-assistant-common';
import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery';
export const createMockClients = () => {
const core = coreMock.createRequestHandlerContext();
@ -36,6 +41,7 @@ export const createMockClients = () => {
getAIAssistantConversationsDataClient: conversationsDataClientMock.create(),
getAIAssistantKnowledgeBaseDataClient: dataClientMock.create(),
getAIAssistantPromptsDataClient: dataClientMock.create(),
getAttackDiscoveryDataClient: attackDiscoveryDataClientMock.create(),
getAIAssistantAnonymizationFieldsDataClient: dataClientMock.create(),
getSpaceId: jest.fn(),
getCurrentUser: jest.fn(),
@ -109,6 +115,10 @@ const createElasticAssistantRequestContextMock = (
() => clients.elasticAssistant.getAIAssistantPromptsDataClient
) as unknown as jest.MockInstance<Promise<AIAssistantDataClient | null>, [], unknown> &
(() => Promise<AIAssistantDataClient | null>),
getAttackDiscoveryDataClient: jest.fn(
() => clients.elasticAssistant.getAttackDiscoveryDataClient
) as unknown as jest.MockInstance<Promise<AttackDiscoveryDataClient | null>, [], unknown> &
(() => Promise<AttackDiscoveryDataClient | null>),
getAIAssistantKnowledgeBaseDataClient: jest.fn(
() => clients.elasticAssistant.getAIAssistantKnowledgeBaseDataClient
) as unknown as jest.MockInstance<

View file

@ -15,6 +15,8 @@ import { EsPromptsSchema } from '../ai_assistant_data_clients/prompts/types';
import { getPromptsSearchEsMock } from './prompts_schema.mock';
import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonymization_fields/types';
import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock';
import { getAttackDiscoverySearchEsMock } from './attack_discovery_schema.mock';
import { EsAttackDiscoverySchema } from '../ai_assistant_data_clients/attack_discovery/types';
export const responseMock = {
create: httpServerMock.createResponseFactory,
@ -34,6 +36,14 @@ export const getFindConversationsResultWithSingleHit = (): FindResponse<EsConver
data: getConversationSearchEsMock(),
});
export const getFindAttackDiscoveryResultWithSingleHit =
(): FindResponse<EsAttackDiscoverySchema> => ({
page: 1,
perPage: 1,
total: 1,
data: getAttackDiscoverySearchEsMock(),
});
export const getFindPromptsResultWithSingleHit = (): FindResponse<EsPromptsSchema> => ({
page: 1,
perPage: 1,

View file

@ -0,0 +1,77 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { createAttackDiscovery } from './create_attack_discovery';
import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin-types-common';
import { getAttackDiscovery } from './get_attack_discovery';
import { loggerMock } from '@kbn/logging-mocks';
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
jest.mock('./get_attack_discovery');
const attackDiscoveryCreate: AttackDiscoveryCreateProps = {
attackDiscoveries: [],
apiConfig: {
actionTypeId: 'action-type-id',
connectorId: 'connector-id',
defaultSystemPromptId: 'default-prompt-id',
model: 'model-name',
provider: 'OpenAI',
},
alertsContextCount: 10,
replacements: { key1: 'value1', key2: 'value2' },
status: 'running',
};
const user = {
username: 'test_user',
profile_uid: '1234',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockArgs = {
esClient: mockEsClient,
attackDiscoveryIndex: 'attack-discovery-index',
spaceId: 'space-1',
user,
attackDiscoveryCreate,
logger: mockLogger,
};
const mockGetAttackDiscovery = jest.mocked(getAttackDiscovery);
describe('createAttackDiscovery', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should create attack discovery successfully', async () => {
// @ts-expect-error not full response interface
mockEsClient.create.mockResolvedValueOnce({ _id: 'created_id' });
mockGetAttackDiscovery.mockResolvedValueOnce({
id: 'created_id',
// ... other attack discovery properties
} as AttackDiscoveryResponse);
const response = await createAttackDiscovery(mockArgs);
expect(response).not.toBeNull();
expect(response!.id).toEqual('created_id');
expect(mockEsClient.create).toHaveBeenCalledTimes(1);
expect(mockGetAttackDiscovery).toHaveBeenCalledTimes(1);
});
it('should throw error on elasticsearch create failure', async () => {
mockEsClient.create.mockRejectedValueOnce(new Error('Elasticsearch error'));
await expect(createAttackDiscovery(mockArgs)).rejects.toThrowError('Elasticsearch error');
expect(mockEsClient.create).toHaveBeenCalledTimes(1);
expect(mockGetAttackDiscovery).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,107 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { AttackDiscoveryCreateProps, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin-types-common';
import { getAttackDiscovery } from './get_attack_discovery';
import { CreateAttackDiscoverySchema } from './types';
export interface CreateAttackDiscoveryParams {
esClient: ElasticsearchClient;
logger: Logger;
attackDiscoveryIndex: string;
spaceId: string;
user: AuthenticatedUser;
attackDiscoveryCreate: AttackDiscoveryCreateProps;
}
export const createAttackDiscovery = async ({
esClient,
attackDiscoveryIndex,
spaceId,
user,
attackDiscoveryCreate,
logger,
}: CreateAttackDiscoveryParams): Promise<AttackDiscoveryResponse | null> => {
const createdAt = new Date().toISOString();
const body = transformToCreateScheme(createdAt, spaceId, user, attackDiscoveryCreate);
const id = attackDiscoveryCreate?.id || uuidv4();
try {
const response = await esClient.create({
body,
id,
index: attackDiscoveryIndex,
refresh: 'wait_for',
});
const createdAttackDiscovery = await getAttackDiscovery({
esClient,
attackDiscoveryIndex,
id: response._id,
logger,
user,
});
return createdAttackDiscovery;
} catch (err) {
logger.error(`Error creating attack discovery: ${err} with id: ${id}`);
throw err;
}
};
export const transformToCreateScheme = (
createdAt: string,
spaceId: string,
user: AuthenticatedUser,
{
attackDiscoveries,
apiConfig,
alertsContextCount,
replacements,
status,
}: AttackDiscoveryCreateProps
): CreateAttackDiscoverySchema => {
return {
'@timestamp': createdAt,
created_at: createdAt,
users: [
{
id: user.profile_uid,
name: user.username,
},
],
status,
api_config: {
action_type_id: apiConfig.actionTypeId,
connector_id: apiConfig.connectorId,
default_system_prompt_id: apiConfig.defaultSystemPromptId,
model: apiConfig.model,
provider: apiConfig.provider,
},
alerts_context_count: alertsContextCount,
attack_discoveries: attackDiscoveries?.map((attackDiscovery) => ({
id: attackDiscovery.id,
alert_ids: attackDiscovery.alertIds,
title: attackDiscovery.title,
details_markdown: attackDiscovery.detailsMarkdown,
entity_summary_markdown: attackDiscovery.entitySummaryMarkdown,
mitre_attack_tactics: attackDiscovery.mitreAttackTactics,
summary_markdown: attackDiscovery.summaryMarkdown,
timestamp: attackDiscovery.timestamp ?? createdAt,
})),
updated_at: createdAt,
replacements: replacements
? Object.keys(replacements).map((key) => ({
uuid: key,
value: replacements[key],
}))
: undefined,
namespace: spaceId,
};
};

View file

@ -0,0 +1,181 @@
/*
* 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 { FieldMap } from '@kbn/data-stream-adapter';
export const attackDiscoveryFieldMap: FieldMap = {
'@timestamp': {
type: 'date',
array: false,
required: false,
},
users: {
type: 'nested',
array: true,
required: false,
},
'users.id': {
type: 'keyword',
array: false,
required: true,
},
'users.name': {
type: 'keyword',
array: false,
required: false,
},
id: {
type: 'keyword',
array: false,
required: true,
},
updated_at: {
type: 'date',
array: false,
required: false,
},
created_at: {
type: 'date',
array: false,
required: false,
},
attack_discoveries: {
type: 'nested',
array: true,
required: false,
},
'attack_discoveries.timestamp': {
type: 'date',
array: false,
required: true,
},
'attack_discoveries.details_markdown': {
type: 'text',
array: false,
required: true,
},
'attack_discoveries.title': {
type: 'text',
array: false,
required: true,
},
'attack_discoveries.entity_summary_markdown': {
type: 'text',
array: false,
required: true,
},
'attack_discoveries.summary_markdown': {
type: 'text',
array: false,
required: true,
},
'attack_discoveries.mitre_attack_tactics': {
type: 'keyword',
array: true,
required: false,
},
'attack_discoveries.id': {
type: 'keyword',
required: false,
},
'attack_discoveries.alert_ids': {
type: 'keyword',
array: true,
required: true,
},
replacements: {
type: 'object',
array: false,
required: false,
},
'replacements.value': {
type: 'keyword',
array: false,
required: false,
},
'replacements.uuid': {
type: 'keyword',
array: false,
required: false,
},
api_config: {
type: 'object',
array: false,
required: true,
},
'api_config.connector_id': {
type: 'keyword',
array: false,
required: true,
},
'api_config.action_type_id': {
type: 'keyword',
array: false,
required: false,
},
'api_config.default_system_prompt_id': {
type: 'keyword',
array: false,
required: false,
},
'api_config.provider': {
type: 'keyword',
array: false,
required: false,
},
'api_config.model': {
type: 'keyword',
array: false,
required: false,
},
alerts_context_count: {
type: 'integer',
array: false,
required: false,
},
status: {
type: 'keyword',
array: false,
required: true,
},
namespace: {
type: 'keyword',
array: false,
required: true,
},
average_interval_ms: {
type: 'integer',
array: false,
required: false,
},
failure_reason: {
type: 'keyword',
array: false,
required: false,
},
generation_intervals: {
type: 'nested',
array: true,
required: false,
},
'generation_intervals.date': {
type: 'date',
array: false,
required: true,
},
'generation_intervals.duration_ms': {
type: 'integer',
array: false,
required: true,
},
} as const;

View file

@ -0,0 +1,69 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id';
import { AuthenticatedUser } from '@kbn/core-security-common';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
const mockResponse = getAttackDiscoverySearchEsMock();
const user = {
username: 'test_user',
profile_uid: '1234',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockRequest = {
esClient: mockEsClient,
attackDiscoveryIndex: 'attack-discovery-index',
connectorId: 'connector-id',
user,
logger: mockLogger,
};
describe('findAttackDiscoveryByConnectorId', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should find attack discovery by connector id successfully', async () => {
mockEsClient.search.mockResolvedValueOnce(mockResponse);
const response = await findAttackDiscoveryByConnectorId(mockRequest);
expect(response).not.toBeNull();
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should return null if no attack discovery found', async () => {
mockEsClient.search.mockResolvedValueOnce({ ...mockResponse, hits: { hits: [] } });
const response = await findAttackDiscoveryByConnectorId(mockRequest);
expect(response).toBeNull();
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should throw error on elasticsearch search failure', async () => {
mockEsClient.search.mockRejectedValueOnce(new Error('Elasticsearch error'));
await expect(findAttackDiscoveryByConnectorId(mockRequest)).rejects.toThrowError(
'Elasticsearch error'
);
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import { EsAttackDiscoverySchema } from './types';
import { transformESSearchToAttackDiscovery } from './transforms';
export interface FindAttackDiscoveryParams {
esClient: ElasticsearchClient;
logger: Logger;
attackDiscoveryIndex: string;
connectorId: string;
user: AuthenticatedUser;
}
export const findAttackDiscoveryByConnectorId = async ({
esClient,
logger,
attackDiscoveryIndex,
connectorId,
user,
}: FindAttackDiscoveryParams): Promise<AttackDiscoveryResponse | null> => {
const filterByUser = [
{
nested: {
path: 'users',
query: {
bool: {
must: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
},
},
},
},
];
try {
const response = await esClient.search<EsAttackDiscoverySchema>({
query: {
bool: {
must: [
{
bool: {
should: [
{
term: {
'api_config.connector_id': connectorId,
},
},
],
},
},
...filterByUser,
],
},
},
_source: true,
ignore_unavailable: true,
index: attackDiscoveryIndex,
seq_no_primary_term: true,
});
const attackDiscovery = transformESSearchToAttackDiscovery(response);
return attackDiscovery[0] ?? null;
} catch (err) {
logger.error(`Error fetching attack discovery: ${err} with connectorId: ${connectorId}`);
throw err;
}
};

View file

@ -0,0 +1,67 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { getAttackDiscovery } from './get_attack_discovery';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
import { AuthenticatedUser } from '@kbn/core-security-common';
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
const mockResponse = getAttackDiscoverySearchEsMock();
const user = {
username: 'test_user',
profile_uid: '1234',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockRequest = {
esClient: mockEsClient,
attackDiscoveryIndex: 'attack-discovery-index',
id: 'discovery-id',
user,
logger: mockLogger,
};
describe('getAttackDiscovery', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get attack discovery by id successfully', async () => {
mockEsClient.search.mockResolvedValueOnce(mockResponse);
const response = await getAttackDiscovery(mockRequest);
expect(response).not.toBeNull();
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should return null if no attack discovery found', async () => {
mockEsClient.search.mockResolvedValueOnce({ ...mockResponse, hits: { hits: [] } });
const response = await getAttackDiscovery(mockRequest);
expect(response).toBeNull();
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).not.toHaveBeenCalled();
});
it('should throw error on elasticsearch search failure', async () => {
mockEsClient.search.mockRejectedValueOnce(new Error('Elasticsearch error'));
await expect(getAttackDiscovery(mockRequest)).rejects.toThrowError('Elasticsearch error');
expect(mockEsClient.search).toHaveBeenCalledTimes(1);
expect(mockLogger.error).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import { EsAttackDiscoverySchema } from './types';
import { transformESSearchToAttackDiscovery } from './transforms';
export interface GetAttackDiscoveryParams {
esClient: ElasticsearchClient;
logger: Logger;
attackDiscoveryIndex: string;
id: string;
user: AuthenticatedUser;
}
export const getAttackDiscovery = async ({
esClient,
logger,
attackDiscoveryIndex,
id,
user,
}: GetAttackDiscoveryParams): Promise<AttackDiscoveryResponse | null> => {
const filterByUser = [
{
nested: {
path: 'users',
query: {
bool: {
must: [
{
match: user.profile_uid
? { 'users.id': user.profile_uid }
: { 'users.name': user.username },
},
],
},
},
},
},
];
try {
const response = await esClient.search<EsAttackDiscoverySchema>({
query: {
bool: {
must: [
{
bool: {
should: [
{
term: {
_id: id,
},
},
],
},
},
...filterByUser,
],
},
},
_source: true,
ignore_unavailable: true,
index: attackDiscoveryIndex,
seq_no_primary_term: true,
});
const attackDiscovery = transformESSearchToAttackDiscovery(response);
return attackDiscovery[0] ?? null;
} catch (err) {
logger.error(`Error fetching attack discovery: ${err} with id: ${id}`);
throw err;
}
};

View file

@ -0,0 +1,122 @@
/*
* 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 {
AttackDiscoveryCreateProps,
AttackDiscoveryUpdateProps,
AttackDiscoveryResponse,
} from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/core-security-common';
import { findAttackDiscoveryByConnectorId } from './find_attack_discovery_by_connector_id';
import { updateAttackDiscovery } from './update_attack_discovery';
import { createAttackDiscovery } from './create_attack_discovery';
import { getAttackDiscovery } from './get_attack_discovery';
import { AIAssistantDataClient, AIAssistantDataClientParams } from '..';
type AttackDiscoveryDataClientParams = AIAssistantDataClientParams;
export class AttackDiscoveryDataClient extends AIAssistantDataClient {
constructor(public readonly options: AttackDiscoveryDataClientParams) {
super(options);
}
/**
* Fetches an attack discovery
* @param options
* @param options.id The existing attack discovery id.
* @param options.authenticatedUser Current authenticated user.
* @returns The attack discovery response
*/
public getAttackDiscovery = async ({
id,
authenticatedUser,
}: {
id: string;
authenticatedUser: AuthenticatedUser;
}): Promise<AttackDiscoveryResponse | null> => {
const esClient = await this.options.elasticsearchClientPromise;
return getAttackDiscovery({
esClient,
logger: this.options.logger,
attackDiscoveryIndex: this.indexTemplateAndPattern.alias,
id,
user: authenticatedUser,
});
};
/**
* Creates an attack discovery, if given at least the "apiConfig"
* @param options
* @param options.attackDiscoveryCreate
* @param options.authenticatedUser
* @returns The Attack Discovery created
*/
public createAttackDiscovery = async ({
attackDiscoveryCreate,
authenticatedUser,
}: {
attackDiscoveryCreate: AttackDiscoveryCreateProps;
authenticatedUser: AuthenticatedUser;
}): Promise<AttackDiscoveryResponse | null> => {
const esClient = await this.options.elasticsearchClientPromise;
return createAttackDiscovery({
esClient,
logger: this.options.logger,
attackDiscoveryIndex: this.indexTemplateAndPattern.alias,
spaceId: this.spaceId,
user: authenticatedUser,
attackDiscoveryCreate,
});
};
/**
* Find attack discovery by apiConfig connectorId
* @param options
* @param options.connectorId
* @param options.authenticatedUser
* @returns The Attack Discovery created
*/
public findAttackDiscoveryByConnectorId = async ({
connectorId,
authenticatedUser,
}: {
connectorId: string;
authenticatedUser: AuthenticatedUser;
}): Promise<AttackDiscoveryResponse | null> => {
const esClient = await this.options.elasticsearchClientPromise;
return findAttackDiscoveryByConnectorId({
esClient,
logger: this.options.logger,
attackDiscoveryIndex: this.indexTemplateAndPattern.alias,
connectorId,
user: authenticatedUser,
});
};
/**
* Updates an attack discovery
* @param options
* @param options.attackDiscoveryUpdateProps
* @param options.authenticatedUser
*/
public updateAttackDiscovery = async ({
attackDiscoveryUpdateProps,
authenticatedUser,
}: {
attackDiscoveryUpdateProps: AttackDiscoveryUpdateProps;
authenticatedUser: AuthenticatedUser;
}): Promise<AttackDiscoveryResponse | null> => {
const esClient = await this.options.elasticsearchClientPromise;
return updateAttackDiscovery({
esClient,
logger: this.options.logger,
attackDiscoveryIndex: attackDiscoveryUpdateProps.backingIndex,
attackDiscoveryUpdateProps,
user: authenticatedUser,
});
};
}

View file

@ -0,0 +1,65 @@
/*
* 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 { estypes } from '@elastic/elasticsearch';
import { AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import { EsAttackDiscoverySchema } from './types';
export const transformESSearchToAttackDiscovery = (
response: estypes.SearchResponse<EsAttackDiscoverySchema>
): AttackDiscoveryResponse[] => {
return response.hits.hits
.filter((hit) => hit._source !== undefined)
.map((hit) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const adSchema = hit._source!;
const ad: AttackDiscoveryResponse = {
timestamp: adSchema['@timestamp'],
id: hit._id,
backingIndex: hit._index,
createdAt: adSchema.created_at,
updatedAt: adSchema.updated_at,
users:
adSchema.users?.map((user) => ({
id: user.id,
name: user.name,
})) ?? [],
namespace: adSchema.namespace,
status: adSchema.status,
alertsContextCount: adSchema.alerts_context_count,
apiConfig: {
connectorId: adSchema.api_config.connector_id,
actionTypeId: adSchema.api_config.action_type_id,
defaultSystemPromptId: adSchema.api_config.default_system_prompt_id,
model: adSchema.api_config.model,
provider: adSchema.api_config.provider,
},
attackDiscoveries: adSchema.attack_discoveries.map((attackDiscovery) => ({
alertIds: attackDiscovery.alert_ids,
title: attackDiscovery.title,
detailsMarkdown: attackDiscovery.details_markdown,
entitySummaryMarkdown: attackDiscovery.entity_summary_markdown,
mitreAttackTactics: attackDiscovery.mitre_attack_tactics,
summaryMarkdown: attackDiscovery.summary_markdown,
timestamp: attackDiscovery.timestamp,
})),
replacements: adSchema.replacements?.reduce((acc: Record<string, string>, r) => {
acc[r.uuid] = r.value;
return acc;
}, {}),
generationIntervals:
adSchema.generation_intervals?.map((interval) => ({
date: interval.date,
durationMs: interval.duration_ms,
})) ?? [],
averageIntervalMs: adSchema.average_interval_ms ?? 0,
failureReason: adSchema.failure_reason,
};
return ad;
});
};

View file

@ -0,0 +1,76 @@
/*
* 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 { AttackDiscoveryStatus, Provider } from '@kbn/elastic-assistant-common';
import { EsReplacementSchema } from '../conversations/types';
export interface EsAttackDiscoverySchema {
'@timestamp': string;
id: string;
created_at: string;
namespace: string;
attack_discoveries: Array<{
alert_ids: string[];
title: string;
timestamp: string;
details_markdown: string;
entity_summary_markdown: string;
mitre_attack_tactics?: string[];
summary_markdown: string;
id?: string;
}>;
failure_reason?: string;
api_config: {
connector_id: string;
action_type_id: string;
default_system_prompt_id?: string;
provider?: Provider;
model?: string;
};
alerts_context_count?: number;
replacements?: EsReplacementSchema[];
status: AttackDiscoveryStatus;
updated_at?: string;
users?: Array<{
id?: string;
name?: string;
}>;
average_interval_ms?: number;
generation_intervals?: Array<{ date: string; duration_ms: number }>;
}
export interface CreateAttackDiscoverySchema {
'@timestamp'?: string;
created_at: string;
id?: string | undefined;
attack_discoveries: Array<{
alert_ids: string[];
title: string;
timestamp: string;
details_markdown: string;
entity_summary_markdown: string;
mitre_attack_tactics?: string[];
summary_markdown: string;
id?: string;
}>;
api_config: {
action_type_id: string;
connector_id: string;
default_system_prompt_id?: string;
provider?: Provider;
model?: string;
};
alerts_context_count?: number;
replacements?: EsReplacementSchema[];
status: AttackDiscoveryStatus;
users: Array<{
id?: string;
name?: string;
}>;
updated_at?: string;
namespace: string;
}

View file

@ -0,0 +1,183 @@
/*
* 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { getAttackDiscovery } from './get_attack_discovery';
import { updateAttackDiscovery } from './update_attack_discovery';
import {
AttackDiscoveryResponse,
AttackDiscoveryStatus,
AttackDiscoveryUpdateProps,
} from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
jest.mock('./get_attack_discovery');
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
const user = {
username: 'test_user',
profile_uid: '1234',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const updateProps: AttackDiscoveryUpdateProps = {
id: 'existing-id',
backingIndex: 'attack-discovery-index',
status: 'succeeded' as AttackDiscoveryStatus,
attackDiscoveries: [
{
alertIds: ['alert-1'],
title: 'Updated Title',
detailsMarkdown: '# Updated Details',
entitySummaryMarkdown: '# Updated Summary',
timestamp: '2024-06-07T21:19:08.090Z',
id: 'existing-id',
mitreAttackTactics: ['T1234'],
summaryMarkdown: '# Updated Summary',
},
],
};
const mockRequest = {
esClient: mockEsClient,
attackDiscoveryIndex: 'attack-discovery-index',
attackDiscoveryUpdateProps: updateProps,
user,
logger: mockLogger,
};
const existingAttackDiscovery: AttackDiscoveryResponse = {
id: 'existing-id',
backingIndex: 'attack-discovery-index',
timestamp: '2024-06-07T18:56:17.357Z',
createdAt: '2024-06-07T18:56:17.357Z',
users: [
{
id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
name: 'elastic',
},
],
status: 'running',
apiConfig: {
actionTypeId: '.gen-ai',
connectorId: 'my-gpt4o-ai',
},
attackDiscoveries: [],
updatedAt: '2024-06-07T21:19:08.090Z',
replacements: {
'f19e1a0a-de3b-496c-8ace-dd91229e1084': 'root',
},
namespace: 'default',
generationIntervals: [
{
date: '2024-06-07T21:19:08.089Z',
durationMs: 110906,
},
{
date: '2024-06-07T20:04:35.715Z',
durationMs: 104593,
},
{
date: '2024-06-07T18:58:27.880Z',
durationMs: 130526,
},
],
alertsContextCount: 20,
averageIntervalMs: 115341,
};
const mockGetDiscovery = getAttackDiscovery as jest.Mock;
describe('updateAttackDiscovery', () => {
const date = '2024-03-28T22:27:28.000Z';
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
jest.setSystemTime(new Date(date));
jest.clearAllMocks();
mockGetDiscovery.mockResolvedValue(existingAttackDiscovery);
});
it('should update attack discovery successfully', async () => {
const response = await updateAttackDiscovery(mockRequest);
expect(response).not.toBeNull();
expect(response!.id).toEqual('existing-id');
expect(mockEsClient.update).toHaveBeenCalledTimes(1);
expect(mockEsClient.update).toHaveBeenCalledWith({
refresh: 'wait_for',
index: 'attack-discovery-index',
id: 'existing-id',
doc: {
attack_discoveries: [
{
id: 'existing-id',
alert_ids: ['alert-1'],
title: 'Updated Title',
details_markdown: '# Updated Details',
entity_summary_markdown: '# Updated Summary',
mitre_attack_tactics: ['T1234'],
summary_markdown: '# Updated Summary',
timestamp: date,
},
],
id: 'existing-id',
status: 'succeeded',
updated_at: date,
},
});
expect(mockGetDiscovery).toHaveBeenCalledTimes(1);
const { attackDiscoveryUpdateProps, ...rest } = mockRequest;
expect(mockGetDiscovery).toHaveBeenCalledWith({
...rest,
id: attackDiscoveryUpdateProps.id,
});
});
it('should not update attack_discoveries if none are present', async () => {
const { attackDiscoveries, ...rest } = mockRequest.attackDiscoveryUpdateProps;
const response = await updateAttackDiscovery({
...mockRequest,
attackDiscoveryUpdateProps: rest,
});
expect(response).not.toBeNull();
expect(response!.id).toEqual('existing-id');
expect(mockEsClient.update).toHaveBeenCalledTimes(1);
expect(mockEsClient.update).toHaveBeenCalledWith({
refresh: 'wait_for',
index: 'attack-discovery-index',
id: 'existing-id',
doc: {
id: 'existing-id',
status: 'succeeded',
updated_at: date,
},
});
expect(mockGetDiscovery).toHaveBeenCalledTimes(1);
const { attackDiscoveryUpdateProps, ...rest2 } = mockRequest;
expect(mockGetDiscovery).toHaveBeenCalledWith({
...rest2,
id: attackDiscoveryUpdateProps.id,
});
});
it('should throw error on elasticsearch update failure', async () => {
const error = new Error('Elasticsearch update error');
mockEsClient.update.mockRejectedValueOnce(error);
await expect(updateAttackDiscovery(mockRequest)).rejects.toThrowError(error);
expect(mockEsClient.update).toHaveBeenCalledTimes(1);
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
expect(mockLogger.warn).toHaveBeenCalledWith(
`Error updating attackDiscovery: ${error} by ID: existing-id`
);
});
});

View file

@ -0,0 +1,155 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import {
AttackDiscoveryResponse,
AttackDiscoveryStatus,
AttackDiscoveryUpdateProps,
Provider,
UUID,
} from '@kbn/elastic-assistant-common';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
import * as uuid from 'uuid';
import { EsReplacementSchema } from '../conversations/types';
import { getAttackDiscovery } from './get_attack_discovery';
export interface UpdateAttackDiscoverySchema {
id: UUID;
'@timestamp'?: string;
attack_discoveries?: Array<{
alert_ids: string[];
title: string;
timestamp: string;
details_markdown: string;
entity_summary_markdown: string;
mitre_attack_tactics?: string[];
summary_markdown: string;
id?: string;
}>;
api_config?: {
action_type_id?: string;
connector_id?: string;
default_system_prompt_id?: string;
provider?: Provider;
model?: string;
};
alerts_context_count?: number;
average_interval_ms?: number;
generation_intervals?: Array<{ date: string; duration_ms: number }>;
replacements?: EsReplacementSchema[];
status: AttackDiscoveryStatus;
updated_at?: string;
failure_reason?: string;
}
export interface UpdateAttackDiscoveryParams {
esClient: ElasticsearchClient;
logger: Logger;
user: AuthenticatedUser;
attackDiscoveryIndex: string;
attackDiscoveryUpdateProps: AttackDiscoveryUpdateProps;
}
export const updateAttackDiscovery = async ({
esClient,
logger,
attackDiscoveryIndex,
attackDiscoveryUpdateProps,
user,
}: UpdateAttackDiscoveryParams): Promise<AttackDiscoveryResponse | null> => {
const updatedAt = new Date().toISOString();
const params = transformToUpdateScheme(updatedAt, attackDiscoveryUpdateProps);
try {
await esClient.update({
refresh: 'wait_for',
index: attackDiscoveryIndex,
id: params.id,
doc: params,
});
const updatedAttackDiscovery = await getAttackDiscovery({
esClient,
attackDiscoveryIndex,
id: params.id,
logger,
user,
});
return updatedAttackDiscovery;
} catch (err) {
logger.warn(`Error updating attackDiscovery: ${err} by ID: ${params.id}`);
throw err;
}
};
export const transformToUpdateScheme = (
updatedAt: string,
{
alertsContextCount,
apiConfig,
attackDiscoveries,
failureReason,
generationIntervals,
id,
replacements,
status,
}: AttackDiscoveryUpdateProps
): UpdateAttackDiscoverySchema => {
const averageIntervalMsObj =
generationIntervals && generationIntervals.length > 0
? {
average_interval_ms: Math.trunc(
generationIntervals.reduce((acc, interval) => acc + interval.durationMs, 0) /
generationIntervals.length
),
generation_intervals: generationIntervals.map((interval) => ({
date: interval.date,
duration_ms: interval.durationMs,
})),
}
: {};
return {
alerts_context_count: alertsContextCount,
...(apiConfig
? {
api_config: {
action_type_id: apiConfig.actionTypeId,
connector_id: apiConfig.connectorId,
default_system_prompt_id: apiConfig.defaultSystemPromptId,
model: apiConfig.model,
provider: apiConfig.provider,
},
}
: {}),
...(attackDiscoveries
? {
attack_discoveries: attackDiscoveries.map((attackDiscovery) => ({
id: attackDiscovery.id ?? uuid.v4(),
alert_ids: attackDiscovery.alertIds,
title: attackDiscovery.title,
details_markdown: attackDiscovery.detailsMarkdown,
entity_summary_markdown: attackDiscovery.entitySummaryMarkdown,
mitre_attack_tactics: attackDiscovery.mitreAttackTactics,
summary_markdown: attackDiscovery.summaryMarkdown,
timestamp: updatedAt,
})),
}
: {}),
failure_reason: failureReason,
id,
replacements: replacements
? Object.keys(replacements).map((key) => ({
uuid: key,
value: replacements[key],
}))
: undefined,
status,
updated_at: updatedAt,
...averageIntervalMsObj,
};
};

View file

@ -135,12 +135,18 @@ describe('AI Assistant Service', () => {
);
expect(assistantService.isInitialized()).toEqual(true);
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(3);
const componentTemplate = clusterClient.cluster.putComponentTemplate.mock.calls[0][0];
expect(componentTemplate.name).toEqual(
'.kibana-elastic-ai-assistant-component-template-conversations'
);
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(4);
const expectedTemplates = [
'.kibana-elastic-ai-assistant-component-template-conversations',
'.kibana-elastic-ai-assistant-component-template-prompts',
'.kibana-elastic-ai-assistant-component-template-anonymization-fields',
'.kibana-elastic-ai-assistant-component-template-attack-discovery',
];
expectedTemplates.forEach((t, i) => {
expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t);
});
});
test('should log error and set initialized to false if creating/updating common component template throws error', async () => {
@ -628,7 +634,19 @@ describe('AI Assistant Service', () => {
'AI Assistant service initialized',
async () => assistantService.isInitialized() === true
);
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(5);
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(6);
const expectedTemplates = [
'.kibana-elastic-ai-assistant-component-template-conversations',
'.kibana-elastic-ai-assistant-component-template-conversations',
'.kibana-elastic-ai-assistant-component-template-conversations',
'.kibana-elastic-ai-assistant-component-template-prompts',
'.kibana-elastic-ai-assistant-component-template-anonymization-fields',
'.kibana-elastic-ai-assistant-component-template-attack-discovery',
];
expectedTemplates.forEach((t, i) => {
expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t);
});
});
test('should retry updating index template for transient ES errors', async () => {
@ -649,7 +667,18 @@ describe('AI Assistant Service', () => {
async () => (await getSpaceResourcesInitialized(assistantService)) === true
);
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(5);
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(6);
const expectedTemplates = [
'.kibana-elastic-ai-assistant-index-template-conversations',
'.kibana-elastic-ai-assistant-index-template-conversations',
'.kibana-elastic-ai-assistant-index-template-conversations',
'.kibana-elastic-ai-assistant-index-template-prompts',
'.kibana-elastic-ai-assistant-index-template-anonymization-fields',
'.kibana-elastic-ai-assistant-index-template-attack-discovery',
];
expectedTemplates.forEach((t, i) => {
expect(clusterClient.indices.putIndexTemplate.mock.calls[i][0].name).toEqual(t);
});
});
test('should retry updating index settings for existing indices for transient ES errors', async () => {
@ -669,7 +698,7 @@ describe('AI Assistant Service', () => {
async () => (await getSpaceResourcesInitialized(assistantService)) === true
);
expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(5);
expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(6);
});
test('should retry updating index mappings for existing indices for transient ES errors', async () => {
@ -689,7 +718,7 @@ describe('AI Assistant Service', () => {
async () => (await getSpaceResourcesInitialized(assistantService)) === true
);
expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(5);
expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(6);
});
test('should retry creating concrete index for transient ES errors', async () => {

View file

@ -12,6 +12,7 @@ import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server';
import type { MlPluginSetup } from '@kbn/ml-plugin/server';
import { AuthenticatedUser } from '@kbn/security-plugin/server';
import { Subject } from 'rxjs';
import { attackDiscoveryFieldMap } from '../ai_assistant_data_clients/attack_discovery/field_maps_configuration';
import { getDefaultAnonymizationFields } from '../../common/anonymization';
import { AssistantResourceNames, GetElser } from '../types';
import { AIAssistantConversationsDataClient } from '../ai_assistant_data_clients/conversations';
@ -28,6 +29,7 @@ import { assistantAnonymizationFieldsFieldMap } from '../ai_assistant_data_clien
import { AIAssistantDataClient } from '../ai_assistant_data_clients';
import { knowledgeBaseFieldMap } from '../ai_assistant_data_clients/knowledge_base/field_maps_configuration';
import { AIAssistantKnowledgeBaseDataClient } from '../ai_assistant_data_clients/knowledge_base';
import { AttackDiscoveryDataClient } from '../ai_assistant_data_clients/attack_discovery';
import { createGetElserId, createPipeline, pipelineExists } from './helpers';
const TOTAL_FIELDS_LIMIT = 2500;
@ -52,7 +54,12 @@ export interface CreateAIAssistantClientParams {
}
export type CreateDataStream = (params: {
resource: 'anonymizationFields' | 'conversations' | 'knowledgeBase' | 'prompts';
resource:
| 'anonymizationFields'
| 'conversations'
| 'knowledgeBase'
| 'prompts'
| 'attackDiscovery';
fieldMap: FieldMap;
kibanaVersion: string;
spaceId?: string;
@ -68,6 +75,7 @@ export class AIAssistantService {
private knowledgeBaseDataStream: DataStreamSpacesAdapter;
private promptsDataStream: DataStreamSpacesAdapter;
private anonymizationFieldsDataStream: DataStreamSpacesAdapter;
private attackDiscoveryDataStream: DataStreamSpacesAdapter;
private resourceInitializationHelper: ResourceInstallationHelper;
private initPromise: Promise<InitializationPromise>;
private isKBSetupInProgress: boolean = false;
@ -95,6 +103,11 @@ export class AIAssistantService {
kibanaVersion: options.kibanaVersion,
fieldMap: assistantAnonymizationFieldsFieldMap,
});
this.attackDiscoveryDataStream = this.createDataStream({
resource: 'attackDiscovery',
kibanaVersion: options.kibanaVersion,
fieldMap: attackDiscoveryFieldMap,
});
this.initPromise = this.initializeResources();
@ -201,6 +214,12 @@ export class AIAssistantService {
logger: this.options.logger,
pluginStop$: this.options.pluginStop$,
});
await this.attackDiscoveryDataStream.install({
esClient,
logger: this.options.logger,
pluginStop$: this.options.pluginStop$,
});
} catch (error) {
this.options.logger.error(`Error initializing AI assistant resources: ${error.message}`);
this.initialized = false;
@ -218,24 +237,28 @@ export class AIAssistantService {
knowledgeBase: getResourceName('component-template-knowledge-base'),
prompts: getResourceName('component-template-prompts'),
anonymizationFields: getResourceName('component-template-anonymization-fields'),
attackDiscovery: getResourceName('component-template-attack-discovery'),
},
aliases: {
conversations: getResourceName('conversations'),
knowledgeBase: getResourceName('knowledge-base'),
prompts: getResourceName('prompts'),
anonymizationFields: getResourceName('anonymization-fields'),
attackDiscovery: getResourceName('attack-discovery'),
},
indexPatterns: {
conversations: getResourceName('conversations*'),
knowledgeBase: getResourceName('knowledge-base*'),
prompts: getResourceName('prompts*'),
anonymizationFields: getResourceName('anonymization-fields*'),
attackDiscovery: getResourceName('attack-discovery*'),
},
indexTemplate: {
conversations: getResourceName('index-template-conversations'),
knowledgeBase: getResourceName('index-template-knowledge-base'),
prompts: getResourceName('index-template-prompts'),
anonymizationFields: getResourceName('index-template-anonymization-fields'),
attackDiscovery: getResourceName('index-template-attack-discovery'),
},
pipelines: {
knowledgeBase: getResourceName('ingest-pipeline-knowledge-base'),
@ -338,6 +361,25 @@ export class AIAssistantService {
});
}
public async createAttackDiscoveryDataClient(
opts: CreateAIAssistantClientParams
): Promise<AttackDiscoveryDataClient | null> {
const res = await this.checkResourcesInstallation(opts);
if (res === null) {
return null;
}
return new AttackDiscoveryDataClient({
logger: this.options.logger.get('attackDiscovery'),
currentUser: opts.currentUser,
elasticsearchClientPromise: this.options.elasticsearchClientPromise,
indexPatternsResourceName: this.resourceNames.aliases.attackDiscovery,
kibanaVersion: this.options.kibanaVersion,
spaceId: opts.spaceId,
});
}
public async createAIAssistantPromptsDataClient(
opts: CreateAIAssistantClientParams
): Promise<AIAssistantDataClient | null> {

View file

@ -163,9 +163,121 @@ export const INVOKE_ASSISTANT_ERROR_EVENT: EventTypeOpts<{
},
};
export const ATTACK_DISCOVERY_SUCCESS_EVENT: EventTypeOpts<{
actionTypeId: string;
alertsContextCount: number;
alertsCount: number;
configuredAlertsCount: number;
discoveriesGenerated: number;
durationMs: number;
model?: string;
provider?: string;
}> = {
eventType: 'attack_discovery_success',
schema: {
actionTypeId: {
type: 'keyword',
_meta: {
description: 'Kibana connector type',
optional: false,
},
},
alertsContextCount: {
type: 'integer',
_meta: {
description: 'Number of alerts sent as context to the LLM',
optional: false,
},
},
alertsCount: {
type: 'integer',
_meta: {
description: 'Number of unique alerts referenced in the attack discoveries',
optional: false,
},
},
configuredAlertsCount: {
type: 'integer',
_meta: {
description: 'Number of alerts configured by the user',
optional: false,
},
},
discoveriesGenerated: {
type: 'integer',
_meta: {
description: 'Quantity of attack discoveries generated',
optional: false,
},
},
durationMs: {
type: 'integer',
_meta: {
description: 'Duration of request in ms',
optional: false,
},
},
model: {
type: 'keyword',
_meta: {
description: 'LLM model',
optional: true,
},
},
provider: {
type: 'keyword',
_meta: {
description: 'OpenAI provider',
optional: true,
},
},
},
};
export const ATTACK_DISCOVERY_ERROR_EVENT: EventTypeOpts<{
actionTypeId: string;
errorMessage: string;
model?: string;
provider?: string;
}> = {
eventType: 'attack_discovery_error',
schema: {
actionTypeId: {
type: 'keyword',
_meta: {
description: 'Kibana connector type',
optional: false,
},
},
errorMessage: {
type: 'keyword',
_meta: {
description: 'Error message from Elasticsearch',
},
},
model: {
type: 'keyword',
_meta: {
description: 'LLM model',
optional: true,
},
},
provider: {
type: 'keyword',
_meta: {
description: 'OpenAI provider',
optional: true,
},
},
},
};
export const events: Array<EventTypeOpts<{ [key: string]: unknown }>> = [
KNOWLEDGE_BASE_EXECUTION_SUCCESS_EVENT,
KNOWLEDGE_BASE_EXECUTION_ERROR_EVENT,
INVOKE_ASSISTANT_SUCCESS_EVENT,
INVOKE_ASSISTANT_ERROR_EVENT,
ATTACK_DISCOVERY_SUCCESS_EVENT,
ATTACK_DISCOVERY_ERROR_EVENT,
];

View file

@ -0,0 +1,107 @@
/*
* 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 { cancelAttackDiscoveryRoute } from './cancel_attack_discovery';
import { AuthenticatedUser } from '@kbn/core-security-common';
import { serverMock } from '../../__mocks__/server';
import { requestContextMock } from '../../__mocks__/request_context';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery';
import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
import { getCancelAttackDiscoveryRequest } from '../../__mocks__/request';
import { updateAttackDiscoveryStatusToCanceled } from './helpers';
jest.mock('./helpers');
const { clients, context } = requestContextMock.createTools();
const server: ReturnType<typeof serverMock.create> = serverMock.create();
clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient();
const mockUser = {
username: 'my_username',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockDataClient = {
findAttackDiscoveryByConnectorId: jest.fn(),
updateAttackDiscovery: jest.fn(),
createAttackDiscovery: jest.fn(),
getAttackDiscovery: jest.fn(),
} as unknown as AttackDiscoveryDataClient;
const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0];
describe('cancelAttackDiscoveryRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
(updateAttackDiscoveryStatusToCanceled as jest.Mock).mockResolvedValue({
...mockCurrentAd,
status: 'canceled',
});
context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser);
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(mockDataClient);
cancelAttackDiscoveryRoute(server.router);
});
it('should handle successful request', async () => {
const response = await server.inject(
getCancelAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
...mockCurrentAd,
status: 'canceled',
});
});
it('should handle missing authenticated user', async () => {
context.elasticAssistant.getCurrentUser.mockReturnValue(null);
const response = await server.inject(
getCancelAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(401);
expect(response.body).toEqual({
message: 'Authenticated user not found',
status_code: 401,
});
});
it('should handle missing data client', async () => {
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(null);
const response = await server.inject(
getCancelAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: 'Attack discovery data client not initialized',
status_code: 500,
});
});
it('should handle updateAttackDiscoveryStatusToCanceled error', async () => {
(updateAttackDiscoveryStatusToCanceled as jest.Mock).mockRejectedValue(new Error('Oh no!'));
const response = await server.inject(
getCancelAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: {
error: 'Oh no!',
success: false,
},
status_code: 500,
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server';
import {
AttackDiscoveryCancelResponse,
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
AttackDiscoveryCancelRequestParams,
} from '@kbn/elastic-assistant-common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { updateAttackDiscoveryStatusToCanceled } from './helpers';
import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../common/constants';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../types';
export const cancelAttackDiscoveryRoute = (
router: IRouter<ElasticAssistantRequestHandlerContext>
) => {
router.versioned
.put({
access: 'internal',
path: ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
options: {
tags: ['access:elasticAssistant'],
},
})
.addVersion(
{
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
validate: {
request: {
params: buildRouteValidationWithZod(AttackDiscoveryCancelRequestParams),
},
response: {
200: {
body: { custom: buildRouteValidationWithZod(AttackDiscoveryCancelResponse) },
},
},
},
},
async (
context,
request,
response
): Promise<IKibanaResponse<AttackDiscoveryCancelResponse>> => {
const resp = buildResponse(response);
const assistantContext = await context.elasticAssistant;
const logger: Logger = assistantContext.logger;
try {
const dataClient = await assistantContext.getAttackDiscoveryDataClient();
const authenticatedUser = assistantContext.getCurrentUser();
const connectorId = decodeURIComponent(request.params.connectorId);
if (authenticatedUser == null) {
return resp.error({
body: `Authenticated user not found`,
statusCode: 401,
});
}
if (!dataClient) {
return resp.error({
body: `Attack discovery data client not initialized`,
statusCode: 500,
});
}
const attackDiscovery = await updateAttackDiscoveryStatusToCanceled(
dataClient,
authenticatedUser,
connectorId
);
return response.ok({
body: attackDiscovery,
});
} catch (err) {
logger.error(err);
const error = transformError(err);
return resp.error({
body: { success: false, error: error.message },
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -0,0 +1,116 @@
/*
* 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 { getAttackDiscoveryRoute } from './get_attack_discovery';
import { AuthenticatedUser } from '@kbn/core-security-common';
import { serverMock } from '../../__mocks__/server';
import { requestContextMock } from '../../__mocks__/request_context';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery';
import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
import { getAttackDiscoveryRequest } from '../../__mocks__/request';
jest.mock('./helpers');
const { clients, context } = requestContextMock.createTools();
const server: ReturnType<typeof serverMock.create> = serverMock.create();
clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient();
const mockUser = {
username: 'my_username',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const findAttackDiscoveryByConnectorId = jest.fn();
const mockDataClient = {
findAttackDiscoveryByConnectorId,
updateAttackDiscovery: jest.fn(),
createAttackDiscovery: jest.fn(),
getAttackDiscovery: jest.fn(),
} as unknown as AttackDiscoveryDataClient;
const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0];
describe('getAttackDiscoveryRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser);
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(mockDataClient);
getAttackDiscoveryRoute(server.router);
findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd);
});
it('should handle successful request', async () => {
const response = await server.inject(
getAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
data: mockCurrentAd,
entryExists: true,
});
});
it('should handle missing authenticated user', async () => {
context.elasticAssistant.getCurrentUser.mockReturnValue(null);
const response = await server.inject(
getAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(401);
expect(response.body).toEqual({
message: 'Authenticated user not found',
status_code: 401,
});
});
it('should handle missing data client', async () => {
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(null);
const response = await server.inject(
getAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: 'Attack discovery data client not initialized',
status_code: 500,
});
});
it('should handle findAttackDiscoveryByConnectorId null response', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
const response = await server.inject(
getAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
entryExists: false,
});
});
it('should handle findAttackDiscoveryByConnectorId error', async () => {
findAttackDiscoveryByConnectorId.mockRejectedValue(new Error('Oh no!'));
const response = await server.inject(
getAttackDiscoveryRequest('connector-id'),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: {
error: 'Oh no!',
success: false,
},
status_code: 500,
});
});
});

View file

@ -0,0 +1,92 @@
/*
* 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 { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { type IKibanaResponse, IRouter, Logger } from '@kbn/core/server';
import {
AttackDiscoveryGetResponse,
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
AttackDiscoveryGetRequestParams,
} from '@kbn/elastic-assistant-common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../common/constants';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../types';
export const getAttackDiscoveryRoute = (router: IRouter<ElasticAssistantRequestHandlerContext>) => {
router.versioned
.get({
access: 'internal',
path: ATTACK_DISCOVERY_BY_CONNECTOR_ID,
options: {
tags: ['access:elasticAssistant'],
},
})
.addVersion(
{
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
validate: {
request: {
params: buildRouteValidationWithZod(AttackDiscoveryGetRequestParams),
},
response: {
200: {
body: { custom: buildRouteValidationWithZod(AttackDiscoveryGetResponse) },
},
},
},
},
async (context, request, response): Promise<IKibanaResponse<AttackDiscoveryGetResponse>> => {
const resp = buildResponse(response);
const assistantContext = await context.elasticAssistant;
const logger: Logger = assistantContext.logger;
try {
const dataClient = await assistantContext.getAttackDiscoveryDataClient();
const authenticatedUser = assistantContext.getCurrentUser();
const connectorId = decodeURIComponent(request.params.connectorId);
if (authenticatedUser == null) {
return resp.error({
body: `Authenticated user not found`,
statusCode: 401,
});
}
if (!dataClient) {
return resp.error({
body: `Attack discovery data client not initialized`,
statusCode: 500,
});
}
const attackDiscovery = await dataClient.findAttackDiscoveryByConnectorId({
connectorId,
authenticatedUser,
});
return response.ok({
body:
attackDiscovery != null
? {
data: attackDiscovery,
entryExists: true,
}
: {
entryExists: false,
},
});
} catch (err) {
logger.error(err);
const error = transformError(err);
return resp.error({
body: { success: false, error: error.message },
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -0,0 +1,491 @@
/*
* 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 { AuthenticatedUser } from '@kbn/core-security-common';
import moment from 'moment';
import {
REQUIRED_FOR_ATTACK_DISCOVERY,
addGenerationInterval,
attackDiscoveryStatus,
getAssistantToolParams,
handleToolError,
updateAttackDiscoveryStatusToCanceled,
updateAttackDiscoveryStatusToRunning,
updateAttackDiscoveries,
} from './helpers';
import { ActionsClientLlm } from '@kbn/langchain/server';
import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { KibanaRequest } from '@kbn/core-http-server';
import {
AttackDiscoveryPostRequestBody,
ExecuteConnectorRequestBody,
} from '@kbn/elastic-assistant-common';
import { coreMock } from '@kbn/core/server/mocks';
import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
jest.mock('lodash/fp', () => ({
uniq: jest.fn((arr) => Array.from(new Set(arr))),
}));
jest.mock('@kbn/securitysolution-es-utils', () => ({
transformError: jest.fn((err) => err),
}));
jest.mock('@kbn/langchain/server', () => ({
ActionsClientLlm: jest.fn(),
}));
jest.mock('../evaluate/utils', () => ({
getLangSmithTracer: jest.fn().mockReturnValue([]),
}));
jest.mock('../utils', () => ({
getLlmType: jest.fn().mockReturnValue('llm-type'),
}));
const findAttackDiscoveryByConnectorId = jest.fn();
const updateAttackDiscovery = jest.fn();
const createAttackDiscovery = jest.fn();
const getAttackDiscovery = jest.fn();
const mockDataClient = {
findAttackDiscoveryByConnectorId,
updateAttackDiscovery,
createAttackDiscovery,
getAttackDiscovery,
} as unknown as AttackDiscoveryDataClient;
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
const mockLogger = loggerMock.create();
const mockTelemetry = coreMock.createSetup().analytics;
const mockError = new Error('Test error');
const mockAuthenticatedUser = {
username: 'user',
profile_uid: '1234',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
provider: OpenAiProviderType.OpenAi,
};
const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0];
describe('helpers', () => {
const date = '2024-03-28T22:27:28.000Z';
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
jest.setSystemTime(new Date(date));
getAttackDiscovery.mockResolvedValue(mockCurrentAd);
updateAttackDiscovery.mockResolvedValue({});
});
describe('getAssistantToolParams', () => {
const mockParams = {
actions: {} as unknown as ActionsPluginStart,
alertsIndexPattern: 'alerts-*',
anonymizationFields: [{ id: '1', field: 'field1', allowed: true, anonymized: true }],
apiConfig: mockApiConfig,
esClient: mockEsClient,
connectorTimeout: 1000,
langChainTimeout: 2000,
langSmithProject: 'project',
langSmithApiKey: 'api-key',
logger: mockLogger,
latestReplacements: {},
onNewReplacements: jest.fn(),
request: {} as KibanaRequest<
unknown,
unknown,
ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody
>,
size: 10,
};
it('should return formatted assistant tool params', () => {
const result = getAssistantToolParams(mockParams);
expect(ActionsClientLlm).toHaveBeenCalledWith(
expect.objectContaining({
connectorId: 'connector-id',
llmType: 'llm-type',
})
);
expect(result.anonymizationFields).toEqual([
...mockParams.anonymizationFields,
...REQUIRED_FOR_ATTACK_DISCOVERY,
]);
});
});
describe('addGenerationInterval', () => {
const generationInterval = { date: '2024-01-01T00:00:00Z', durationMs: 1000 };
const existingIntervals = [
{ date: '2024-01-02T00:00:00Z', durationMs: 2000 },
{ date: '2024-01-03T00:00:00Z', durationMs: 3000 },
];
it('should add new interval and maintain length within MAX_GENERATION_INTERVALS', () => {
const result = addGenerationInterval(existingIntervals, generationInterval);
expect(result.length).toBeLessThanOrEqual(5);
expect(result).toContain(generationInterval);
});
it('should remove the oldest interval if exceeding MAX_GENERATION_INTERVALS', () => {
const longExistingIntervals = [...Array(5)].map((_, i) => ({
date: `2024-01-0${i + 2}T00:00:00Z`,
durationMs: (i + 2) * 1000,
}));
const result = addGenerationInterval(longExistingIntervals, generationInterval);
expect(result.length).toBe(5);
expect(result).not.toContain(longExistingIntervals[4]);
});
});
describe('updateAttackDiscoveryStatusToRunning', () => {
it('should update existing attack discovery to running', async () => {
const existingAd = { id: 'existing-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual({ attackDiscoveryId: existingAd.id, currentAd: existingAd });
});
it('should create a new attack discovery if none exists', async () => {
const newAd = { id: 'new-id', backingIndex: 'index' };
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(newAd);
const result = await updateAttackDiscoveryStatusToRunning(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig
);
expect(createAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryCreate: expect.objectContaining({
status: attackDiscoveryStatus.running,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual({ attackDiscoveryId: newAd.id, currentAd: newAd });
});
it('should throw an error if updating or creating attack discovery fails', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
createAttackDiscovery.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToRunning(mockDataClient, mockAuthenticatedUser, mockApiConfig)
).rejects.toThrow('Could not create attack discovery for connectorId: connector-id');
});
});
describe('updateAttackDiscoveryStatusToCanceled', () => {
const existingAd = {
id: 'existing-id',
backingIndex: 'index',
status: attackDiscoveryStatus.running,
};
it('should update existing attack discovery to canceled', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(existingAd);
const result = await updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
);
expect(findAttackDiscoveryByConnectorId).toHaveBeenCalledWith({
connectorId: mockApiConfig.connectorId,
authenticatedUser: mockAuthenticatedUser,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: expect.objectContaining({
status: attackDiscoveryStatus.canceled,
}),
authenticatedUser: mockAuthenticatedUser,
});
expect(result).toEqual(existingAd);
});
it('should throw an error if attack discovery is not running', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue({
...existingAd,
status: attackDiscoveryStatus.succeeded,
});
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow(
'Connector id connector-id does not have a running attack discovery, and therefore cannot be canceled.'
);
});
it('should throw an error if attack discovery does not exist', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not find attack discovery for connector id: connector-id');
});
it('should throw error if updateAttackDiscovery returns null', async () => {
findAttackDiscoveryByConnectorId.mockResolvedValue(existingAd);
updateAttackDiscovery.mockResolvedValue(null);
await expect(
updateAttackDiscoveryStatusToCanceled(
mockDataClient,
mockAuthenticatedUser,
mockApiConfig.connectorId
)
).rejects.toThrow('Could not update attack discovery for connector id: connector-id');
});
});
describe('updateAttackDiscoveries', () => {
const mockAttackDiscoveryId = 'attack-discovery-id';
const mockLatestReplacements = {};
const mockRawAttackDiscoveries = JSON.stringify({
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
});
const mockSize = 10;
const mockStartTime = moment('2024-03-28T22:25:28.000Z');
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: mockAttackDiscoveryId,
authenticatedUser: mockAuthenticatedUser,
dataClient: mockDataClient,
latestReplacements: mockLatestReplacements,
logger: mockLogger,
rawAttackDiscoveries: mockRawAttackDiscoveries,
size: mockSize,
startTime: mockStartTime,
telemetry: mockTelemetry,
};
it('should update attack discoveries and report success telemetry', async () => {
await updateAttackDiscoveries(mockArgs);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
alertsContextCount: 5,
attackDiscoveries: [{ alertIds: ['alert-1', 'alert-2'] }, { alertIds: ['alert-3'] }],
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
generationIntervals: [{ date, durationMs: 120000 }, ...mockCurrentAd.generationIntervals],
},
authenticatedUser: mockAuthenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 5,
alertsCount: 3,
configuredAlertsCount: mockSize,
discoveriesGenerated: 2,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
});
});
it('should update attack discoveries without generation interval if no discoveries are found', async () => {
const noDiscoveriesRaw = JSON.stringify({
alertsContextCount: 0,
attackDiscoveries: [],
});
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: noDiscoveriesRaw,
});
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
alertsContextCount: 0,
attackDiscoveries: [],
status: attackDiscoveryStatus.succeeded,
id: mockAttackDiscoveryId,
replacements: mockLatestReplacements,
backingIndex: mockCurrentAd.backingIndex,
},
authenticatedUser: mockAuthenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_success', {
actionTypeId: mockApiConfig.actionTypeId,
alertsContextCount: 0,
alertsCount: 0,
configuredAlertsCount: mockSize,
discoveriesGenerated: 0,
durationMs: 120000,
model: mockApiConfig.model,
provider: mockApiConfig.provider,
});
});
it('should catch and log an error if raw attack discoveries is null', async () => {
await updateAttackDiscoveries({
...mockArgs,
rawAttackDiscoveries: null,
});
expect(mockLogger.error).toHaveBeenCalledTimes(1);
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: 'tool returned no attack discoveries',
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await updateAttackDiscoveries(mockArgs);
expect(mockLogger.error).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await updateAttackDiscoveries(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
describe('handleToolError', () => {
const mockArgs = {
apiConfig: mockApiConfig,
attackDiscoveryId: 'discovery-id',
authenticatedUser: mockAuthenticatedUser,
backingIndex: 'backing-index',
dataClient: mockDataClient,
err: mockError,
latestReplacements: {},
logger: mockLogger,
telemetry: mockTelemetry,
};
it('should log the error and update attack discovery status to failed', async () => {
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should log the error and report telemetry when updateAttackDiscovery rejects', async () => {
updateAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).toHaveBeenCalledWith({
attackDiscoveryUpdateProps: {
status: attackDiscoveryStatus.failed,
attackDiscoveries: [],
backingIndex: 'foo',
failureReason: 'Test error',
id: 'discovery-id',
replacements: {},
},
authenticatedUser: mockArgs.authenticatedUser,
});
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
it('should return and not call updateAttackDiscovery when getAttackDiscovery returns a canceled response', async () => {
getAttackDiscovery.mockResolvedValue({
...mockCurrentAd,
status: attackDiscoveryStatus.canceled,
});
await handleToolError(mockArgs);
expect(mockTelemetry.reportEvent).not.toHaveBeenCalled();
expect(updateAttackDiscovery).not.toHaveBeenCalled();
});
it('should log the error and report telemetry when getAttackDiscovery rejects', async () => {
getAttackDiscovery.mockRejectedValue(mockError);
await handleToolError(mockArgs);
expect(mockLogger.error).toHaveBeenCalledWith(mockError);
expect(updateAttackDiscovery).not.toHaveBeenCalled();
expect(mockTelemetry.reportEvent).toHaveBeenCalledWith('attack_discovery_error', {
actionTypeId: mockArgs.apiConfig.actionTypeId,
errorMessage: mockError.message,
model: mockArgs.apiConfig.model,
provider: mockArgs.apiConfig.provider,
});
});
});
});

View file

@ -5,19 +5,36 @@
* 2.0.
*/
import { KibanaRequest } from '@kbn/core/server';
import { Logger } from '@kbn/logging';
import { AnalyticsServiceSetup, AuthenticatedUser, KibanaRequest, Logger } from '@kbn/core/server';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import {
ApiConfig,
AttackDiscovery,
AttackDiscoveryPostRequestBody,
AttackDiscoveryResponse,
AttackDiscoveryStatus,
ExecuteConnectorRequestBody,
GenerationInterval,
Replacements,
} from '@kbn/elastic-assistant-common';
import { AnonymizationFieldResponse } from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen';
import { v4 as uuidv4 } from 'uuid';
import { ActionsClientLlm } from '@kbn/langchain/server';
import { Moment } from 'moment';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server';
import moment from 'moment/moment';
import { uniq } from 'lodash/fp';
import { getLangSmithTracer } from '../evaluate/utils';
import { getLlmType } from '../utils';
import type { GetRegisteredTools } from '../../services/app_context';
import {
ATTACK_DISCOVERY_ERROR_EVENT,
ATTACK_DISCOVERY_SUCCESS_EVENT,
} from '../../lib/telemetry/event_based_telemetry';
import { AssistantToolParams } from '../../types';
import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery';
export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [
{
@ -35,6 +52,77 @@ export const REQUIRED_FOR_ATTACK_DISCOVERY: AnonymizationFieldResponse[] = [
];
export const getAssistantToolParams = ({
actions,
alertsIndexPattern,
anonymizationFields,
apiConfig,
esClient,
connectorTimeout,
langChainTimeout,
langSmithProject,
langSmithApiKey,
logger,
latestReplacements,
onNewReplacements,
request,
size,
}: {
actions: ActionsPluginStart;
alertsIndexPattern: string;
anonymizationFields?: AnonymizationFieldResponse[];
apiConfig: ApiConfig;
esClient: ElasticsearchClient;
connectorTimeout: number;
langChainTimeout: number;
langSmithProject?: string;
langSmithApiKey?: string;
logger: Logger;
latestReplacements: Replacements;
onNewReplacements: (newReplacements: Replacements) => void;
request: KibanaRequest<
unknown,
unknown,
ExecuteConnectorRequestBody | AttackDiscoveryPostRequestBody
>;
size: number;
}) => {
const traceOptions = {
projectName: langSmithProject,
tracers: [
...getLangSmithTracer({
apiKey: langSmithApiKey,
projectName: langSmithProject,
logger,
}),
],
};
const llm = new ActionsClientLlm({
actions,
connectorId: apiConfig.connectorId,
llmType: getLlmType(apiConfig.actionTypeId),
logger,
request,
temperature: 0, // zero temperature for attack discovery, because we want structured JSON output
timeout: connectorTimeout,
traceOptions,
});
return formatAssistantToolParams({
alertsIndexPattern,
anonymizationFields,
esClient,
latestReplacements,
langChainTimeout,
llm,
logger,
onNewReplacements,
request,
size,
});
};
const formatAssistantToolParams = ({
alertsIndexPattern,
anonymizationFields,
esClient,
@ -75,3 +163,254 @@ export const getAssistantToolParams = ({
request,
size,
});
export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = {
canceled: 'canceled',
failed: 'failed',
running: 'running',
succeeded: 'succeeded',
};
const MAX_GENERATION_INTERVALS = 5;
export const addGenerationInterval = (
generationIntervals: GenerationInterval[],
generationInterval: GenerationInterval
): GenerationInterval[] => {
const newGenerationIntervals = [generationInterval, ...generationIntervals];
if (newGenerationIntervals.length > MAX_GENERATION_INTERVALS) {
return newGenerationIntervals.slice(0, MAX_GENERATION_INTERVALS); // Return the first MAX_GENERATION_INTERVALS items
}
return newGenerationIntervals;
};
export const updateAttackDiscoveryStatusToRunning = async (
dataClient: AttackDiscoveryDataClient,
authenticatedUser: AuthenticatedUser,
apiConfig: ApiConfig
): Promise<{
currentAd: AttackDiscoveryResponse;
attackDiscoveryId: string;
}> => {
const foundAttackDiscovery = await dataClient?.findAttackDiscoveryByConnectorId({
connectorId: apiConfig.connectorId,
authenticatedUser,
});
const currentAd = foundAttackDiscovery
? await dataClient?.updateAttackDiscovery({
attackDiscoveryUpdateProps: {
backingIndex: foundAttackDiscovery.backingIndex,
id: foundAttackDiscovery.id,
status: attackDiscoveryStatus.running,
},
authenticatedUser,
})
: await dataClient?.createAttackDiscovery({
attackDiscoveryCreate: {
apiConfig,
attackDiscoveries: [],
status: attackDiscoveryStatus.running,
},
authenticatedUser,
});
if (!currentAd) {
throw new Error(
`Could not ${foundAttackDiscovery ? 'update' : 'create'} attack discovery for connectorId: ${
apiConfig.connectorId
}`
);
}
return {
attackDiscoveryId: currentAd.id,
currentAd,
};
};
export const updateAttackDiscoveryStatusToCanceled = async (
dataClient: AttackDiscoveryDataClient,
authenticatedUser: AuthenticatedUser,
connectorId: string
): Promise<AttackDiscoveryResponse> => {
const foundAttackDiscovery = await dataClient?.findAttackDiscoveryByConnectorId({
connectorId,
authenticatedUser,
});
if (foundAttackDiscovery == null) {
throw new Error(`Could not find attack discovery for connector id: ${connectorId}`);
}
if (foundAttackDiscovery.status !== 'running') {
throw new Error(
`Connector id ${connectorId} does not have a running attack discovery, and therefore cannot be canceled.`
);
}
const updatedAttackDiscovery = await dataClient?.updateAttackDiscovery({
attackDiscoveryUpdateProps: {
backingIndex: foundAttackDiscovery.backingIndex,
id: foundAttackDiscovery.id,
status: attackDiscoveryStatus.canceled,
},
authenticatedUser,
});
if (!updatedAttackDiscovery) {
throw new Error(`Could not update attack discovery for connector id: ${connectorId}`);
}
return updatedAttackDiscovery;
};
const getDataFromJSON = (adStringified: string) => {
const { alertsContextCount, attackDiscoveries } = JSON.parse(adStringified);
return { alertsContextCount, attackDiscoveries };
};
export const updateAttackDiscoveries = async ({
apiConfig,
attackDiscoveryId,
authenticatedUser,
dataClient,
latestReplacements,
logger,
rawAttackDiscoveries,
size,
startTime,
telemetry,
}: {
apiConfig: ApiConfig;
attackDiscoveryId: string;
authenticatedUser: AuthenticatedUser;
dataClient: AttackDiscoveryDataClient;
latestReplacements: Replacements;
logger: Logger;
rawAttackDiscoveries: string | null;
size: number;
startTime: Moment;
telemetry: AnalyticsServiceSetup;
}) => {
try {
if (rawAttackDiscoveries == null) {
throw new Error('tool returned no attack discoveries');
}
const currentAd = await dataClient.getAttackDiscovery({
id: attackDiscoveryId,
authenticatedUser,
});
if (currentAd === null || currentAd?.status === 'canceled') {
return;
}
const endTime = moment();
const durationMs = endTime.diff(startTime);
const { alertsContextCount, attackDiscoveries } = getDataFromJSON(rawAttackDiscoveries);
const updateProps = {
alertsContextCount,
attackDiscoveries,
status: attackDiscoveryStatus.succeeded,
...(alertsContextCount === 0 || attackDiscoveries === 0
? {}
: {
generationIntervals: addGenerationInterval(currentAd.generationIntervals, {
durationMs,
date: new Date().toISOString(),
}),
}),
id: attackDiscoveryId,
replacements: latestReplacements,
backingIndex: currentAd.backingIndex,
};
await dataClient.updateAttackDiscovery({
attackDiscoveryUpdateProps: updateProps,
authenticatedUser,
});
telemetry.reportEvent(ATTACK_DISCOVERY_SUCCESS_EVENT.eventType, {
actionTypeId: apiConfig.actionTypeId,
alertsContextCount: updateProps.alertsContextCount,
alertsCount: uniq(
updateProps.attackDiscoveries.flatMap(
(attackDiscovery: AttackDiscovery) => attackDiscovery.alertIds
)
).length,
configuredAlertsCount: size,
discoveriesGenerated: updateProps.attackDiscoveries.length,
durationMs,
model: apiConfig.model,
provider: apiConfig.provider,
});
} catch (updateErr) {
logger.error(updateErr);
const updateError = transformError(updateErr);
telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, {
actionTypeId: apiConfig.actionTypeId,
errorMessage: updateError.message,
model: apiConfig.model,
provider: apiConfig.provider,
});
}
};
export const handleToolError = async ({
apiConfig,
attackDiscoveryId,
authenticatedUser,
dataClient,
err,
latestReplacements,
logger,
telemetry,
}: {
apiConfig: ApiConfig;
attackDiscoveryId: string;
authenticatedUser: AuthenticatedUser;
dataClient: AttackDiscoveryDataClient;
err: Error;
latestReplacements: Replacements;
logger: Logger;
telemetry: AnalyticsServiceSetup;
}) => {
try {
logger.error(err);
const error = transformError(err);
const currentAd = await dataClient.getAttackDiscovery({
id: attackDiscoveryId,
authenticatedUser,
});
if (currentAd === null || currentAd?.status === 'canceled') {
return;
}
await dataClient.updateAttackDiscovery({
attackDiscoveryUpdateProps: {
attackDiscoveries: [],
status: attackDiscoveryStatus.failed,
id: attackDiscoveryId,
replacements: latestReplacements,
backingIndex: currentAd.backingIndex,
failureReason: error.message,
},
authenticatedUser,
});
telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, {
actionTypeId: apiConfig.actionTypeId,
errorMessage: error.message,
model: apiConfig.model,
provider: apiConfig.provider,
});
} catch (updateErr) {
const updateError = transformError(updateErr);
telemetry.reportEvent(ATTACK_DISCOVERY_ERROR_EVENT.eventType, {
actionTypeId: apiConfig.actionTypeId,
errorMessage: updateError.message,
model: apiConfig.model,
provider: apiConfig.provider,
});
}
};
export const getAssistantTool = (getRegisteredTools: GetRegisteredTools, pluginName: string) => {
// get the attack discovery tool:
const assistantTools = getRegisteredTools(pluginName);
return assistantTools.find((tool) => tool.id === 'attack-discovery');
};

View file

@ -0,0 +1,142 @@
/*
* 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 { AuthenticatedUser } from '@kbn/core-security-common';
import { postAttackDiscoveryRoute } from './post_attack_discovery';
import { serverMock } from '../../__mocks__/server';
import { requestContextMock } from '../../__mocks__/request_context';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { AttackDiscoveryDataClient } from '../../ai_assistant_data_clients/attack_discovery';
import { transformESSearchToAttackDiscovery } from '../../ai_assistant_data_clients/attack_discovery/transforms';
import { getAttackDiscoverySearchEsMock } from '../../__mocks__/attack_discovery_schema.mock';
import { postAttackDiscoveryRequest } from '../../__mocks__/request';
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
import { AttackDiscoveryPostRequestBody } from '@kbn/elastic-assistant-common';
import {
getAssistantTool,
getAssistantToolParams,
updateAttackDiscoveryStatusToRunning,
} from './helpers';
jest.mock('./helpers');
const { clients, context } = requestContextMock.createTools();
const server: ReturnType<typeof serverMock.create> = serverMock.create();
clients.core.elasticsearch.client = elasticsearchServiceMock.createScopedClusterClient();
const mockUser = {
username: 'my_username',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const findAttackDiscoveryByConnectorId = jest.fn();
const mockDataClient = {
findAttackDiscoveryByConnectorId,
updateAttackDiscovery: jest.fn(),
createAttackDiscovery: jest.fn(),
getAttackDiscovery: jest.fn(),
} as unknown as AttackDiscoveryDataClient;
const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
provider: OpenAiProviderType.OpenAi,
};
const mockRequestBody: AttackDiscoveryPostRequestBody = {
subAction: 'invokeAI',
apiConfig: mockApiConfig,
alertsIndexPattern: 'alerts-*',
anonymizationFields: [],
replacements: {},
model: 'gpt-4',
size: 20,
langSmithProject: 'langSmithProject',
langSmithApiKey: 'langSmithApiKey',
};
const mockCurrentAd = transformESSearchToAttackDiscovery(getAttackDiscoverySearchEsMock())[0];
const runningAd = {
...mockCurrentAd,
status: 'running',
};
describe('postAttackDiscoveryRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
context.elasticAssistant.getCurrentUser.mockReturnValue(mockUser);
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(mockDataClient);
postAttackDiscoveryRoute(server.router);
findAttackDiscoveryByConnectorId.mockResolvedValue(mockCurrentAd);
(getAssistantTool as jest.Mock).mockReturnValue({ getTool: jest.fn() });
(getAssistantToolParams as jest.Mock).mockReturnValue({ tool: 'tool' });
(updateAttackDiscoveryStatusToRunning as jest.Mock).mockResolvedValue({
currentAd: runningAd,
attackDiscoveryId: mockCurrentAd.id,
});
});
it('should handle successful request', async () => {
const response = await server.inject(
postAttackDiscoveryRequest(mockRequestBody),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(runningAd);
});
it('should handle missing authenticated user', async () => {
context.elasticAssistant.getCurrentUser.mockReturnValue(null);
const response = await server.inject(
postAttackDiscoveryRequest(mockRequestBody),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(401);
expect(response.body).toEqual({
message: 'Authenticated user not found',
status_code: 401,
});
});
it('should handle missing data client', async () => {
context.elasticAssistant.getAttackDiscoveryDataClient.mockResolvedValue(null);
const response = await server.inject(
postAttackDiscoveryRequest(mockRequestBody),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: 'Attack discovery data client not initialized',
status_code: 500,
});
});
it('should handle assistantTool null response', async () => {
(getAssistantTool as jest.Mock).mockReturnValue(null);
const response = await server.inject(
postAttackDiscoveryRequest(mockRequestBody),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(404);
});
it('should handle updateAttackDiscoveryStatusToRunning error', async () => {
(updateAttackDiscoveryStatusToRunning as jest.Mock).mockRejectedValue(new Error('Oh no!'));
const response = await server.inject(
postAttackDiscoveryRequest(mockRequestBody),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(500);
expect(response.body).toEqual({
message: {
error: 'Oh no!',
success: false,
},
status_code: 500,
});
});
});

View file

@ -14,15 +14,19 @@ import {
Replacements,
} from '@kbn/elastic-assistant-common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { ActionsClientLlm } from '@kbn/langchain/server';
import moment from 'moment/moment';
import { ATTACK_DISCOVERY } from '../../../common/constants';
import { getAssistantToolParams } from './helpers';
import {
getAssistantTool,
getAssistantToolParams,
handleToolError,
updateAttackDiscoveries,
updateAttackDiscoveryStatusToRunning,
} from './helpers';
import { DEFAULT_PLUGIN_NAME, getPluginNameFromRequest } from '../helpers';
import { getLangSmithTracer } from '../evaluate/utils';
import { buildResponse } from '../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../types';
import { getLlmType } from '../utils';
const ROUTE_HANDLER_TIMEOUT = 10 * 60 * 1000; // 10 * 60 seconds = 10 minutes
const LANG_CHAIN_TIMEOUT = ROUTE_HANDLER_TIMEOUT - 10_000; // 9 minutes 50 seconds
@ -57,13 +61,29 @@ export const postAttackDiscoveryRoute = (
},
},
async (context, request, response): Promise<IKibanaResponse<AttackDiscoveryPostResponse>> => {
const startTime = moment(); // start timing the generation
const resp = buildResponse(response);
const assistantContext = await context.elasticAssistant;
const logger: Logger = assistantContext.logger;
const telemetry = assistantContext.telemetry;
try {
// get the actions plugin start contract from the request context:
const actions = (await context.elasticAssistant).actions;
const dataClient = await assistantContext.getAttackDiscoveryDataClient();
const authenticatedUser = assistantContext.getCurrentUser();
if (authenticatedUser == null) {
return resp.error({
body: `Authenticated user not found`,
statusCode: 401,
});
}
if (!dataClient) {
return resp.error({
body: `Attack discovery data client not initialized`,
statusCode: 500,
});
}
const pluginName = getPluginNameFromRequest({
request,
defaultPluginName: DEFAULT_PLUGIN_NAME,
@ -72,9 +92,8 @@ export const postAttackDiscoveryRoute = (
// get parameters from the request body
const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern);
const connectorId = decodeURIComponent(request.body.connectorId);
const {
actionTypeId,
apiConfig,
anonymizationFields,
langSmithApiKey,
langSmithProject,
@ -91,42 +110,26 @@ export const postAttackDiscoveryRoute = (
latestReplacements = { ...latestReplacements, ...newReplacements };
};
// get the attack discovery tool:
const assistantTools = (await context.elasticAssistant).getRegisteredTools(pluginName);
const assistantTool = assistantTools.find((tool) => tool.id === 'attack-discovery');
const assistantTool = getAssistantTool(
(await context.elasticAssistant).getRegisteredTools,
pluginName
);
if (!assistantTool) {
return response.notFound(); // attack discovery tool not found
}
const traceOptions = {
projectName: langSmithProject,
tracers: [
...getLangSmithTracer({
apiKey: langSmithApiKey,
projectName: langSmithProject,
logger,
}),
],
};
const llm = new ActionsClientLlm({
actions,
connectorId,
llmType: getLlmType(actionTypeId),
logger,
request,
temperature: 0, // zero temperature for attack discovery, because we want structured JSON output
timeout: CONNECTOR_TIMEOUT,
traceOptions,
});
const assistantToolParams = getAssistantToolParams({
actions,
alertsIndexPattern,
anonymizationFields,
apiConfig,
esClient,
latestReplacements,
connectorTimeout: CONNECTOR_TIMEOUT,
langChainTimeout: LANG_CHAIN_TIMEOUT,
llm,
langSmithProject,
langSmithApiKey,
logger,
onNewReplacements,
request,
@ -135,23 +138,44 @@ export const postAttackDiscoveryRoute = (
// invoke the attack discovery tool:
const toolInstance = assistantTool.getTool(assistantToolParams);
const rawAttackDiscoveries = await toolInstance?.invoke('');
if (rawAttackDiscoveries == null) {
return response.customError({
body: { message: 'tool returned no attack discoveries' },
statusCode: 500,
});
}
const { alertsContextCount, attackDiscoveries } = JSON.parse(rawAttackDiscoveries);
const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning(
dataClient,
authenticatedUser,
apiConfig
);
toolInstance
?.invoke('')
.then((rawAttackDiscoveries: string) =>
updateAttackDiscoveries({
apiConfig,
attackDiscoveryId,
authenticatedUser,
dataClient,
latestReplacements,
logger,
rawAttackDiscoveries,
size,
startTime,
telemetry,
})
)
.catch((err) =>
handleToolError({
apiConfig,
attackDiscoveryId,
authenticatedUser,
dataClient,
err,
latestReplacements,
logger,
telemetry,
})
);
return response.ok({
body: {
alertsContextCount,
attackDiscoveries,
connector_id: connectorId,
replacements: latestReplacements,
},
body: currentAd,
});
} catch (err) {
logger.error(err);

View file

@ -10,6 +10,7 @@ export { postActionsConnectorExecuteRoute } from './post_actions_connector_execu
// Attack Discovery
export { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery';
export { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery';
// Knowledge Base
export { deleteKnowledgeBaseRoute } from './knowledge_base/delete_knowledge_base';

View file

@ -7,6 +7,8 @@
import type { Logger } from '@kbn/core/server';
import { cancelAttackDiscoveryRoute } from './attack_discovery/cancel_attack_discovery';
import { getAttackDiscoveryRoute } from './attack_discovery/get_attack_discovery';
import { postAttackDiscoveryRoute } from './attack_discovery/post_attack_discovery';
import { ElasticAssistantPluginRouter, GetElser } from '../types';
import { createConversationRoute } from './user_conversations/create_route';
@ -78,5 +80,7 @@ export const registerRoutes = (
findAnonymizationFieldsRoute(router, logger);
// Attack Discovery
getAttackDiscoveryRoute(router);
postAttackDiscoveryRoute(router);
cancelAttackDiscoveryRoute(router);
};

View file

@ -93,6 +93,15 @@ export class RequestContextFactory implements IRequestContextFactory {
});
}),
getAttackDiscoveryDataClient: memoize(() => {
const currentUser = getCurrentUser();
return this.assistantService.createAttackDiscoveryDataClient({
spaceId: getSpaceId(),
logger: this.logger,
currentUser,
});
}),
getAIAssistantPromptsDataClient: memoize(() => {
const currentUser = getCurrentUser();
return this.assistantService.createAIAssistantPromptsDataClient({

View file

@ -39,6 +39,7 @@ import {
ActionsClientSimpleChatModel,
} from '@kbn/langchain/server';
import { AttackDiscoveryDataClient } from './ai_assistant_data_clients/attack_discovery';
import { AIAssistantConversationsDataClient } from './ai_assistant_data_clients/conversations';
import type { GetRegisteredFeatures, GetRegisteredTools } from './services/app_context';
import { AIAssistantDataClient } from './ai_assistant_data_clients';
@ -114,6 +115,7 @@ export interface ElasticAssistantApiRequestHandlerContext {
getAIAssistantKnowledgeBaseDataClient: (
initializeKnowledgeBase: boolean
) => Promise<AIAssistantKnowledgeBaseDataClient | null>;
getAttackDiscoveryDataClient: () => Promise<AttackDiscoveryDataClient | null>;
getAIAssistantPromptsDataClient: () => Promise<AIAssistantDataClient | null>;
getAIAssistantAnonymizationFieldsDataClient: () => Promise<AIAssistantDataClient | null>;
telemetry: AnalyticsServiceSetup;
@ -148,24 +150,28 @@ export interface AssistantResourceNames {
knowledgeBase: string;
prompts: string;
anonymizationFields: string;
attackDiscovery: string;
};
indexTemplate: {
conversations: string;
knowledgeBase: string;
prompts: string;
anonymizationFields: string;
attackDiscovery: string;
};
aliases: {
conversations: string;
knowledgeBase: string;
prompts: string;
anonymizationFields: string;
attackDiscovery: string;
};
indexPatterns: {
conversations: string;
knowledgeBase: string;
prompts: string;
anonymizationFields: string;
attackDiscovery: string;
};
pipelines: {
knowledgeBase: string;

View file

@ -46,6 +46,7 @@
"@kbn/core-security-common",
"@kbn/core-saved-objects-api-server",
"@kbn/langchain",
"@kbn/stack-connectors-plugin",
],
"exclude": [
"target/**/*",

View file

@ -9,9 +9,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useMemo } from 'react';
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
import { Tactic } from './tactic';
import { getTacticMetadata } from '../../helpers';
import type { AttackDiscovery } from '../../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -9,9 +9,9 @@ import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui';
import React, { useMemo } from 'react';
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
import { getTacticMetadata } from '../../helpers';
import { ATTACK_CHAIN_TOOLTIP } from './translations';
import type { AttackDiscovery } from '../../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -6,11 +6,10 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import React, { useMemo } from 'react';
import { AttackDiscoveryMarkdownFormatter } from '../../attack_discovery_markdown_formatter';
import type { AttackDiscovery } from '../../types';
import { ViewInAiAssistant } from '../view_in_ai_assistant';
interface Props {

View file

@ -7,14 +7,13 @@
import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import React from 'react';
import { AlertsBadge } from './alerts_badge';
import { MiniAttackChain } from '../../attack/mini_attack_chain';
import { TakeAction } from './take_action';
import * as i18n from './translations';
import type { AttackDiscovery } from '../../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import {
EuiButtonEmpty,
EuiContextMenuItem,
@ -19,7 +19,6 @@ import { useKibana } from '../../../../common/lib/kibana';
import { APP_ID } from '../../../../../common';
import { getAttackDiscoveryMarkdown } from '../../../get_attack_discovery_markdown/get_attack_discovery_markdown';
import * as i18n from './translations';
import type { AttackDiscovery } from '../../../types';
import { useAddToNewCase } from '../use_add_to_case';
import { useAddToExistingCase } from '../use_add_to_existing_case';
import { useViewInAiAssistant } from '../../view_in_ai_assistant/use_view_in_ai_assistant';

View file

@ -7,14 +7,13 @@
import { css } from '@emotion/react';
import { EuiAccordion, EuiPanel, EuiSpacer, useEuiTheme, useGeneratedHtmlId } from '@elastic/eui';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import React, { useCallback, useMemo, useState } from 'react';
import { ActionableSummary } from './actionable_summary';
import { Actions } from './actions';
import { Tabs } from './tabs';
import { Title } from './title';
import type { AttackDiscovery } from '../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { GenerationInterval } from '../../types';
export const encodeIntervals = (
intervalByConnectorId: Record<string, [GenerationInterval]>
): string | null => {
try {
return JSON.stringify(intervalByConnectorId, null, 2);
} catch {
return null;
}
};
export const decodeIntervals = (
intervalByConnectorId: string
): Record<string, [GenerationInterval]> | null => {
try {
return JSON.parse(intervalByConnectorId);
} catch {
return null;
}
};

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { AlertConsumers } from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import React, { useMemo } from 'react';
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../../common/constants';
import { useKibana } from '../../../../common/lib/kibana';
import type { AttackDiscovery } from '../../../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useMemo } from 'react';
@ -16,7 +16,6 @@ import { buildAlertsKqlFilter } from '../../../../detections/components/alerts_t
import { getTacticMetadata } from '../../../helpers';
import { AttackDiscoveryMarkdownFormatter } from '../../../attack_discovery_markdown_formatter';
import * as i18n from './translations';
import type { AttackDiscovery } from '../../../types';
import { ViewInAiAssistant } from '../../view_in_ai_assistant';
interface Props {

View file

@ -6,13 +6,12 @@
*/
import { EuiSpacer } from '@elastic/eui';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import React from 'react';
import { AttackDiscoveryTab } from './attack_discovery_tab';
import { AlertsTab } from './alerts_tab';
import * as i18n from './translations';
import type { AttackDiscovery } from '../../types';
interface TabInfo {
content: JSX.Element;

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { EuiTabs, EuiTab } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { getTabs } from './get_tabs';
import type { AttackDiscovery } from '../../types';
interface Props {
attackDiscovery: AttackDiscovery;

View file

@ -6,12 +6,11 @@
*/
import { AssistantAvatar } from '@kbn/elastic-assistant';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import * as i18n from './translations';
import type { AttackDiscovery } from '../../types';
import { useViewInAiAssistant } from './use_view_in_ai_assistant';
interface Props {

View file

@ -7,10 +7,9 @@
import { useMemo, useCallback } from 'react';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import { getAttackDiscoveryMarkdown } from '../../get_attack_discovery_markdown/get_attack_discovery_markdown';
import type { AttackDiscovery } from '../../types';
/**
* This category is provided in the prompt context for the assistant
@ -39,7 +38,7 @@ export const useViewInAiAssistant = ({
attackDiscovery.title, // conversation title
attackDiscovery.title, // description used in context pill
getPromptContext,
attackDiscovery.id, // accept the UUID default for this prompt context
attackDiscovery.id ?? null, // accept the UUID default for this prompt context
null, // suggestedUserPrompt
null, // tooltip
isAssistantEnabled,

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscovery, Replacements } from '@kbn/elastic-assistant-common';
import { getTacticLabel, getTacticMetadata } from '../helpers';
import type { AttackDiscovery } from '../types';
export const getMarkdownFields = (markdown: string): string => {
const regex = new RegExp('{{\\s*(\\S+)\\s+(\\S+)\\s*}}', 'gm');

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { AttackDiscovery } from './types';
export const RECONNAISSANCE = 'Reconnaissance';
export const INITIAL_ACCESS = 'Initial Access';

View file

@ -1,59 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useAttackDiscoveryTelemetry } from '.';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
const reportAttackDiscoveriesGenerated = jest.fn();
const mockedTelemetry = {
...createTelemetryServiceMock(),
reportAttackDiscoveriesGenerated,
};
jest.mock('../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
services: {
telemetry: mockedTelemetry,
},
}),
};
});
describe('useAttackDiscoveryTelemetry', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the expected telemetry object with tracking functions', () => {
const { result } = renderHook(() => useAttackDiscoveryTelemetry());
expect(result.current).toHaveProperty('reportAttackDiscoveriesGenerated');
});
it('Should call reportAttackDiscoveriesGenerated with appropriate actionTypeId when tracking is called', async () => {
const { result } = renderHook(() => useAttackDiscoveryTelemetry());
await result.current.reportAttackDiscoveriesGenerated({
actionTypeId: '.gen-ai',
model: 'gpt-4',
durationMs: 8000,
alertsCount: 20,
alertsContextCount: 25,
configuredAlertsCount: 30,
});
expect(reportAttackDiscoveriesGenerated).toHaveBeenCalledWith({
actionTypeId: '.gen-ai',
model: 'gpt-4',
durationMs: 8000,
alertsCount: 20,
alertsContextCount: 25,
configuredAlertsCount: 30,
});
});
});

View file

@ -1,23 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ReportAttackDiscoveriesGeneratedParams } from '../../../common/lib/telemetry/events/attack_discovery/types';
import { useKibana } from '../../../common/lib/kibana';
interface AttackDiscoveryTelemetry {
reportAttackDiscoveriesGenerated: (params: ReportAttackDiscoveriesGeneratedParams) => void;
}
export const useAttackDiscoveryTelemetry = (): AttackDiscoveryTelemetry => {
const {
services: { telemetry },
} = useKibana();
return {
reportAttackDiscoveriesGenerated: telemetry.reportAttackDiscoveriesGenerated,
};
};

View file

@ -0,0 +1,197 @@
/*
* 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 { HttpSetupMock } from '@kbn/core-http-browser-mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { act, renderHook } from '@testing-library/react-hooks';
import { attackDiscoveryStatus, usePollApi } from './use_poll_api';
import moment from 'moment/moment';
import { kibanaMock } from '../../common/mock';
const http: HttpSetupMock = coreMock.createSetup().http;
const setApproximateFutureTime = jest.fn();
const defaultProps = { http, setApproximateFutureTime, connectorId: 'my-gpt4o-ai' };
const mockResponse = {
timestamp: '2024-06-07T18:56:17.357Z',
createdAt: '2024-06-07T18:56:17.357Z',
users: [
{
id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
name: 'elastic',
},
],
status: 'succeeded',
apiConfig: {
actionTypeId: '.gen-ai',
connectorId: 'my-gpt4o-ai',
},
attackDiscoveries: [
{
summaryMarkdown:
'A critical malware incident involving {{ host.name c1f9889f-1f6b-4abc-8e65-02de89fe1054 }} and {{ user.name 71ca47cf-082e-4d35-a8e7-6e4fa4e175da }} has been detected. The malware, identified as AppPool.vbs, was executed with high privileges and attempted to evade detection.',
id: '2204421f-bb42-4b96-a200-016a5388a029',
title: 'Critical Malware Incident on Windows Host',
mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'],
alertIds: [
'43cf228ce034aeeb89a1ef41cd7fcdef1a3db574fa5237badf1fa9eaa3425c21',
'44ae9696784b3baeee75935f889e55ce77da338241230b5c488f90a8bace43e2',
'2479b1b1007952d3b6dc26344c89f44c1bb396de56f1655eca408135b3d05af8',
],
detailsMarkdown: 'details',
entitySummaryMarkdown:
'{{ host.name c1f9889f-1f6b-4abc-8e65-02de89fe1054 }} and {{ user.name 71ca47cf-082e-4d35-a8e7-6e4fa4e175da }} are involved in a critical malware incident.',
timestamp: '2024-06-07T20:04:35.715Z',
},
],
backingIndex: '1234',
updatedAt: '2024-06-07T20:04:35.715Z',
replacements: {
'c1f9889f-1f6b-4abc-8e65-02de89fe1054': 'root',
'71ca47cf-082e-4d35-a8e7-6e4fa4e175da': 'james',
},
namespace: 'default',
generationIntervals: [
{
date: '2024-06-07T20:04:35.715Z',
durationMs: 104593,
},
{
date: '2024-06-07T18:58:27.880Z',
durationMs: 130526,
},
],
alertsContextCount: 20,
averageIntervalMs: 117559,
id: '8e215edc-6318-4760-9566-d32f1844f9cb',
};
describe('usePollApi', () => {
beforeAll(() => {
jest.useFakeTimers({ legacyFakeTimers: true });
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
});
test('should render initial state with null status and data', () => {
const { result } = renderHook(() => usePollApi(defaultProps));
expect(result.current.status).toBeNull();
expect(result.current.data).toBeNull();
});
test('should call http.fetch on pollApi call', async () => {
const { result } = renderHook(() => usePollApi(defaultProps));
await result.current.pollApi();
expect(http.fetch).toHaveBeenCalledTimes(1);
expect(http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/attack_discovery/my-gpt4o-ai',
{ method: 'GET', version: '1' }
);
});
test('should update didInitialFetch on connector change', async () => {
http.fetch.mockResolvedValue({
entryExists: true,
data: mockResponse,
});
const { result, rerender } = renderHook((props) => usePollApi(props), {
initialProps: defaultProps,
});
expect(result.current.didInitialFetch).toEqual(false);
await act(async () => {
await result.current.pollApi();
});
expect(result.current.didInitialFetch).toEqual(true);
rerender({ ...defaultProps, connectorId: 'new-connector-id' });
expect(result.current.didInitialFetch).toEqual(false);
await act(async () => {
await result.current.pollApi();
});
expect(result.current.didInitialFetch).toEqual(true);
});
test('should update status and data on successful response', async () => {
http.fetch.mockResolvedValue({
entryExists: true,
data: mockResponse,
});
const { result } = renderHook(() => usePollApi(defaultProps));
await act(async () => {
await result.current.pollApi();
});
expect(result.current.status).toBe(attackDiscoveryStatus.succeeded);
expect(result.current.data).toEqual({ ...mockResponse, connectorId: defaultProps.connectorId });
expect(setApproximateFutureTime).toHaveBeenCalledWith(
moment(mockResponse.updatedAt).add(mockResponse.averageIntervalMs, 'milliseconds').toDate()
);
});
test('should update status and data on running status and schedule next poll', async () => {
// @ts-ignore
jest.spyOn(global, 'setTimeout').mockImplementation((cb) => cb());
http.fetch
.mockResolvedValueOnce({
entryExists: true,
data: { ...mockResponse, attackDiscoveries: [], status: 'running' },
})
.mockResolvedValueOnce({
entryExists: true,
data: { ...mockResponse, attackDiscoveries: [], status: 'running' },
})
.mockResolvedValueOnce({
entryExists: true,
data: { ...mockResponse, attackDiscoveries: [], status: 'running' },
})
.mockResolvedValue({
entryExists: true,
data: mockResponse,
});
const { result } = renderHook(() => usePollApi(defaultProps));
await act(async () => {
await result.current.pollApi();
});
// 3 from the mockResolvedValueOnce above
expect(setTimeout).toHaveBeenCalledTimes(3);
});
test('When no connectorId and pollApi is called, should update status and data to null on error and show toast', async () => {
const addDangerMock = jest.spyOn(kibanaMock.notifications.toasts, 'addDanger');
const { result } = renderHook(() =>
usePollApi({
http,
setApproximateFutureTime: () => {},
toasts: kibanaMock.notifications.toasts,
})
);
await result.current.pollApi();
expect(result.current.status).toBeNull();
expect(result.current.data).toBeNull();
expect(addDangerMock).toHaveBeenCalledTimes(1);
expect(addDangerMock).toHaveBeenCalledWith(new Error('Invalid connector id'), {
text: 'Invalid connector id',
title: 'Error generating attack discoveries',
});
});
});

View file

@ -0,0 +1,180 @@
/*
* 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 { useCallback, useEffect, useRef, useState } from 'react';
import * as uuid from 'uuid';
import type { AttackDiscoveryStatus, AttackDiscoveryResponse } from '@kbn/elastic-assistant-common';
import {
AttackDiscoveryCancelResponse,
AttackDiscoveryGetResponse,
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
} from '@kbn/elastic-assistant-common';
import type { HttpSetup } from '@kbn/core-http-browser';
import moment from 'moment';
import type { IToasts } from '@kbn/core-notifications-browser';
import {
ERROR_CANCELING_ATTACK_DISCOVERIES,
ERROR_GENERATING_ATTACK_DISCOVERIES,
} from '../pages/translations';
import { getErrorToastText } from '../pages/helpers';
import { replaceNewlineLiterals } from '../helpers';
export interface Props {
http: HttpSetup;
setApproximateFutureTime: (date: Date | null) => void;
toasts?: IToasts;
connectorId?: string;
}
export interface AttackDiscoveryData extends AttackDiscoveryResponse {
connectorId: string;
}
interface UsePollApi {
cancelAttackDiscovery: () => Promise<void>;
didInitialFetch: boolean;
status: AttackDiscoveryStatus | null;
data: AttackDiscoveryData | null;
pollApi: () => void;
}
export const usePollApi = ({
http,
setApproximateFutureTime,
toasts,
connectorId,
}: Props): UsePollApi => {
const [status, setStatus] = useState<AttackDiscoveryStatus | null>(null);
const [data, setData] = useState<AttackDiscoveryData | null>(null);
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [didInitialFetch, setDidInitialFetch] = useState(false);
useEffect(() => {
setDidInitialFetch(false);
return () => {
// when a connectorId changes, clear timeout
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
};
}, [connectorId]);
const handleResponse = useCallback(
(responseData: AttackDiscoveryResponse | null) => {
if (connectorId == null || connectorId === '') {
throw new Error('Invalid connector id');
}
setDidInitialFetch(true);
if (responseData == null) {
setStatus(null);
setData(null);
return;
}
setData((prevData) => {
if (
responseData.updatedAt === prevData?.updatedAt &&
responseData.status === prevData?.status &&
responseData.id === prevData?.id
) {
// do not update if the data is the same
// prevents unnecessary re-renders
return prevData;
}
setStatus(responseData.status);
setApproximateFutureTime(
moment(responseData.updatedAt)
.add(responseData.averageIntervalMs, 'milliseconds')
.toDate()
);
return {
...responseData,
connectorId,
attackDiscoveries: responseData.attackDiscoveries.map((attackDiscovery) => ({
...attackDiscovery,
id: attackDiscovery.id ?? uuid.v4(),
detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown),
entitySummaryMarkdown: replaceNewlineLiterals(attackDiscovery.entitySummaryMarkdown),
summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown),
})),
};
});
},
[connectorId, setApproximateFutureTime]
);
const cancelAttackDiscovery = useCallback(async () => {
try {
if (connectorId == null || connectorId === '') {
throw new Error('Invalid connector id');
}
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current);
const rawResponse = await http.fetch(
`/internal/elastic_assistant/attack_discovery/cancel/${connectorId}`,
{
method: 'PUT',
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
}
);
const parsedResponse = AttackDiscoveryCancelResponse.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new Error('Failed to parse the attack discovery cancel response');
}
handleResponse(parsedResponse.data);
} catch (error) {
setStatus(null);
toasts?.addDanger(error, {
title: ERROR_CANCELING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
}
}, [connectorId, handleResponse, http, toasts]);
const pollApi = useCallback(async () => {
try {
if (connectorId == null || connectorId === '') {
throw new Error('Invalid connector id');
}
// call the internal API to generate attack discoveries:
const rawResponse = await http.fetch(
`/internal/elastic_assistant/attack_discovery/${connectorId}`,
{
method: 'GET',
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
}
);
const parsedResponse = AttackDiscoveryGetResponse.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new Error('Failed to parse the attack discovery GET response');
}
handleResponse(parsedResponse.data.data ?? null);
if (parsedResponse?.data?.data?.status === attackDiscoveryStatus.running) {
// poll every 3 seconds if attack discovery is running
timeoutIdRef.current = setTimeout(() => {
pollApi();
}, 3000);
}
} catch (error) {
setStatus(null);
setData(null);
toasts?.addDanger(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
}
}, [connectorId, handleResponse, http, toasts]);
return { cancelAttackDiscovery, didInitialFetch, status, data, pollApi };
};
export const attackDiscoveryStatus: { [k: string]: AttackDiscoveryStatus } = {
canceled: 'canceled',
failed: 'failed',
running: 'running',
succeeded: 'succeeded',
};

View file

@ -12,365 +12,36 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = (
): UseAttackDiscovery => ({
alertsContextCount: 20,
approximateFutureTime: null,
cachedAttackDiscoveries: {
claudeV3SonnetUsEast1: {
connectorId: 'claudeV3SonnetUsEast1',
attackDiscoveries: [
{
alertIds: [
'e770a817-0e87-4e4b-8e26-1bf504a209d2',
'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96',
'8cfde870-cd3b-40b8-9999-901c0b97fb5a',
'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84',
'597fd583-4036-4631-a71a-7a8a7dd17848',
'550691a2-edac-4cc5-a453-6a36d5351c76',
'df97c2d9-9e28-43e0-a461-3bacf91a262f',
'f6558144-630c-49ec-8aa2-fe96364883c7',
'113819ec-cfd0-4867-bfbd-cb9ca8e1e69f',
'c6cbd80f-9602-4748-b951-56c0745f3e1f',
],
detailsMarkdown:
'- {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential ransomware attack progression:\n\n - A suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} was created and executed from {{ file.path 4053a825-9628-470a-8c83-c733e941bece }} by the parent process {{ process.parent.executable C:\\Windows\\Explorer.EXE }}.\n - The suspicious executable then created another file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} at {{ file.path 8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4 }} and loaded it.\n - Multiple shellcode injection alerts were triggered by the loaded file, indicating potential malicious activity.\n - A ransomware detection alert was also triggered, suggesting the presence of ransomware behavior.\n\n- The suspicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} had an expired code signature from "TRANSPORT", which is not a trusted source.\n- The loaded file {{ file.name 604300eb-3711-4e38-8500-0a395d3cc1e5 }} was identified as potentially malicious by Elastic Endpoint Security.',
entitySummaryMarkdown:
'Potential ransomware attack involving {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.',
id: '9f6d4a18-7483-4103-92e7-24e2ebab77bb',
mitreAttackTactics: [
'Execution',
'Persistence',
'Privilege Escalation',
'Defense Evasion',
],
summaryMarkdown:
'A potential ransomware attack progression was detected on {{ host.name 001cc415-42ad-4b21-a92c-e4193b283b78 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A suspicious executable with an untrusted code signature was executed, leading to the creation and loading of a malicious file that triggered shellcode injection and ransomware detection alerts.',
title: 'Potential Ransomware Attack Progression Detected',
},
{
alertIds: [
'4691c8da-ccba-40f2-b540-0ec5656ad8ef',
'53b3ee1a-1594-447d-94a0-338af2a22844',
'2e744d88-3040-4ab8-90a3-1d5011ab1a6b',
'452ed87e-2e64-486b-ad6a-b368010f570a',
'd2ce2be7-1d86-4fbe-851a-05883e575a0b',
'7d0ae0fc-7c24-4760-8543-dc4d44f17126',
],
detailsMarkdown:
'- {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack progression:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name B1Z8U2N9.txt }}) into another executable ({{ file.name Q3C7N1V8.exe }}).\n - The decoded executable {{ file.name Q3C7N1V8.exe }} was then executed and created another file {{ file.name 2ddee627-fbe2-45a8-8b2b-eba7542b4e3d }} at {{ file.path ae8aacc8-bfe3-4735-8075-a135fcf60722 }}, which was loaded.\n - Multiple alerts were triggered, including malware detection, suspicious Microsoft Office child process, uncommon persistence via registry modification, and rundll32 with unusual arguments.\n\n- The decoded executable {{ file.name Q3C7N1V8.exe }} exhibited persistence behavior by modifying the registry.\n- The rundll32.exe process was launched with unusual arguments to load the decoded file, which is a common malware technique.',
entitySummaryMarkdown:
'Potential malware attack involving {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.',
id: 'fd82a3bf-45e4-43ba-bb8f-795584923474',
mitreAttackTactics: ['Execution', 'Persistence', 'Defense Evasion'],
summaryMarkdown:
'A potential malware attack progression was detected on {{ host.name 4d31c85a-f08b-4461-a67e-ca1991427e6d }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that decoded and executed a malicious executable, which exhibited persistence behavior and triggered multiple security alerts.',
title: 'Potential Malware Attack Progression Detected',
},
{
alertIds: ['9896f807-4e57-4da8-b1ea-d62645045428'],
detailsMarkdown:
'- {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name K2G8Q8Z9.txt }}) into another executable ({{ file.name Z5K7J6H8.exe }}).\n - This behavior triggered a "Malicious Behavior Prevention Alert: Suspicious Microsoft Office Child Process" alert.\n\n- The certutil.exe process is commonly abused by malware to decode and execute malicious payloads.',
entitySummaryMarkdown:
'Potential malware attack involving {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.',
id: '79a97cec-4126-479a-8fa1-706aec736bc5',
mitreAttackTactics: ['Execution', 'Defense Evasion'],
summaryMarkdown:
'A potential malware attack was detected on {{ host.name c7697774-7350-4153-9061-64a484500241 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. A Microsoft Office process launched a suspicious child process that attempted to decode and execute a malicious payload, triggering a security alert.',
title: 'Potential Malware Attack Detected',
},
{
alertIds: ['53157916-4437-4a92-a7fd-f792c4aa1aae'],
detailsMarkdown:
'- {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware incident:\n\n - The explorer.exe process ({{ process.executable C:\\Windows\\explorer.exe }}) attempted to create a file ({{ file.name 25a994dc-c605-425c-b139-c273001dc816 }}) at {{ file.path 9693f967-2b96-4281-893e-79adbdcf1066 }}.\n - This file creation attempt was blocked, and a "Malware Prevention Alert" was triggered.\n\n- The file {{ file.name 25a994dc-c605-425c-b139-c273001dc816 }} was likely identified as malicious by Elastic Endpoint Security, leading to the prevention of its creation.',
entitySummaryMarkdown:
'Potential malware incident involving {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}.',
id: '13c4a00d-88a8-408c-9ed5-b2518df0eae3',
mitreAttackTactics: ['Defense Evasion'],
summaryMarkdown:
'A potential malware incident was detected on {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} involving {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }}. The explorer.exe process attempted to create a file that was identified as malicious by Elastic Endpoint Security, triggering a malware prevention alert and blocking the file creation.',
title: 'Potential Malware Incident Detected',
},
],
replacements: {
'8e2853aa-f0b9-4c95-9895-d71a7aa8b4a4': 'C:\\Windows\\mpsvc.dll',
'73f9a91c-3268-4229-8bb9-7c1fe2f667bc': 'Administrator',
'001cc415-42ad-4b21-a92c-e4193b283b78': 'SRVWIN02',
'b0fd402c-9752-4d43-b0f7-9750cce247e7': 'OMM-WIN-DETECT',
'604300eb-3711-4e38-8500-0a395d3cc1e5': 'mpsvc.dll',
'e770a817-0e87-4e4b-8e26-1bf504a209d2':
'13c8569b2bfd65ecfa75b264b6d7f31a1b50c530101bcaeb8569b3a0190e93b4',
'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96':
'250d812f9623d0916bba521d4221757163f199d64ffab92f888581a00ca499be',
'4053a825-9628-470a-8c83-c733e941bece':
'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe',
'2acbc31d-a0ec-4f99-a544-b23fcdd37b70':
'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe',
'8cfde870-cd3b-40b8-9999-901c0b97fb5a':
'138876c616a2f403aadb6a1c3da316d97f15669fc90187a27d7f94a55674d19a',
'da8fa0b1-1f51-4c63-b5d0-2e35c9fa3b84':
'2bc20691da4ec37cc1f967d6f5b79e95c7f07f6e473724479dcf4402a192969c',
'9693f967-2b96-4281-893e-79adbdcf1066':
'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e',
'25a994dc-c605-425c-b139-c273001dc816':
'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e',
'597fd583-4036-4631-a71a-7a8a7dd17848':
'6cea6124aa27adf2f782db267c5173742b675331107cdb7372a46ae469366210',
'550691a2-edac-4cc5-a453-6a36d5351c76':
'26a9788ca7189baa31dcbb509779c1ac5d2e72297cb02e4b4ee8c1f9e371666f',
'df97c2d9-9e28-43e0-a461-3bacf91a262f':
'c107e4e903724f2a1e0ea8e0135032d1d75624bf7de8b99c17ba9a9f178c2d6a',
'f6558144-630c-49ec-8aa2-fe96364883c7':
'afb8ed160ae9f78990980d92fb3213ffff74a12ec75034384b4f53a3edf74400',
'c6cbd80f-9602-4748-b951-56c0745f3e1f':
'137aa729928d2a0df1d5e35f47f0ad2bd525012409a889358476dca8e06ba804',
'113819ec-cfd0-4867-bfbd-cb9ca8e1e69f':
'5bec676e7faa4b6329027c9798e70e6d5e7a4d6d08696597dc8a3b31490bdfe5',
'ae8aacc8-bfe3-4735-8075-a135fcf60722':
'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll',
'4d31c85a-f08b-4461-a67e-ca1991427e6d': 'SRVWIN01',
'2ddee627-fbe2-45a8-8b2b-eba7542b4e3d': 'cdnver.dll',
'8e8e2e05-521d-4988-b7ce-4763fea1faf0':
'f5d9e2d82dad1ff40161b92c097340ee07ae43715f6c9270705fb0db7a9eeca4',
'4691c8da-ccba-40f2-b540-0ec5656ad8ef':
'b4bf1d7b993141f813008dccab0182af3c810de0c10e43a92ac0d9d5f1dbf42e',
'53b3ee1a-1594-447d-94a0-338af2a22844':
'4ab871ec3d41d3271c2a1fc3861fabcbc06f7f4534a1b6f741816417bc73927c',
'2e744d88-3040-4ab8-90a3-1d5011ab1a6b':
'1f492a1b66f6c633a81a4c6318345b07f6d05624714da0b0cb7dd6d8e374e249',
'9e44ac92-1d88-4cfc-9f38-781c3457b395':
'e6fba60799acc5bf85ca34ec634482b95ac941c71e9822dfa34d9d774dd1e2bd',
'5164c2f3-9f96-4867-a263-cc7041b06ece': 'C:\\ProgramData\\Q3C7N1V8.exe',
'0aaff15a-a311-46b8-b20b-0db550e5005e': 'Q3C7N1V8.exe',
'452ed87e-2e64-486b-ad6a-b368010f570a':
'4be1be7b4351f2e94fa706ea1ab7f9dd7c3267a77832e94794ebb2b0a6d8493a',
'84e2000b-3c0a-4775-9903-89ebe953f247': 'C:\\Programdata\\Q3C7N1V8.exe',
'd2ce2be7-1d86-4fbe-851a-05883e575a0b':
'5ed1aa94157bd6b949bf1527320caf0e6f5f61d86518e5f13912314d0f024e88',
'7d0ae0fc-7c24-4760-8543-dc4d44f17126':
'a786f965902ed5490656f48adc79b46676dc2518a052759625f6108bbe2d864d',
'c7697774-7350-4153-9061-64a484500241': 'SRVWIN01-PRIV',
'b26da819-a141-4efd-84b0-6d2876f8800d': 'OMM-WIN-PREVENT',
'9896f807-4e57-4da8-b1ea-d62645045428':
'2a33e2c6150dfc6f0d49022fc0b5aefc90db76b6e237371992ebdee909d3c194',
'6d4355b3-3d1a-4673-b0c7-51c1c698bcc5': 'SRVWIN02-PRIV',
'53157916-4437-4a92-a7fd-f792c4aa1aae':
'605ebf550ae0ffc4aec2088b97cbf99853113b0db81879500547c4277ca1981a',
},
updated: new Date('2024-04-15T13:48:44.393Z'),
isLoadingPost: false,
didInitialFetch: true,
failureReason: null,
generationIntervals: [
{
date: new Date('2024-04-15T13:48:44.397Z').toISOString(),
durationMs: 85807,
},
claudeV3SonnetUsWest2: {
connectorId: 'claudeV3SonnetUsWest2',
attackDiscoveries: [
{
alertIds: [
'e6b49cac-a5d0-4d22-a7e2-868881aa9d20',
'648d8ad4-6f4e-4c06-99f7-cdbce20f4480',
'bbfc0fd4-fbad-4ac4-b1b4-a9acd91ac504',
'c1252ff5-113a-4fe8-b341-9726c5011402',
'a3544119-12a0-4dd2-97b8-ed211233393b',
'3575d826-2350-4a4d-bb26-c92c324f38ca',
'778fd5cf-13b9-40fe-863d-abac2a6fe3c7',
'2ed82499-db91-4197-ad8d-5f03f59c6616',
'280e1e76-3a10-470c-8adc-094094badb1d',
'61ae312a-82c7-4bae-8014-f3790628b82f',
],
detailsMarkdown:
'- {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }} was compromised by a malicious executable {{ file.name d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe }} launched from {{ process.parent.executable C:\\Windows\\Explorer.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The malicious executable created a suspicious file {{ file.name d2aeb0e2-e327-4979-aa31-d46454d5b1a5 }} and loaded it into memory via {{ process.executable C:\\Windows\\MsMpEng.exe }}\n\n- This behavior triggered multiple alerts for shellcode injection, ransomware activity, and other malicious behaviors\n\n- The malware appears to be a variant of ransomware',
entitySummaryMarkdown:
'Malicious activity detected on {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
id: 'e536ae7a-4ae8-4e47-9f20-0e40ac675d56',
mitreAttackTactics: [
'Initial Access',
'Execution',
'Persistence',
'Privilege Escalation',
'Defense Evasion',
'Discovery',
'Lateral Movement',
'Collection',
'Exfiltration',
'Impact',
],
summaryMarkdown:
'Multiple critical alerts indicate a ransomware attack on {{ host.name fb5608fd-5bf4-4b28-8ea8-a51160df847f }}, likely initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
title: 'Ransomware Attack',
},
{
alertIds: [
'b544dd2a-e208-4dac-afba-b60f799ab623',
'7d3a4bae-3bd7-41a7-aee2-f68088aef1d5',
'd1716ee3-e12e-4b03-8057-b9320f3ce825',
'ca31a2b6-cb77-4ca2-ada0-14bb39ec1a2e',
'a0b56cd3-1f7f-4221-bc88-6efb4082e781',
'2ab6a581-e2ab-4a54-a0e1-7b23bf8299cb',
'1d1040c3-9e30-47fb-b2cf-f9e8ab647547',
],
detailsMarkdown:
'- {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} was compromised by a malicious executable {{ file.name 94b3c78d-c647-4ee1-9eba-8101b806a7af }} launched from {{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The malicious executable was decoded from a file {{ file.name 30820807-30f3-4b43-bb1d-c523d6375f49 }} using certutil.exe, which is a common malware technique\n\n- It established persistence by modifying registry keys and loading a malicious DLL {{ file.name 30820807-30f3-4b43-bb1d-c523d6375f49 }} via rundll32.exe\n\n- This behavior triggered alerts for malware, suspicious Microsoft Office child processes, and uncommon persistence mechanisms',
entitySummaryMarkdown:
'Malicious activity detected on {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
id: '36d3daf0-93f0-4887-8d2c-a935863091a0',
mitreAttackTactics: [
'Initial Access',
'Execution',
'Persistence',
'Privilege Escalation',
'Defense Evasion',
'Discovery',
],
summaryMarkdown:
'Multiple critical alerts indicate a malware infection on {{ host.name b6fb7e37-e3d6-47aa-b176-83d800984be8 }} likely initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }} via a malicious Microsoft Office document',
title: 'Malware Infection via Malicious Office Document',
},
{
alertIds: ['67a27f31-f18f-4256-b64f-63e718eb688e'],
detailsMarkdown:
'- {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }} was targeted by a malicious executable that attempted to be decoded from a file using certutil.exe, which is a common malware technique\n\n- The malicious activity was initiated from {{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}, likely via a malicious Microsoft Office document\n\n- This behavior triggered an alert for a suspicious Microsoft Office child process',
entitySummaryMarkdown:
'Suspected malicious activity detected on {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
id: 'bbf6f5fc-f739-4598-945b-463dea90ea50',
mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'],
summaryMarkdown:
'A suspicious Microsoft Office child process was detected on {{ host.name b8639719-38c4-401e-8582-6e8ea098feef }}, potentially initiated by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }} via a malicious document',
title: 'Suspected Malicious Activity via Office Document',
},
{
alertIds: ['2242a749-7d59-4f24-8b33-b8772ab4f8df'],
detailsMarkdown:
'- A suspicious file creation attempt {{ file.name efcf53ac-3943-4d7d-96b5-d84eefd2c478 }} with the same hash as a known malicious executable was blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}\n\n- The file was likely being staged for later malicious activity\n\n- This triggered a malware prevention alert, indicating the threat was detected and mitigated',
entitySummaryMarkdown:
'Suspected malicious file blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
id: '069a5b43-1458-4e87-8dc6-97459a020ef8',
mitreAttackTactics: ['Initial Access', 'Execution'],
summaryMarkdown:
'A suspected malicious file creation was blocked on {{ host.name 6bcc5c79-2171-4c71-9bea-fe0c116d3803 }} by {{ user.name 4f7ff689-3079-4811-8fec-8c2bc2646cc2 }}',
title: 'Suspected Malicious File Creation Blocked',
},
],
replacements: {
'6fcdf365-367a-4695-b08e-519c31345fec': 'C:\\Windows\\mpsvc.dll',
'4f7ff689-3079-4811-8fec-8c2bc2646cc2': 'Administrator',
'fb5608fd-5bf4-4b28-8ea8-a51160df847f': 'SRVWIN02',
'a141c5f0-5c06-41b8-8399-27c03a459398': 'OMM-WIN-DETECT',
'd2aeb0e2-e327-4979-aa31-d46454d5b1a5': 'mpsvc.dll',
'e6b49cac-a5d0-4d22-a7e2-868881aa9d20':
'13c8569b2bfd65ecfa75b264b6d7f31a1b50c530101bcaeb8569b3a0190e93b4',
'648d8ad4-6f4e-4c06-99f7-cdbce20f4480':
'250d812f9623d0916bba521d4221757163f199d64ffab92f888581a00ca499be',
'fca45966-448c-4652-9e02-2600dfa02a35':
'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe',
'5b9f846a-c497-4631-8a2f-7de265bfc864':
'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e.exe',
'bbfc0fd4-fbad-4ac4-b1b4-a9acd91ac504':
'138876c616a2f403aadb6a1c3da316d97f15669fc90187a27d7f94a55674d19a',
'61ae312a-82c7-4bae-8014-f3790628b82f':
'2bc20691da4ec37cc1f967d6f5b79e95c7f07f6e473724479dcf4402a192969c',
'f1bbf0b8-d417-438f-ad09-dd8a854e0abb':
'C:\\Users\\Administrator\\Desktop\\8813719803\\d55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e',
'efcf53ac-3943-4d7d-96b5-d84eefd2c478':
'd55f983c994caa160ec63a59f6b4250fe67fb3e8c43a388aec60a4a6978e9f1e',
'c1252ff5-113a-4fe8-b341-9726c5011402':
'6cea6124aa27adf2f782db267c5173742b675331107cdb7372a46ae469366210',
'a3544119-12a0-4dd2-97b8-ed211233393b':
'26a9788ca7189baa31dcbb509779c1ac5d2e72297cb02e4b4ee8c1f9e371666f',
'3575d826-2350-4a4d-bb26-c92c324f38ca':
'c107e4e903724f2a1e0ea8e0135032d1d75624bf7de8b99c17ba9a9f178c2d6a',
'778fd5cf-13b9-40fe-863d-abac2a6fe3c7':
'afb8ed160ae9f78990980d92fb3213ffff74a12ec75034384b4f53a3edf74400',
'2ed82499-db91-4197-ad8d-5f03f59c6616':
'137aa729928d2a0df1d5e35f47f0ad2bd525012409a889358476dca8e06ba804',
'280e1e76-3a10-470c-8adc-094094badb1d':
'5bec676e7faa4b6329027c9798e70e6d5e7a4d6d08696597dc8a3b31490bdfe5',
'6fad79d9-1ed4-4c1d-8b30-43023b7a5552':
'C:\\Users\\Administrator\\AppData\\Local\\cdnver.dll',
'b6fb7e37-e3d6-47aa-b176-83d800984be8': 'SRVWIN01',
'30820807-30f3-4b43-bb1d-c523d6375f49': 'cdnver.dll',
'1d1040c3-9e30-47fb-b2cf-f9e8ab647547':
'f5d9e2d82dad1ff40161b92c097340ee07ae43715f6c9270705fb0db7a9eeca4',
'b544dd2a-e208-4dac-afba-b60f799ab623':
'b4bf1d7b993141f813008dccab0182af3c810de0c10e43a92ac0d9d5f1dbf42e',
'7d3a4bae-3bd7-41a7-aee2-f68088aef1d5':
'4ab871ec3d41d3271c2a1fc3861fabcbc06f7f4534a1b6f741816417bc73927c',
'd1716ee3-e12e-4b03-8057-b9320f3ce825':
'1f492a1b66f6c633a81a4c6318345b07f6d05624714da0b0cb7dd6d8e374e249',
'ca31a2b6-cb77-4ca2-ada0-14bb39ec1a2e':
'e6fba60799acc5bf85ca34ec634482b95ac941c71e9822dfa34d9d774dd1e2bd',
'03bcdffb-54d1-457e-9599-f10b93e10ed3': 'C:\\ProgramData\\Q3C7N1V8.exe',
'94b3c78d-c647-4ee1-9eba-8101b806a7af': 'Q3C7N1V8.exe',
'8fd14f7c-6b89-43b2-b58e-09502a007e21':
'4be1be7b4351f2e94fa706ea1ab7f9dd7c3267a77832e94794ebb2b0a6d8493a',
'2342b541-1c6b-4d59-bbd4-d897637573e1': 'C:\\Programdata\\Q3C7N1V8.exe',
'a0b56cd3-1f7f-4221-bc88-6efb4082e781':
'5ed1aa94157bd6b949bf1527320caf0e6f5f61d86518e5f13912314d0f024e88',
'2ab6a581-e2ab-4a54-a0e1-7b23bf8299cb':
'a786f965902ed5490656f48adc79b46676dc2518a052759625f6108bbe2d864d',
'b8639719-38c4-401e-8582-6e8ea098feef': 'SRVWIN01-PRIV',
'0549244b-3878-4ff8-a327-1758b8e88c10': 'OMM-WIN-PREVENT',
'67a27f31-f18f-4256-b64f-63e718eb688e':
'2a33e2c6150dfc6f0d49022fc0b5aefc90db76b6e237371992ebdee909d3c194',
'6bcc5c79-2171-4c71-9bea-fe0c116d3803': 'SRVWIN02-PRIV',
'2242a749-7d59-4f24-8b33-b8772ab4f8df':
'605ebf550ae0ffc4aec2088b97cbf99853113b0db81879500547c4277ca1981a',
},
updated: new Date('2024-04-15T15:11:24.903Z'),
{
date: new Date('2024-04-15T12:41:15.255Z').toISOString(),
durationMs: 12751,
},
},
generationIntervals: {
claudeV3SonnetUsEast1: [
{
connectorId: 'claudeV3SonnetUsEast1',
date: new Date('2024-04-15T13:48:44.397Z'),
durationMs: 85807,
},
{
connectorId: 'claudeV3SonnetUsEast1',
date: new Date('2024-04-15T12:41:15.255Z'),
durationMs: 12751,
},
{
connectorId: 'claudeV3SonnetUsEast1',
date: new Date('2024-04-12T20:59:13.238Z'),
durationMs: 46169,
},
{
connectorId: 'claudeV3SonnetUsEast1',
date: new Date('2024-04-12T19:34:56.701Z'),
durationMs: 86674,
},
{
connectorId: 'claudeV3SonnetUsEast1',
date: new Date('2024-04-12T19:17:21.697Z'),
durationMs: 78486,
},
],
claudeV3SonnetUsWest2: [
{
connectorId: 'claudeV3SonnetUsWest2',
date: new Date('2024-04-15T15:11:24.906Z'),
durationMs: 71715,
},
{
connectorId: 'claudeV3SonnetUsWest2',
date: new Date('2024-04-12T13:13:35.335Z'),
durationMs: 66176,
},
{
connectorId: 'claudeV3SonnetUsWest2',
date: new Date('2024-04-11T18:30:36.360Z'),
durationMs: 88079,
},
{
connectorId: 'claudeV3SonnetUsWest2',
date: new Date('2024-04-11T18:12:50.350Z'),
durationMs: 77704,
},
{
connectorId: 'claudeV3SonnetUsWest2',
date: new Date('2024-04-11T17:57:21.902Z'),
durationMs: 77016,
},
],
},
{
date: new Date('2024-04-12T20:59:13.238Z').toISOString(),
durationMs: 46169,
},
{
date: new Date('2024-04-12T19:34:56.701Z').toISOString(),
durationMs: 86674,
},
{
date: new Date('2024-04-12T19:17:21.697Z').toISOString(),
durationMs: 78486,
},
],
fetchAttackDiscoveries,
onCancel: jest.fn(),
attackDiscoveries: [
{
timestamp: new Date('2024-04-15T15:11:24.906Z').toISOString(),
alertIds: [
'e770a817-0e87-4e4b-8e26-1bf504a209d2',
'f0ab5b5d-55c5-4d05-8f4f-12f0e62ecd96',
@ -394,6 +65,7 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = (
title: 'Potential Ransomware Attack Progression Detected',
},
{
timestamp: new Date('2024-04-15T15:11:24.906Z').toISOString(),
alertIds: [
'4691c8da-ccba-40f2-b540-0ec5656ad8ef',
'53b3ee1a-1594-447d-94a0-338af2a22844',
@ -413,6 +85,7 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = (
title: 'Potential Malware Attack Progression Detected',
},
{
timestamp: new Date('2024-04-15T15:11:24.906Z').toISOString(),
alertIds: ['9896f807-4e57-4da8-b1ea-d62645045428'],
detailsMarkdown:
'- {{ host.name c7697774-7350-4153-9061-64a484500241 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware attack:\n\n - A Microsoft Office process ({{ process.parent.executable C:\\Program Files\\Microsoft Office\\root\\Office16\\EXCEL.EXE }}) launched a suspicious child process ({{ process.name certutil.exe }}) with unusual arguments to decode a file ({{ file.name K2G8Q8Z9.txt }}) into another executable ({{ file.name Z5K7J6H8.exe }}).\n - This behavior triggered a "Malicious Behavior Prevention Alert: Suspicious Microsoft Office Child Process" alert.\n\n- The certutil.exe process is commonly abused by malware to decode and execute malicious payloads.',
@ -425,6 +98,7 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = (
title: 'Potential Malware Attack Detected',
},
{
timestamp: new Date('2024-04-15T15:11:24.906Z').toISOString(),
alertIds: ['53157916-4437-4a92-a7fd-f792c4aa1aae'],
detailsMarkdown:
'- {{ host.name 6d4355b3-3d1a-4673-b0c7-51c1c698bcc5 }} and {{ user.name 73f9a91c-3268-4229-8bb9-7c1fe2f667bc }} were involved in a potential malware incident:\n\n - The explorer.exe process ({{ process.executable C:\\Windows\\explorer.exe }}) attempted to create a file ({{ file.name 25a994dc-c605-425c-b139-c273001dc816 }}) at {{ file.path 9693f967-2b96-4281-893e-79adbdcf1066 }}.\n - This file creation attempt was blocked, and a "Malware Prevention Alert" was triggered.\n\n- The file {{ file.name 25a994dc-c605-425c-b139-c273001dc816 }} was likely identified as malicious by Elastic Endpoint Security, leading to the prevention of its creation.',
@ -505,29 +179,18 @@ export const getMockUseAttackDiscoveriesWithCachedAttackDiscoveries = (
isLoading: false,
});
export const getMockUseAttackDiscoveriesWithNoAttackDiscoveries = (
fetchAttackDiscoveries: () => Promise<void>
): UseAttackDiscovery => ({
alertsContextCount: null,
approximateFutureTime: null,
cachedAttackDiscoveries: {},
fetchAttackDiscoveries,
generationIntervals: undefined,
attackDiscoveries: [],
lastUpdated: null,
replacements: {},
isLoading: false,
});
export const getMockUseAttackDiscoveriesWithNoAttackDiscoveriesLoading = (
fetchAttackDiscoveries: () => Promise<void>
): UseAttackDiscovery => ({
alertsContextCount: null,
approximateFutureTime: new Date('2024-04-15T17:13:29.470Z'), // <-- estimated generation completion time
cachedAttackDiscoveries: {},
fetchAttackDiscoveries,
onCancel: jest.fn(),
generationIntervals: undefined,
attackDiscoveries: [],
isLoadingPost: false,
didInitialFetch: true,
failureReason: null,
lastUpdated: null,
replacements: {},
isLoading: true, // <-- attack discoveries are being generated

View file

@ -32,6 +32,7 @@ describe('EmptyStates', () => {
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={null}
isLoading={isLoading}
onGenerate={onGenerate}
/>
@ -72,6 +73,7 @@ describe('EmptyStates', () => {
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={null}
isLoading={isLoading}
onGenerate={onGenerate}
/>
@ -83,6 +85,10 @@ describe('EmptyStates', () => {
expect(screen.queryByTestId('welcome')).not.toBeInTheDocument();
});
it('does NOT render the Failure prompt', () => {
expect(screen.queryByTestId('failure')).not.toBeInTheDocument();
});
it('renders the No Alerts prompt', () => {
expect(screen.getByTestId('noAlerts')).toBeInTheDocument();
});
@ -92,6 +98,51 @@ describe('EmptyStates', () => {
});
});
describe('when the Failure prompt should be shown', () => {
beforeEach(() => {
jest.clearAllMocks();
const aiConnectorsCount = 1;
const alertsContextCount = 10;
const alertsCount = 10;
const attackDiscoveriesCount = 10;
const connectorId = 'test-connector-id';
const isLoading = false;
const onGenerate = jest.fn();
render(
<TestProviders>
<EmptyStates
aiConnectorsCount={aiConnectorsCount}
alertsContextCount={alertsContextCount}
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={"you're a failure"}
isLoading={isLoading}
onGenerate={onGenerate}
/>
</TestProviders>
);
});
it('does NOT render the Welcome prompt', () => {
expect(screen.queryByTestId('welcome')).not.toBeInTheDocument();
});
it('renders the Failure prompt', () => {
expect(screen.getByTestId('failure')).toBeInTheDocument();
});
it('does NOT render the No Alerts prompt', () => {
expect(screen.queryByTestId('noAlerts')).not.toBeInTheDocument();
});
it('does NOT render the Empty prompt', () => {
expect(screen.queryByTestId('emptyPrompt')).not.toBeInTheDocument();
});
});
describe('when the Empty prompt should be shown', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -112,6 +163,7 @@ describe('EmptyStates', () => {
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={null}
isLoading={isLoading}
onGenerate={onGenerate}
/>
@ -123,6 +175,10 @@ describe('EmptyStates', () => {
expect(screen.queryByTestId('welcome')).not.toBeInTheDocument();
});
it('does NOT render the Failure prompt', () => {
expect(screen.queryByTestId('failure')).not.toBeInTheDocument();
});
it('does NOT render the No Alerts prompt', () => {
expect(screen.queryByTestId('noAlerts')).not.toBeInTheDocument();
});
@ -154,6 +210,7 @@ describe('EmptyStates', () => {
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={null}
isLoading={isLoading}
onGenerate={onGenerate}
/>
@ -165,6 +222,10 @@ describe('EmptyStates', () => {
expect(screen.queryByTestId('welcome')).not.toBeInTheDocument();
});
it('does NOT render the Failure prompt', () => {
expect(screen.queryByTestId('failure')).not.toBeInTheDocument();
});
it('does NOT render the No Alerts prompt', () => {
expect(screen.queryByTestId('noAlerts')).not.toBeInTheDocument();
});
@ -200,6 +261,7 @@ describe('EmptyStates', () => {
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
failureReason={null}
isLoading={isLoading}
onGenerate={onGenerate}
/>
@ -211,6 +273,10 @@ describe('EmptyStates', () => {
expect(screen.queryByTestId('welcome')).not.toBeInTheDocument();
});
it('does NOT render the Failure prompt', () => {
expect(screen.queryByTestId('failure')).not.toBeInTheDocument();
});
it('does NOT render the No Alerts prompt', () => {
expect(screen.queryByTestId('noAlerts')).not.toBeInTheDocument();
});

View file

@ -7,6 +7,7 @@
import React from 'react';
import { Failure } from '../failure';
import { EmptyPrompt } from '../empty_prompt';
import { showEmptyPrompt, showNoAlertsPrompt, showWelcomePrompt } from '../helpers';
import { NoAlerts } from '../no_alerts';
@ -18,6 +19,7 @@ interface Props {
alertsCount: number;
attackDiscoveriesCount: number;
connectorId: string | undefined;
failureReason: string | null;
isLoading: boolean;
onGenerate: () => Promise<void>;
}
@ -28,11 +30,14 @@ const EmptyStatesComponent: React.FC<Props> = ({
alertsCount,
attackDiscoveriesCount,
connectorId,
failureReason,
isLoading,
onGenerate,
}) => {
if (showWelcomePrompt({ aiConnectorsCount, isLoading })) {
return <Welcome />;
} else if (failureReason !== null) {
return <Failure failureReason={failureReason} />;
} else if (showNoAlertsPrompt({ alertsContextCount, isLoading })) {
return <NoAlerts />;
} else if (showEmptyPrompt({ attackDiscoveriesCount, isLoading })) {

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Failure } from '.';
import { LEARN_MORE, FAILURE_TITLE } from './translations';
const failureReason = "You're a failure";
describe('Failure', () => {
beforeEach(() => {
render(<Failure failureReason={failureReason} />);
});
it('renders the expected title', () => {
const title = screen.getByTestId('failureTitle');
expect(title).toHaveTextContent(FAILURE_TITLE);
});
it('renders the expected body text', () => {
const bodyText = screen.getByTestId('bodyText');
expect(bodyText).toHaveTextContent(failureReason);
});
describe('link', () => {
let learnMoreLink: HTMLElement;
beforeEach(() => {
learnMoreLink = screen.getByTestId('learnMoreLink');
});
it('links to the documentation', () => {
expect(learnMoreLink).toHaveAttribute(
'href',
'https://www.elastic.co/guide/en/security/current/attack-discovery.html'
);
});
it('opens in a new tab', () => {
expect(learnMoreLink).toHaveAttribute('target', '_blank');
});
it('has the expected text', () => {
expect(learnMoreLink).toHaveTextContent(LEARN_MORE);
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import React from 'react';
import * as i18n from './translations';
const FailureComponent: React.FC<{ failureReason: string }> = ({ failureReason }) => {
return (
<EuiFlexGroup alignItems="center" data-test-subj="failure" direction="column">
<EuiFlexItem data-test-subj="emptyPromptContainer" grow={false}>
<EuiEmptyPrompt
iconType="error"
color="danger"
body={
<EuiText color="subdued" data-test-subj="bodyText">
{failureReason}
</EuiText>
}
title={<h2 data-test-subj="failureTitle">{i18n.FAILURE_TITLE}</h2>}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
external={true}
data-test-subj="learnMoreLink"
href="https://www.elastic.co/guide/en/security/current/attack-discovery.html"
target="_blank"
>
{i18n.LEARN_MORE}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const Failure = React.memo(FailureComponent);

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const LEARN_MORE = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.failure.learnMoreLink',
{
defaultMessage: 'Learn more about Attack discovery',
}
);
export const FAILURE_TITLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.failure.title',
{
defaultMessage: 'Attack discovery generation failed',
}
);

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import type { EuiButtonProps } from '@elastic/eui';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { ConnectorSelectorInline } from '@kbn/elastic-assistant';
import { noop } from 'lodash/fp';
import React from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAssistantAvailability } from '../../../assistant/use_assistant_availability';
import * as i18n from './translations';
@ -18,7 +19,9 @@ interface Props {
connectorId: string | undefined;
connectorsAreConfigured: boolean;
isLoading: boolean;
isDisabledActions: boolean;
onGenerate: () => void;
onCancel: () => void;
onConnectorIdSelected: (connectorId: string) => void;
}
@ -26,13 +29,44 @@ const HeaderComponent: React.FC<Props> = ({
connectorId,
connectorsAreConfigured,
isLoading,
isDisabledActions,
onGenerate,
onConnectorIdSelected,
onCancel,
}) => {
const isFlyoutMode = false; // always false for attack discovery
const { hasAssistantPrivilege } = useAssistantAvailability();
const { euiTheme } = useEuiTheme();
const disabled = !hasAssistantPrivilege || isLoading || connectorId == null;
const disabled = !hasAssistantPrivilege || connectorId == null;
const [didCancel, setDidCancel] = useState(false);
const handleCancel = useCallback(() => {
setDidCancel(true);
onCancel();
}, [onCancel]);
useEffect(() => {
if (isLoading === false) setDidCancel(false);
}, [isLoading]);
const buttonProps = useMemo(
() =>
isLoading
? {
dataTestSubj: 'cancel',
color: 'danger' as EuiButtonProps['color'],
onClick: handleCancel,
text: i18n.CANCEL,
}
: {
dataTestSubj: 'generate',
color: 'primary' as EuiButtonProps['color'],
onClick: onGenerate,
text: i18n.GENERATE,
},
[isLoading, handleCancel, onGenerate]
);
return (
<EuiFlexGroup
@ -61,13 +95,13 @@ const HeaderComponent: React.FC<Props> = ({
data-test-subj="generateTooltip"
>
<EuiButton
data-test-subj="generate"
data-test-subj={buttonProps.dataTestSubj}
size="s"
disabled={disabled}
isLoading={isLoading}
onClick={onGenerate}
disabled={disabled || didCancel || isDisabledActions}
color={buttonProps.color}
onClick={buttonProps.onClick}
>
{isLoading ? i18n.LOADING : i18n.GENERATE}
{buttonProps.text}
</EuiButton>
</EuiToolTip>
</EuiFlexItem>

View file

@ -14,10 +14,10 @@ export const GENERATE = i18n.translate(
}
);
export const LOADING = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.header.loadingButton',
export const CANCEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.pages.header.cancelButton',
{
defaultMessage: 'Loading...',
defaultMessage: 'Cancel',
}
);

View file

@ -63,9 +63,12 @@ jest.mock('../use_attack_discovery', () => ({
approximateFutureTime: null,
attackDiscoveries: [],
cachedAttackDiscoveries: {},
didInitialFetch: true,
fetchAttackDiscoveries: jest.fn(),
failureReason: null,
generationIntervals: undefined,
isLoading: false,
isLoadingPost: false,
lastUpdated: null,
replacements: {},
}),

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingLogo, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/react';
import {
ATTACK_DISCOVERY_STORAGE_KEY,
@ -13,7 +13,7 @@ import {
useAssistantContext,
useLoadConnectors,
} from '@kbn/elastic-assistant';
import type { Replacements } from '@kbn/elastic-assistant-common';
import type { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common';
import { uniq } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocalStorage } from 'react-use';
@ -37,7 +37,6 @@ import { PageTitle } from './page_title';
import { Summary } from './summary';
import { Upgrade } from './upgrade';
import { useAttackDiscovery } from '../use_attack_discovery';
import type { AttackDiscovery } from '../types';
const AttackDiscoveryPageComponent: React.FC = () => {
const spaceId = useSpaceId() ?? 'default';
@ -72,31 +71,32 @@ const AttackDiscoveryPageComponent: React.FC = () => {
alertsContextCount,
approximateFutureTime,
attackDiscoveries,
cachedAttackDiscoveries,
didInitialFetch,
failureReason,
fetchAttackDiscoveries,
generationIntervals,
onCancel,
isLoading,
isLoadingPost,
lastUpdated,
replacements,
} = useAttackDiscovery({
connectorId,
setConnectorId,
setLoadingConnectorId,
});
// get last updated from the cached attack discoveries if it exists:
const [selectedConnectorLastUpdated, setSelectedConnectorLastUpdated] = useState<Date | null>(
cachedAttackDiscoveries[connectorId ?? '']?.updated ?? null
lastUpdated ?? null
);
// get cached attack discoveries if they exist:
const [selectedConnectorAttackDiscoveries, setSelectedConnectorAttackDiscoveries] = useState<
AttackDiscovery[]
>(cachedAttackDiscoveries[connectorId ?? '']?.attackDiscoveries ?? []);
const [selectedConnectorAttackDiscoveries, setSelectedConnectorAttackDiscoveries] =
useState<AttackDiscoveries>(attackDiscoveries ?? []);
// get replacements from the cached attack discoveries if they exist:
const [selectedConnectorReplacements, setSelectedConnectorReplacements] = useState<Replacements>(
cachedAttackDiscoveries[connectorId ?? '']?.replacements ?? {}
replacements ?? {}
);
// the number of unique alerts in the attack discoveries:
@ -114,27 +114,12 @@ const AttackDiscoveryPageComponent: React.FC = () => {
// update the connector ID in local storage:
setConnectorId(selectedConnectorId);
setLocalStorageAttackDiscoveryConnectorId(selectedConnectorId);
// get the cached attack discoveries for the selected connector:
const cached = cachedAttackDiscoveries[selectedConnectorId];
if (cached != null) {
setSelectedConnectorReplacements(cached.replacements ?? {});
setSelectedConnectorAttackDiscoveries(cached.attackDiscoveries ?? []);
setSelectedConnectorLastUpdated(cached.updated ?? null);
} else {
setSelectedConnectorReplacements({});
setSelectedConnectorAttackDiscoveries([]);
setSelectedConnectorLastUpdated(null);
}
},
[cachedAttackDiscoveries, setLocalStorageAttackDiscoveryConnectorId]
[setLocalStorageAttackDiscoveryConnectorId]
);
// get connector intervals from generation intervals:
const connectorIntervals = useMemo(
() => generationIntervals?.[connectorId ?? ''] ?? [],
[connectorId, generationIntervals]
);
const connectorIntervals = useMemo(() => generationIntervals ?? [], [generationIntervals]);
const pageTitle = useMemo(() => <PageTitle />, []);
@ -182,73 +167,82 @@ const AttackDiscoveryPageComponent: React.FC = () => {
connectorId={connectorId}
connectorsAreConfigured={aiConnectors != null && aiConnectors.length > 0}
isLoading={isLoading}
// disable header actions before post request has completed
isDisabledActions={isLoadingPost}
onConnectorIdSelected={onConnectorIdSelected}
onGenerate={onGenerate}
onCancel={onCancel}
/>
<EuiSpacer size="m" />
</HeaderPage>
{!didInitialFetch ? (
<EuiEmptyPrompt icon={<EuiLoadingLogo logo="logoSecurity" size="xl" />} />
) : (
<>
{showSummary({
attackDiscoveriesCount,
connectorId,
loadingConnectorId,
}) && (
<Summary
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
lastUpdated={selectedConnectorLastUpdated}
onToggleShowAnonymized={onToggleShowAnonymized}
showAnonymized={showAnonymized}
/>
)}
{showSummary({
attackDiscoveriesCount,
connectorId,
loadingConnectorId,
}) && (
<Summary
alertsCount={alertsCount}
attackDiscoveriesCount={attackDiscoveriesCount}
lastUpdated={selectedConnectorLastUpdated}
onToggleShowAnonymized={onToggleShowAnonymized}
showAnonymized={showAnonymized}
/>
)}
<>
{showLoading({
attackDiscoveriesCount,
connectorId,
isLoading,
loadingConnectorId,
}) ? (
<LoadingCallout
alertsCount={knowledgeBase.latestAlerts}
approximateFutureTime={approximateFutureTime}
connectorIntervals={connectorIntervals}
/>
) : (
selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => (
<React.Fragment key={attackDiscovery.id}>
<AttackDiscoveryPanel
attackDiscovery={attackDiscovery}
initialIsOpen={getInitialIsOpen(i)}
showAnonymized={showAnonymized}
replacements={selectedConnectorReplacements}
<>
{showLoading({
attackDiscoveriesCount,
connectorId,
isLoading: isLoading || isLoadingPost,
loadingConnectorId,
}) ? (
<LoadingCallout
alertsCount={knowledgeBase.latestAlerts}
approximateFutureTime={approximateFutureTime}
connectorIntervals={connectorIntervals}
/>
<EuiSpacer size="l" />
</React.Fragment>
))
)}
</>
<EuiFlexGroup
css={css`
max-height: 100%;
min-height: 100%;
`}
direction="column"
gutterSize="none"
>
<EuiSpacer size="xxl" />
<EuiFlexItem grow={false}>
<EmptyStates
aiConnectorsCount={aiConnectors?.length ?? null}
alertsContextCount={alertsContextCount}
alertsCount={knowledgeBase.latestAlerts}
attackDiscoveriesCount={attackDiscoveriesCount}
connectorId={connectorId}
isLoading={isLoading}
onGenerate={onGenerate}
/>
</EuiFlexItem>
</EuiFlexGroup>
) : (
selectedConnectorAttackDiscoveries.map((attackDiscovery, i) => (
<React.Fragment key={attackDiscovery.id}>
<AttackDiscoveryPanel
attackDiscovery={attackDiscovery}
initialIsOpen={getInitialIsOpen(i)}
showAnonymized={showAnonymized}
replacements={selectedConnectorReplacements}
/>
<EuiSpacer size="l" />
</React.Fragment>
))
)}
</>
<EuiFlexGroup
css={css`
max-height: 100%;
min-height: 100%;
`}
direction="column"
gutterSize="none"
>
<EuiSpacer size="xxl" />
<EuiFlexItem grow={false}>
<EmptyStates
aiConnectorsCount={aiConnectors?.length ?? null}
alertsContextCount={alertsContextCount}
alertsCount={knowledgeBase.latestAlerts}
attackDiscoveriesCount={attackDiscoveriesCount}
failureReason={failureReason}
connectorId={connectorId}
isLoading={isLoading || isLoadingPost}
onGenerate={onGenerate}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
<SpyRoute pageName={SecurityPageName.attackDiscovery} />
</SecurityRoutePageWrapper>
</div>

View file

@ -18,10 +18,10 @@ import { css } from '@emotion/react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { useKibana } from '../../../../common/lib/kibana';
import { getTimerPrefix } from './last_times_popover/helpers';
import type { GenerationInterval } from '../../../types';
import { InfoPopoverBody } from '../info_popover_body';
const TEXT_COLOR = '#343741';
@ -48,17 +48,21 @@ const CountdownComponent: React.FC<Props> = ({ approximateFutureTime, connectorI
useEffect(() => {
// periodically update the formatted date as time passes:
if (approximateFutureTime === null) {
return;
}
const intervalId = setInterval(() => {
const now = moment();
const duration = moment(approximateFutureTime).isSameOrAfter(now)
? moment.duration(moment(approximateFutureTime).diff(now))
: moment.duration(now.diff(approximateFutureTime));
const text = moment.utc(duration.asMilliseconds()).format('mm:ss');
setPrefix(getTimerPrefix(approximateFutureTime));
setTimerText(text);
if (approximateFutureTime !== null) {
const now = moment();
const duration = moment(approximateFutureTime).isSameOrAfter(now)
? moment.duration(moment(approximateFutureTime).diff(now))
: moment.duration(now.diff(approximateFutureTime));
const text = moment.utc(duration.asMilliseconds()).format('mm:ss');
setTimerText(text);
}
}, 1000);
return () => clearInterval(intervalId);

View file

@ -7,13 +7,14 @@
import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import moment from 'moment';
import React, { useMemo } from 'react';
import { PreferenceFormattedDate } from '../../../../../../common/components/formatted_date';
import { useKibana } from '../../../../../../common/lib/kibana';
import { MAX_SECONDS_BADGE_WIDTH } from '../helpers';
import * as i18n from '../translations';
import type { GenerationInterval } from '../../../../../types';
interface Props {
interval: GenerationInterval;
@ -51,7 +52,7 @@ const GenerationTimingComponent: React.FC<Props> = ({ interval }) => {
data-test-subj="date"
size="xs"
>
<PreferenceFormattedDate value={interval.date} />
<PreferenceFormattedDate value={moment(interval.date).toDate()} />
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import moment from 'moment';
import { APPROXIMATE_TIME_REMAINING, ABOVE_THE_AVERAGE_TIME } from '../translations';
import type { GenerationInterval } from '../../../../types';
export const MAX_SECONDS_BADGE_WIDTH = 64; // px

View file

@ -8,10 +8,10 @@
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useMemo } from 'react';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { useKibana } from '../../../../../common/lib/kibana';
import * as i18n from './translations';
import type { GenerationInterval } from '../../../../types';
import { GenerationTiming } from './generation_timing';
interface Props {

View file

@ -9,10 +9,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, useEuiTheme } from '@elas
import { css } from '@emotion/react';
import React, { useMemo } from 'react';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import { useKibana } from '../../../common/lib/kibana';
import { Countdown } from './countdown';
import { LoadingMessages } from './loading_messages';
import type { GenerationInterval } from '../../types';
const BACKGROUND_COLOR_LIGHT = '#E6F1FA';
const BACKGROUND_COLOR_DARK = '#0B2030';

View file

@ -7,6 +7,7 @@
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiPopoverTitle, EuiText } from '@elastic/eui';
import { css } from '@emotion/react';
import type { GenerationInterval } from '@kbn/elastic-assistant-common';
import React, { useMemo } from 'react';
import { useKibana } from '../../../../common/lib/kibana';
@ -17,7 +18,6 @@ import {
} from '../countdown/last_times_popover/helpers';
import { SECONDS_ABBREVIATION } from '../countdown/last_times_popover/translations';
import { AVERAGE_TIME } from '../countdown/translations';
import type { GenerationInterval } from '../../../types';
const TEXT_COLOR = '#343741';

View file

@ -1,180 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { GenerationInterval } from '../../types';
import {
encodeGenerationIntervals,
decodeGenerationIntervals,
getLocalStorageGenerationIntervals,
setLocalStorageGenerationIntervals,
} from '.';
const key = 'elasticAssistantDefault.attackDiscovery.default.generationIntervals';
const generationIntervals: Record<string, GenerationInterval[]> = {
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T14:13:09.838Z'),
durationMs: 173648,
},
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T13:59:49.620Z'),
durationMs: 146605,
},
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T13:47:00.629Z'),
durationMs: 255163,
},
],
testConnector2: [
{
connectorId: 'testConnector2',
date: new Date('2024-05-16T14:26:25.273Z'),
durationMs: 130447,
},
],
testConnector3: [
{
connectorId: 'testConnector3',
date: new Date('2024-05-16T14:36:53.171Z'),
durationMs: 46614,
},
{
connectorId: 'testConnector3',
date: new Date('2024-05-16T14:27:17.187Z'),
durationMs: 44129,
},
],
};
describe('storage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('encodeGenerationIntervals', () => {
it('returns null when generationIntervals is invalid', () => {
const invalidGenerationIntervals: Record<string, GenerationInterval[]> =
1n as unknown as Record<string, GenerationInterval[]>; // <-- invalid
const result = encodeGenerationIntervals(invalidGenerationIntervals);
expect(result).toBeNull();
});
it('returns the expected encoded generationIntervals', () => {
const result = encodeGenerationIntervals(generationIntervals);
expect(result).toEqual(JSON.stringify(generationIntervals));
});
});
describe('decodeGenerationIntervals', () => {
it('returns null when generationIntervals is invalid', () => {
const invalidGenerationIntervals = 'invalid generation intervals'; // <-- invalid
const result = decodeGenerationIntervals(invalidGenerationIntervals);
expect(result).toBeNull();
});
it('returns the expected decoded generation intervals', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? ''; // <-- valid intervals
const result = decodeGenerationIntervals(encoded);
expect(result).toEqual(generationIntervals);
});
it('parses date strings into Date objects', () => {
const encoded = JSON.stringify({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
],
});
const result = decodeGenerationIntervals(encoded);
expect(result).toEqual({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T14:13:09.838Z'),
durationMs: 173648,
},
],
});
});
it('returns null when date is not a string', () => {
const encoded = JSON.stringify({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: 1234, // <-- invalid
durationMs: 173648,
},
],
});
const result = decodeGenerationIntervals(encoded);
expect(result).toBeNull();
});
});
describe('getLocalStorageGenerationIntervals', () => {
it('returns null when the key is empty', () => {
const result = getLocalStorageGenerationIntervals(''); // <-- empty key
expect(result).toBeNull();
});
it('returns null the key is unknown', () => {
const result = getLocalStorageGenerationIntervals('unknown key'); // <-- unknown key
expect(result).toBeNull();
});
it('returns null when the generation intervals are invalid', () => {
localStorage.setItem(key, 'invalid generation intervals'); // <-- invalid
const result = getLocalStorageGenerationIntervals(key);
expect(result).toBeNull();
});
it('returns the expected decoded generation intervals', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? ''; // <-- valid intervals
localStorage.setItem(key, encoded);
const decoded = decodeGenerationIntervals(encoded);
const result = getLocalStorageGenerationIntervals(key);
expect(result).toEqual(decoded);
});
});
describe('setLocalStorageGenerationIntervals', () => {
const localStorageSetItemSpy = jest.spyOn(Storage.prototype, 'setItem');
it('sets the encoded generation intervals in localStorage', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? '';
setLocalStorageGenerationIntervals({ key, generationIntervals });
expect(localStorageSetItemSpy).toHaveBeenCalledWith(key, encoded);
});
});
});

View file

@ -1,120 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Replacements } from '@kbn/elastic-assistant-common';
import { isEmpty } from 'lodash/fp';
import type { AttackDiscovery, GenerationInterval } from '../../types';
export interface CachedAttackDiscoveries {
connectorId: string;
updated: Date;
attackDiscoveries: AttackDiscovery[];
replacements: Replacements;
}
export const encodeCachedAttackDiscoveries = (
cachedAttackDiscoveries: Record<string, CachedAttackDiscoveries>
): string | null => {
try {
return JSON.stringify(cachedAttackDiscoveries);
} catch {
return null;
}
};
export const decodeCachedAttackDiscoveries = (
cachedAttackDiscoveries: string
): Record<string, CachedAttackDiscoveries> | null => {
try {
return JSON.parse(cachedAttackDiscoveries);
} catch {
return null;
}
};
export const getSessionStorageCachedAttackDiscoveries = (
key: string
): Record<string, CachedAttackDiscoveries> | null => {
if (!isEmpty(key)) {
return decodeCachedAttackDiscoveries(sessionStorage.getItem(key) ?? '');
}
return null;
};
export const setSessionStorageCachedAttackDiscoveries = ({
key,
cachedAttackDiscoveries,
}: {
key: string;
cachedAttackDiscoveries: Record<string, CachedAttackDiscoveries>;
}) => {
if (!isEmpty(key)) {
const encoded = encodeCachedAttackDiscoveries(cachedAttackDiscoveries);
if (encoded != null) {
sessionStorage.setItem(key, encoded);
}
}
};
export const encodeGenerationIntervals = (
generationIntervals: Record<string, GenerationInterval[]>
): string | null => {
try {
return JSON.stringify(generationIntervals);
} catch {
return null;
}
};
export const decodeGenerationIntervals = (
generationIntervals: string
): Record<string, GenerationInterval[]> | null => {
const parseDate = (key: string, value: unknown) => {
if (key === 'date' && typeof value === 'string') {
return new Date(value);
} else if (key === 'date' && typeof value !== 'string') {
throw new Error('Invalid date');
} else {
return value;
}
};
try {
return JSON.parse(generationIntervals, parseDate);
} catch {
return null;
}
};
export const getLocalStorageGenerationIntervals = (
key: string
): Record<string, GenerationInterval[]> | null => {
if (!isEmpty(key)) {
return decodeGenerationIntervals(localStorage.getItem(key) ?? '');
}
return null;
};
export const setLocalStorageGenerationIntervals = ({
key,
generationIntervals,
}: {
key: string;
generationIntervals: Record<string, GenerationInterval[]>;
}) => {
if (!isEmpty(key)) {
const encoded = encodeGenerationIntervals(generationIntervals);
if (encoded != null) {
localStorage.setItem(key, encoded);
}
}
};

View file

@ -14,6 +14,20 @@ export const ERROR_GENERATING_ATTACK_DISCOVERIES = i18n.translate(
}
);
export const ERROR_CANCELING_ATTACK_DISCOVERIES = i18n.translate(
'xpack.securitySolution.attackDiscovery.errorCancelingAttackDiscoveriesToastTitle',
{
defaultMessage: 'Error canceling attack discoveries',
}
);
export const CONNECTOR_ERROR = i18n.translate(
'xpack.securitySolution.attackDiscovery.errorConnector',
{
defaultMessage: 'No connector selected, select a connector to use attack discovery',
}
);
export const SHOW_REAL_VALUES = i18n.translate(
'xpack.securitySolution.attackDiscovery.showRealValuesLabel',
{

View file

@ -1,23 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface AttackDiscovery {
alertIds: string[];
detailsMarkdown: string;
entitySummaryMarkdown: string;
id: string;
mitreAttackTactics?: string[];
summaryMarkdown: string;
title: string;
}
/** Generation intervals measure the time it takes to generate attack discoveries */
export interface GenerationInterval {
connectorId: string;
date: Date;
durationMs: number;
}

View file

@ -56,14 +56,13 @@ const getAzureApiVersionParameter = (url: string): string | undefined => {
};
export const getRequestBody = ({
actionTypeId,
alertsIndexPattern,
anonymizationFields,
connectorId,
genAiConfig,
knowledgeBase,
selectedConnector,
traceOptions,
}: {
actionTypeId: string;
alertsIndexPattern: string | undefined;
anonymizationFields: {
page: number;
@ -82,14 +81,13 @@ export const getRequestBody = ({
namespace?: string | undefined;
}>;
};
connectorId: string | undefined;
genAiConfig?: GenAiConfig;
knowledgeBase: KnowledgeBaseConfig;
selectedConnector?: ActionConnector;
traceOptions: TraceOptions;
}): AttackDiscoveryPostRequestBody => ({
actionTypeId,
alertsIndexPattern: alertsIndexPattern ?? '',
anonymizationFields: anonymizationFields?.data ?? [],
connectorId: connectorId ?? '',
langSmithProject: isEmpty(traceOptions?.langSmithProject)
? undefined
: traceOptions?.langSmithProject,
@ -99,4 +97,10 @@ export const getRequestBody = ({
size: knowledgeBase.latestAlerts,
replacements: {}, // no need to re-use replacements in the current implementation
subAction: 'invokeAI', // non-streaming
apiConfig: {
connectorId: selectedConnector?.id ?? '',
actionTypeId: selectedConnector?.actionTypeId ?? '',
provider: genAiConfig?.apiProvider,
model: genAiConfig?.defaultModel,
},
});

View file

@ -0,0 +1,222 @@
/*
* 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-hooks';
import { useKibana } from '../../common/lib/kibana';
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields';
import { usePollApi } from '../hooks/use_poll_api';
import { useAttackDiscovery } from '.';
import { ERROR_GENERATING_ATTACK_DISCOVERIES } from '../pages/translations';
import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__';
jest.mock(
'@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields'
);
jest.mock('../hooks/use_poll_api');
jest.mock('../../common/lib/kibana');
const mockedUseKibana = mockUseKibana();
const mockAssistantAvailability = jest.fn(() => ({
hasAssistantPrivilege: true,
}));
const mockConnectors: unknown[] = [
{
id: 'test-id',
name: 'OpenAI connector',
actionTypeId: '.gen-ai',
},
];
jest.mock('@kbn/elastic-assistant', () => ({
AssistantOverlay: () => <div data-test-subj="assistantOverlay" />,
useAssistantContext: () => ({
alertsIndexPattern: 'alerts-index-pattern',
assistantAvailability: mockAssistantAvailability(),
knowledgeBase: {
isEnabledRAGAlerts: true,
isEnabledKnowledgeBase: true,
latestAlerts: 20,
},
}),
useLoadConnectors: () => ({
isFetched: true,
data: mockConnectors,
}),
}));
const mockAttackDiscoveryPost = {
timestamp: '2024-06-13T17:50:59.409Z',
id: 'f48da2ca-b63e-4387-82d7-1423a68500aa',
backingIndex: '.ds-.kibana-elastic-ai-assistant-attack-discovery-default-2024.06.12-000001',
createdAt: '2024-06-13T17:50:59.409Z',
updatedAt: '2024-06-17T15:00:39.680Z',
users: [
{
id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
name: 'elastic',
},
],
namespace: 'default',
status: 'running',
alertsContextCount: 20,
apiConfig: {
connectorId: 'my-gpt4o-ai',
actionTypeId: '.gen-ai',
},
attackDiscoveries: [],
replacements: { abcd: 'hostname' },
generationIntervals: [
{
date: '2024-06-13T17:52:47.619Z',
durationMs: 108214,
},
],
averageIntervalMs: 108214,
};
const mockAttackDiscoveries = [
{
summaryMarkdown:
'A critical malware incident involving {{ host.name c1f9889f-1f6b-4abc-8e65-02de89fe1054 }} and {{ user.name 71ca47cf-082e-4d35-a8e7-6e4fa4e175da }} has been detected. The malware, identified as AppPool.vbs, was executed with high privileges and attempted to evade detection.',
id: '2204421f-bb42-4b96-a200-016a5388a029',
title: 'Critical Malware Incident on Windows Host',
mitreAttackTactics: ['Initial Access', 'Execution', 'Defense Evasion'],
alertIds: [
'43cf228ce034aeeb89a1ef41cd7fcdef1a3db574fa5237badf1fa9eaa3425c21',
'44ae9696784b3baeee75935f889e55ce77da338241230b5c488f90a8bace43e2',
'2479b1b1007952d3b6dc26344c89f44c1bb396de56f1655eca408135b3d05af8',
],
detailsMarkdown: 'details',
entitySummaryMarkdown:
'{{ host.name c1f9889f-1f6b-4abc-8e65-02de89fe1054 }} and {{ user.name 71ca47cf-082e-4d35-a8e7-6e4fa4e175da }} are involved in a critical malware incident.',
timestamp: '2024-06-07T20:04:35.715Z',
},
];
const setLoadingConnectorId = jest.fn();
describe('useAttackDiscovery', () => {
const mockPollApi = {
cancelAttackDiscovery: jest.fn(),
data: null,
pollApi: jest.fn(),
status: 'succeeded',
};
beforeEach(() => {
jest.clearAllMocks();
(useKibana as jest.Mock).mockReturnValue(mockedUseKibana);
(useFetchAnonymizationFields as jest.Mock).mockReturnValue({ data: [] });
(usePollApi as jest.Mock).mockReturnValue(mockPollApi);
});
it('initializes with correct default values', () => {
const { result } = renderHook(() =>
useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId })
);
expect(result.current.alertsContextCount).toBeNull();
expect(result.current.approximateFutureTime).toBeNull();
expect(result.current.attackDiscoveries).toEqual([]);
expect(result.current.failureReason).toBeNull();
expect(result.current.generationIntervals).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.lastUpdated).toBeNull();
expect(result.current.replacements).toEqual({});
expect(mockPollApi.pollApi).toHaveBeenCalled();
expect(setLoadingConnectorId).toHaveBeenCalledWith(null);
});
it('fetches attack discoveries and updates state correctly', async () => {
(mockedUseKibana.services.http.fetch as jest.Mock).mockResolvedValue(mockAttackDiscoveryPost);
const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' }));
await act(async () => {
await result.current.fetchAttackDiscoveries();
});
expect(mockedUseKibana.services.http.fetch).toHaveBeenCalledWith(
'/internal/elastic_assistant/attack_discovery',
{
body: '{"alertsIndexPattern":"alerts-index-pattern","anonymizationFields":[],"size":20,"replacements":{},"subAction":"invokeAI","apiConfig":{"connectorId":"test-id","actionTypeId":".gen-ai"}}',
method: 'POST',
version: '1',
}
);
// called on mount, and after successful fetch
expect(mockPollApi.pollApi).toHaveBeenCalledTimes(2);
expect(result.current.isLoading).toBe(true);
});
it('handles fetch errors correctly', async () => {
const errorMessage = 'Fetch error';
const error = new Error(errorMessage);
(mockedUseKibana.services.http.fetch as jest.Mock).mockRejectedValue(error);
const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' }));
await act(async () => {
await result.current.fetchAttackDiscoveries();
});
expect(mockedUseKibana.services.notifications.toasts.addDanger).toHaveBeenCalledWith(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: errorMessage,
});
expect(result.current.isLoading).toBe(false);
});
it('sets loading state based on poll status', async () => {
(usePollApi as jest.Mock).mockReturnValue({ ...mockPollApi, status: 'running' });
const { result } = renderHook(() =>
useAttackDiscovery({ connectorId: 'test-id', setLoadingConnectorId })
);
expect(result.current.isLoading).toBe(true);
expect(setLoadingConnectorId).toHaveBeenCalledWith('test-id');
});
it('sets state based off of poll data', () => {
(usePollApi as jest.Mock).mockReturnValue({
...mockPollApi,
data: {
...mockAttackDiscoveryPost,
status: 'succeeded',
attackDiscoveries: mockAttackDiscoveries,
connectorId: 'test-id',
},
status: 'succeeded',
});
const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' }));
expect(result.current.alertsContextCount).toEqual(20);
// this is set from usePollApi
expect(result.current.approximateFutureTime).toBeNull();
expect(result.current.attackDiscoveries).toEqual(mockAttackDiscoveries);
expect(result.current.failureReason).toBeNull();
expect(result.current.generationIntervals).toEqual(mockAttackDiscoveryPost.generationIntervals);
expect(result.current.isLoading).toBe(false);
expect(result.current.lastUpdated).toEqual(new Date(mockAttackDiscoveries[0].timestamp));
expect(result.current.replacements).toEqual(mockAttackDiscoveryPost.replacements);
});
it('sets state based off of failed poll data', () => {
(usePollApi as jest.Mock).mockReturnValue({
...mockPollApi,
data: {
...mockAttackDiscoveryPost,
status: 'failed',
failureReason: 'something bad',
connectorId: 'test-id',
},
status: 'failed',
});
const { result } = renderHook(() => useAttackDiscovery({ connectorId: 'test-id' }));
expect(result.current.failureReason).toEqual('something bad');
expect(result.current.isLoading).toBe(false);
expect(result.current.lastUpdated).toEqual(null);
});
});

View file

@ -5,71 +5,47 @@
* 2.0.
*/
import {
ATTACK_DISCOVERY_STORAGE_KEY,
DEFAULT_ASSISTANT_NAMESPACE,
useAssistantContext,
useLoadConnectors,
} from '@kbn/elastic-assistant';
import type { Replacements } from '@kbn/elastic-assistant-common';
import { useAssistantContext, useLoadConnectors } from '@kbn/elastic-assistant';
import type {
AttackDiscoveries,
Replacements,
GenerationInterval,
} from '@kbn/elastic-assistant-common';
import {
AttackDiscoveryPostResponse,
ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
} from '@kbn/elastic-assistant-common';
import { uniq } from 'lodash/fp';
import moment from 'moment';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import * as uuid from 'uuid';
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields';
import { useSpaceId } from '../../common/hooks/use_space_id';
import { usePollApi } from '../hooks/use_poll_api';
import { useKibana } from '../../common/lib/kibana';
import { replaceNewlineLiterals } from '../helpers';
import { useAttackDiscoveryTelemetry } from '../hooks/use_attack_discovery_telemetry';
import {
CACHED_ATTACK_DISCOVERIES_SESSION_STORAGE_KEY,
GENERATION_INTERVALS_LOCAL_STORAGE_KEY,
getErrorToastText,
getFallbackActionTypeId,
} from '../pages/helpers';
import { getAverageIntervalSeconds } from '../pages/loading_callout/countdown/last_times_popover/helpers';
import type { CachedAttackDiscoveries } from '../pages/session_storage';
import {
getLocalStorageGenerationIntervals,
getSessionStorageCachedAttackDiscoveries,
setLocalStorageGenerationIntervals,
setSessionStorageCachedAttackDiscoveries,
} from '../pages/session_storage';
import { ERROR_GENERATING_ATTACK_DISCOVERIES } from '../pages/translations';
import type { AttackDiscovery, GenerationInterval } from '../types';
import { getErrorToastText } from '../pages/helpers';
import { CONNECTOR_ERROR, ERROR_GENERATING_ATTACK_DISCOVERIES } from '../pages/translations';
import { getGenAiConfig, getRequestBody } from './helpers';
const MAX_GENERATION_INTERVALS = 5;
export interface UseAttackDiscovery {
alertsContextCount: number | null;
approximateFutureTime: Date | null;
attackDiscoveries: AttackDiscovery[];
cachedAttackDiscoveries: Record<string, CachedAttackDiscoveries>;
attackDiscoveries: AttackDiscoveries;
didInitialFetch: boolean;
failureReason: string | null;
fetchAttackDiscoveries: () => Promise<void>;
generationIntervals: Record<string, GenerationInterval[]> | undefined;
generationIntervals: GenerationInterval[] | undefined;
isLoading: boolean;
isLoadingPost: boolean;
lastUpdated: Date | null;
onCancel: () => Promise<void>;
replacements: Replacements;
}
export const useAttackDiscovery = ({
connectorId,
setConnectorId,
setLoadingConnectorId,
}: {
connectorId: string | undefined;
setConnectorId?: (connectorId: string | undefined) => void;
setLoadingConnectorId?: (loadingConnectorId: string | null) => void;
}): UseAttackDiscovery => {
const { reportAttackDiscoveriesGenerated } = useAttackDiscoveryTelemetry();
const spaceId: string | undefined = useSpaceId();
// get Kibana services and connectors
const {
http,
@ -79,6 +55,18 @@ export const useAttackDiscovery = ({
http,
});
// generation can take a long time, so we calculate an approximate future time:
const [approximateFutureTime, setApproximateFutureTime] = useState<Date | null>(null);
// whether post request is loading (dont show actions)
const [isLoadingPost, setIsLoadingPost] = useState<boolean>(false);
const {
cancelAttackDiscovery,
data: pollData,
pollApi,
status: pollStatus,
didInitialFetch,
} = usePollApi({ http, setApproximateFutureTime, toasts, connectorId });
// loading boilerplate:
const [isLoading, setIsLoading] = useState(false);
@ -87,245 +75,124 @@ export const useAttackDiscovery = ({
const { data: anonymizationFields } = useFetchAnonymizationFields();
const sessionStorageKey = useMemo(
() =>
spaceId != null // spaceId is undefined while the useSpaceId hook is loading
? `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${CACHED_ATTACK_DISCOVERIES_SESSION_STORAGE_KEY}`
: '',
[spaceId]
);
const [cachedAttackDiscoveries, setCachedAttackDiscoveries] = useState<
Record<string, CachedAttackDiscoveries>
>({});
useEffect(() => {
const decoded = getSessionStorageCachedAttackDiscoveries(sessionStorageKey);
if (decoded != null) {
setCachedAttackDiscoveries(decoded);
const decodedAttackDiscoveries = decoded[connectorId ?? '']?.attackDiscoveries;
if (decodedAttackDiscoveries != null) {
setAttackDiscoveries(decodedAttackDiscoveries);
}
const decodedReplacements = decoded[connectorId ?? '']?.replacements;
if (decodedReplacements != null) {
setReplacements(decodedReplacements);
}
const decodedLastUpdated = decoded[connectorId ?? '']?.updated;
if (decodedLastUpdated != null) {
setLastUpdated(decodedLastUpdated);
}
}
}, [connectorId, sessionStorageKey]);
const localStorageKey = useMemo(
() =>
spaceId != null // spaceId is undefined while the useSpaceId hook is loading
? `${DEFAULT_ASSISTANT_NAMESPACE}.${ATTACK_DISCOVERY_STORAGE_KEY}.${spaceId}.${GENERATION_INTERVALS_LOCAL_STORAGE_KEY}`
: '',
[spaceId]
);
const [generationIntervals, setGenerationIntervals] = React.useState<
Record<string, GenerationInterval[]> | undefined
>(undefined);
useEffect(() => {
const decoded = getLocalStorageGenerationIntervals(localStorageKey);
if (decoded != null) {
setGenerationIntervals(decoded);
}
}, [localStorageKey]);
// get connector intervals from generation intervals:
const connectorIntervals = useMemo(
() => generationIntervals?.[connectorId ?? ''] ?? [],
[connectorId, generationIntervals]
);
// generation can take a long time, so we calculate an approximate future time:
const [approximateFutureTime, setApproximateFutureTime] = useState<Date | null>(null);
// get cached attack discoveries if they exist:
const [attackDiscoveries, setAttackDiscoveries] = useState<AttackDiscovery[]>(
cachedAttackDiscoveries[connectorId ?? '']?.attackDiscoveries ?? []
);
// get replacements from the cached attack discoveries if they exist:
const [replacements, setReplacements] = useState<Replacements>(
cachedAttackDiscoveries[connectorId ?? '']?.replacements ?? {}
);
// get last updated from the cached attack discoveries if it exists:
const [lastUpdated, setLastUpdated] = useState<Date | null>(
cachedAttackDiscoveries[connectorId ?? '']?.updated ?? null
);
const [generationIntervals, setGenerationIntervals] = React.useState<GenerationInterval[]>([]);
const [attackDiscoveries, setAttackDiscoveries] = useState<AttackDiscoveries>([]);
const [replacements, setReplacements] = useState<Replacements>({});
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [failureReason, setFailureReason] = useState<string | null>(null);
// number of alerts sent as context to the LLM:
const [alertsContextCount, setAlertsContextCount] = useState<number | null>(null);
/** The callback when users click the Generate button */
const fetchAttackDiscoveries = useCallback(async () => {
const requestBody = useMemo(() => {
const selectedConnector = aiConnectors?.find((connector) => connector.id === connectorId);
const actionTypeId = getFallbackActionTypeId(selectedConnector?.actionTypeId);
const body = getRequestBody({
actionTypeId,
const genAiConfig = getGenAiConfig(selectedConnector);
return getRequestBody({
alertsIndexPattern,
anonymizationFields,
connectorId,
genAiConfig,
knowledgeBase,
selectedConnector,
traceOptions,
});
try {
setLoadingConnectorId?.(connectorId ?? null);
setIsLoading(true);
setApproximateFutureTime(null);
const averageIntervalSeconds = getAverageIntervalSeconds(connectorIntervals);
setApproximateFutureTime(moment().add(averageIntervalSeconds, 'seconds').toDate());
const startTime = moment(); // start timing the generation
// call the internal API to generate attack discoveries:
const rawResponse = await http.fetch('/internal/elastic_assistant/attack_discovery', {
body: JSON.stringify(body),
method: 'POST',
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
});
const parsedResponse = AttackDiscoveryPostResponse.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new Error('Failed to parse the response');
}
const endTime = moment();
const durationMs = endTime.diff(startTime);
// update the cached attack discoveries with the new discoveries:
const newAttackDiscoveries: AttackDiscovery[] =
parsedResponse.data.attackDiscoveries?.map((attackDiscovery) => ({
alertIds: [...attackDiscovery.alertIds],
detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown),
entitySummaryMarkdown: replaceNewlineLiterals(attackDiscovery.entitySummaryMarkdown),
id: uuid.v4(),
mitreAttackTactics: attackDiscovery.mitreAttackTactics,
summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown),
title: attackDiscovery.title,
})) ?? [];
const responseReplacements = parsedResponse.data.replacements ?? {};
const newReplacements = { ...replacements, ...responseReplacements };
const newLastUpdated = new Date();
const newCachedAttackDiscoveries = {
...cachedAttackDiscoveries,
[connectorId ?? '']: {
connectorId: connectorId ?? '',
attackDiscoveries: newAttackDiscoveries,
replacements: newReplacements,
updated: newLastUpdated,
},
};
setCachedAttackDiscoveries(newCachedAttackDiscoveries);
setSessionStorageCachedAttackDiscoveries({
key: sessionStorageKey,
cachedAttackDiscoveries: newCachedAttackDiscoveries,
});
// update the generation intervals with the latest timing:
const previousConnectorIntervals: GenerationInterval[] =
generationIntervals != null ? generationIntervals[connectorId ?? ''] ?? [] : [];
const newInterval: GenerationInterval = {
connectorId: connectorId ?? '',
date: new Date(),
durationMs,
};
const newConnectorIntervals = [newInterval, ...previousConnectorIntervals].slice(
0,
MAX_GENERATION_INTERVALS
);
const newGenerationIntervals: Record<string, GenerationInterval[]> = {
...generationIntervals,
[connectorId ?? '']: newConnectorIntervals,
};
const newAlertsContextCount = parsedResponse.data.alertsContextCount ?? null;
setAlertsContextCount(newAlertsContextCount);
// only update the generation intervals if alerts were sent as context to the LLM:
if (newAlertsContextCount != null && newAlertsContextCount > 0) {
setGenerationIntervals(newGenerationIntervals);
setLocalStorageGenerationIntervals({
key: localStorageKey,
generationIntervals: newGenerationIntervals,
});
}
setReplacements(newReplacements);
setAttackDiscoveries(newAttackDiscoveries);
setLastUpdated(newLastUpdated);
setConnectorId?.(connectorId);
const connectorConfig = getGenAiConfig(selectedConnector);
reportAttackDiscoveriesGenerated({
actionTypeId,
durationMs,
alertsContextCount: newAlertsContextCount ?? 0,
alertsCount: uniq(
newAttackDiscoveries.flatMap((attackDiscovery) => attackDiscovery.alertIds)
).length,
configuredAlertsCount: knowledgeBase.latestAlerts,
provider: connectorConfig?.apiProvider,
model: connectorConfig?.defaultModel,
});
} catch (error) {
toasts?.addDanger(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
} finally {
setApproximateFutureTime(null);
setLoadingConnectorId?.(null);
setIsLoading(false);
}
}, [
aiConnectors,
alertsIndexPattern,
anonymizationFields,
cachedAttackDiscoveries,
connectorId,
connectorIntervals,
generationIntervals,
http,
knowledgeBase,
localStorageKey,
replacements,
reportAttackDiscoveriesGenerated,
sessionStorageKey,
setConnectorId,
setLoadingConnectorId,
toasts,
traceOptions,
]);
useEffect(() => {
if (connectorId != null && connectorId !== '') {
pollApi();
setLoadingConnectorId?.(connectorId);
setAlertsContextCount(null);
setFailureReason(null);
setLastUpdated(null);
setReplacements({});
setAttackDiscoveries([]);
setGenerationIntervals([]);
}
}, [pollApi, connectorId, setLoadingConnectorId]);
useEffect(() => {
if (pollStatus === 'running') {
setIsLoading(true);
setLoadingConnectorId?.(connectorId ?? null);
} else {
setIsLoading(false);
setLoadingConnectorId?.(null);
}
}, [pollStatus, connectorId, setLoadingConnectorId]);
useEffect(() => {
if (pollData !== null && pollData.connectorId === connectorId) {
if (pollData.alertsContextCount != null) setAlertsContextCount(pollData.alertsContextCount);
if (pollData.attackDiscoveries.length) {
// get last updated from timestamp, not from updatedAt since this can indicate the last time the status was updated
setLastUpdated(new Date(pollData.attackDiscoveries[0].timestamp));
}
if (pollData.replacements) setReplacements(pollData.replacements);
if (pollData.status === 'failed' && pollData.failureReason) {
setFailureReason(pollData.failureReason);
} else {
setFailureReason(null);
}
setAttackDiscoveries(pollData.attackDiscoveries);
setGenerationIntervals(pollData.generationIntervals);
}
}, [connectorId, pollData]);
/** The callback when users click the Generate button */
const fetchAttackDiscoveries = useCallback(async () => {
try {
if (requestBody.apiConfig.connectorId === '' || requestBody.apiConfig.actionTypeId === '') {
throw new Error(CONNECTOR_ERROR);
}
setLoadingConnectorId?.(connectorId ?? null);
setIsLoading(true);
setIsLoadingPost(true);
setApproximateFutureTime(null);
// call the internal API to generate attack discoveries:
const rawResponse = await http.fetch('/internal/elastic_assistant/attack_discovery', {
body: JSON.stringify(requestBody),
method: 'POST',
version: ELASTIC_AI_ASSISTANT_INTERNAL_API_VERSION,
});
setIsLoadingPost(false);
const parsedResponse = AttackDiscoveryPostResponse.safeParse(rawResponse);
if (!parsedResponse.success) {
throw new Error('Failed to parse the response');
}
if (parsedResponse.data.status === 'running') {
pollApi();
}
} catch (error) {
setIsLoadingPost(false);
setIsLoading(false);
toasts?.addDanger(error, {
title: ERROR_GENERATING_ATTACK_DISCOVERIES,
text: getErrorToastText(error),
});
}
}, [connectorId, http, pollApi, requestBody, setLoadingConnectorId, toasts]);
return {
alertsContextCount,
approximateFutureTime,
attackDiscoveries,
cachedAttackDiscoveries,
didInitialFetch,
failureReason,
fetchAttackDiscoveries,
generationIntervals,
isLoading,
isLoadingPost,
lastUpdated,
onCancel: cancelAttackDiscovery,
replacements,
};
};

View file

@ -60,7 +60,6 @@ export enum TelemetryEventTypes {
AssetCriticalityCsvPreviewGenerated = 'Asset Criticality Csv Preview Generated',
AssetCriticalityFileSelected = 'Asset Criticality File Selected',
AssetCriticalityCsvImported = 'Asset Criticality CSV Imported',
AttackDiscoveriesGenerated = 'Attack Discoveries Generated',
EntityDetailsClicked = 'Entity Details Clicked',
EntityAlertsClicked = 'Entity Alerts Clicked',
EntityRiskFiltered = 'Entity Risk Filtered',

View file

@ -1,64 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TelemetryEvent } from '../../types';
import { TelemetryEventTypes } from '../../constants';
export const insightsGeneratedEvent: TelemetryEvent = {
eventType: TelemetryEventTypes.AttackDiscoveriesGenerated,
schema: {
actionTypeId: {
type: 'keyword',
_meta: {
description: 'Kibana connector type',
optional: false,
},
},
durationMs: {
type: 'integer',
_meta: {
description: 'Duration of request in ms',
optional: false,
},
},
alertsContextCount: {
type: 'integer',
_meta: {
description: 'Number of alerts sent as context to the LLM',
optional: false,
},
},
alertsCount: {
type: 'integer',
_meta: {
description: 'Number of unique alerts referenced in the attack discoveries',
optional: false,
},
},
configuredAlertsCount: {
type: 'integer',
_meta: {
description: 'Number of alerts configured by the user',
optional: false,
},
},
model: {
type: 'keyword',
_meta: {
description: 'LLM model',
optional: true,
},
},
provider: {
type: 'keyword',
_meta: {
description: 'OpenAI provider',
optional: true,
},
},
},
};

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RootSchema } from '@kbn/core/public';
import type { TelemetryEventTypes } from '../../constants';
export interface ReportAttackDiscoveriesGeneratedParams {
actionTypeId: string;
provider?: string;
model?: string;
durationMs: number;
alertsContextCount: number;
alertsCount: number;
configuredAlertsCount: number;
}
export type ReportAttackDiscoveryTelemetryEventParams = ReportAttackDiscoveriesGeneratedParams;
export interface AttackDiscoveryTelemetryEvent {
eventType: TelemetryEventTypes.AttackDiscoveriesGenerated;
schema: RootSchema<ReportAttackDiscoveriesGeneratedParams>;
}

View file

@ -28,7 +28,6 @@ import {
assistantMessageSentEvent,
assistantQuickPrompt,
} from './ai_assistant';
import { insightsGeneratedEvent } from './attack_discovery';
import { dataQualityIndexCheckedEvent, dataQualityCheckAllClickedEvent } from './data_quality';
import {
DocumentDetailsFlyoutOpenedEvent,
@ -156,7 +155,6 @@ export const telemetryEvents = [
assistantMessageSentEvent,
assistantQuickPrompt,
assistantSettingToggledEvent,
insightsGeneratedEvent,
entityClickedEvent,
entityAlertsClickedEvent,
entityRiskFilteredEvent,

View file

@ -35,5 +35,4 @@ export const createTelemetryClientMock = (): jest.Mocked<TelemetryClientStart> =
reportAssetCriticalityCsvPreviewGenerated: jest.fn(),
reportAssetCriticalityFileSelected: jest.fn(),
reportAssetCriticalityCsvImported: jest.fn(),
reportAttackDiscoveriesGenerated: jest.fn(),
});

View file

@ -24,7 +24,6 @@ import type {
ReportAssistantMessageSentParams,
ReportAssistantQuickPromptParams,
ReportAssistantSettingToggledParams,
ReportAttackDiscoveriesGeneratedParams,
ReportRiskInputsExpandedFlyoutOpenedParams,
ReportToggleRiskSummaryClickedParams,
ReportDetailsFlyoutOpenedParams,
@ -74,10 +73,6 @@ export class TelemetryClient implements TelemetryClientStart {
this.analytics.reportEvent(TelemetryEventTypes.AssistantSettingToggled, params);
};
public reportAttackDiscoveriesGenerated = (params: ReportAttackDiscoveriesGeneratedParams) => {
this.analytics.reportEvent(TelemetryEventTypes.AttackDiscoveriesGenerated, params);
};
public reportEntityDetailsClicked = ({ entity }: ReportEntityDetailsClickedParams) => {
this.analytics.reportEvent(TelemetryEventTypes.EntityDetailsClicked, {
entity,

View file

@ -6,11 +6,6 @@
*/
import type { AnalyticsServiceSetup, RootSchema } from '@kbn/core/public';
import type {
AttackDiscoveryTelemetryEvent,
ReportAttackDiscoveriesGeneratedParams,
ReportAttackDiscoveryTelemetryEventParams,
} from './events/attack_discovery/types';
import type { SecurityCellActionMetadata } from '../../../app/actions/types';
import type { ML_JOB_TELEMETRY_STATUS, TelemetryEventTypes } from './constants';
import type {
@ -61,7 +56,6 @@ import type {
export * from './events/ai_assistant/types';
export * from './events/alerts_grouping/types';
export * from './events/attack_discovery/types';
export * from './events/data_quality/types';
export * from './events/onboarding/types';
export type {
@ -108,7 +102,6 @@ export interface ReportBreadcrumbClickedParams {
export type TelemetryEventParams =
| ReportAlertsGroupingTelemetryEventParams
| ReportAssistantTelemetryEventParams
| ReportAttackDiscoveryTelemetryEventParams
| ReportEntityAnalyticsTelemetryEventParams
| ReportMLJobUpdateParams
| ReportCellActionClickedParams
@ -132,9 +125,6 @@ export interface TelemetryClientStart {
reportAssistantQuickPrompt(params: ReportAssistantQuickPromptParams): void;
reportAssistantSettingToggled(params: ReportAssistantSettingToggledParams): void;
// Attack discovery
reportAttackDiscoveriesGenerated(params: ReportAttackDiscoveriesGeneratedParams): void;
// Entity Analytics
reportEntityDetailsClicked(params: ReportEntityDetailsClickedParams): void;
reportEntityAlertsClicked(params: ReportEntityAlertsClickedParams): void;
@ -173,7 +163,6 @@ export type TelemetryEvent =
| EntityAnalyticsTelemetryEvent
| DataQualityTelemetryEvents
| DocumentDetailsTelemetryEvents
| AttackDiscoveryTelemetryEvent
| {
eventType: TelemetryEventTypes.MLJobUpdate;
schema: RootSchema<ReportMLJobUpdateParams>;