mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security solution] Attack discovery background task and persistence (#184949)
This commit is contained in:
parent
8e76b0b113
commit
48c0e0dd7c
94 changed files with 4826 additions and 1472 deletions
|
@ -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;
|
|
@ -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
|
|
@ -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(),
|
||||
});
|
|
@ -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'
|
||||
|
|
@ -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(),
|
||||
});
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(),
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 () => {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -46,6 +46,7 @@
|
|||
"@kbn/core-security-common",
|
||||
"@kbn/core-saved-objects-api-server",
|
||||
"@kbn/langchain",
|
||||
"@kbn/stack-connectors-plugin",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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 })) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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',
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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: {},
|
||||
}),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -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>;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -35,5 +35,4 @@ export const createTelemetryClientMock = (): jest.Mocked<TelemetryClientStart> =
|
|||
reportAssetCriticalityCsvPreviewGenerated: jest.fn(),
|
||||
reportAssetCriticalityFileSelected: jest.fn(),
|
||||
reportAssetCriticalityCsvImported: jest.fn(),
|
||||
reportAttackDiscoveriesGenerated: jest.fn(),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue