mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[8.x] [Attack Discovery][Scheduling] Decouple AD generation logic from the postAttackDiscoveryRoute
(#12036) (#216274) (#216625)
# Backport This will backport the following commits from `main` to `8.x`: - [[Attack Discovery][Scheduling] Decouple AD generation logic from the `postAttackDiscoveryRoute` (#12036) (#216274)](https://github.com/elastic/kibana/pull/216274) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Ievgen Sorokopud","email":"ievgen.sorokopud@elastic.co"},"sourceCommit":{"committedDate":"2025-04-01T11:00:41Z","message":"[Attack Discovery][Scheduling] Decouple AD generation logic from the `postAttackDiscoveryRoute` (#12036) (#216274)\n\n## Summary\n\nThese changes decouple the core attack discovery generation\nfunctionality from the `POST\n/internal/elastic_assistant/attack_discovery` route.\n\nThis will allow us to use this functionality within the upcoming attack\ndiscovery schedule execution handler ([internal\nlink](https://github.com/elastic/security-team/issues/12004)).\n\nThere are no changes in the business logic of the attack discovery\ngeneration process and everything should continue working as before.","sha":"437065d9c81181f740033ad5db7bfab4ce6e679f","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team: SecuritySolution","Team:Security Generative AI","backport:version","v9.1.0","v8.19.0"],"title":"[Attack Discovery][Scheduling] Decouple AD generation logic from the `postAttackDiscoveryRoute` (#12036)","number":216274,"url":"https://github.com/elastic/kibana/pull/216274","mergeCommit":{"message":"[Attack Discovery][Scheduling] Decouple AD generation logic from the `postAttackDiscoveryRoute` (#12036) (#216274)\n\n## Summary\n\nThese changes decouple the core attack discovery generation\nfunctionality from the `POST\n/internal/elastic_assistant/attack_discovery` route.\n\nThis will allow us to use this functionality within the upcoming attack\ndiscovery schedule execution handler ([internal\nlink](https://github.com/elastic/security-team/issues/12004)).\n\nThere are no changes in the business logic of the attack discovery\ngeneration process and everything should continue working as before.","sha":"437065d9c81181f740033ad5db7bfab4ce6e679f"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/216274","number":216274,"mergeCommit":{"message":"[Attack Discovery][Scheduling] Decouple AD generation logic from the `postAttackDiscoveryRoute` (#12036) (#216274)\n\n## Summary\n\nThese changes decouple the core attack discovery generation\nfunctionality from the `POST\n/internal/elastic_assistant/attack_discovery` route.\n\nThis will allow us to use this functionality within the upcoming attack\ndiscovery schedule execution handler ([internal\nlink](https://github.com/elastic/security-team/issues/12004)).\n\nThere are no changes in the business logic of the attack discovery\ngeneration process and everything should continue working as before.","sha":"437065d9c81181f740033ad5db7bfab4ce6e679f"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Ievgen Sorokopud <ievgen.sorokopud@elastic.co>
This commit is contained in:
parent
3a1f8398fb
commit
009adee75e
10 changed files with 573 additions and 144 deletions
|
@ -18,6 +18,7 @@ import { z } from '@kbn/zod';
|
|||
|
||||
import { NonEmptyString, User } from '../common_attributes.gen';
|
||||
import { Replacements, ApiConfig } from '../conversations/common_attributes.gen';
|
||||
import { AnonymizationFieldResponse } from '../anonymization_fields/bulk_crud_anonymization_fields_route.gen';
|
||||
|
||||
/**
|
||||
* An attack discovery generated from one or more alerts
|
||||
|
@ -247,3 +248,22 @@ export const AttackDiscoveryCreateProps = z.object({
|
|||
apiConfig: ApiConfig,
|
||||
replacements: Replacements.optional(),
|
||||
});
|
||||
|
||||
export type AttackDiscoveryGenerationConfig = z.infer<typeof AttackDiscoveryGenerationConfig>;
|
||||
export const AttackDiscoveryGenerationConfig = z.object({
|
||||
alertsIndexPattern: z.string(),
|
||||
anonymizationFields: z.array(AnonymizationFieldResponse),
|
||||
/**
|
||||
* LLM API configuration.
|
||||
*/
|
||||
apiConfig: ApiConfig,
|
||||
end: z.string().optional(),
|
||||
filter: z.object({}).catchall(z.unknown()).optional(),
|
||||
langSmithProject: z.string().optional(),
|
||||
langSmithApiKey: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
replacements: Replacements.optional(),
|
||||
size: z.number(),
|
||||
start: z.string().optional(),
|
||||
subAction: z.enum(['invokeAI', 'invokeStream']),
|
||||
});
|
||||
|
|
|
@ -114,7 +114,6 @@ components:
|
|||
items:
|
||||
$ref: '#/components/schemas/AttackDiscoveryStat'
|
||||
|
||||
|
||||
AttackDiscoveryResponse:
|
||||
type: object
|
||||
required:
|
||||
|
@ -142,8 +141,8 @@ components:
|
|||
description: The last time attack discovery was viewed in the browser.
|
||||
type: string
|
||||
alertsContextCount:
|
||||
type: integer
|
||||
description: The number of alerts in the context.
|
||||
type: integer
|
||||
description: The number of alerts in the context.
|
||||
createdAt:
|
||||
description: The time attack discovery was created.
|
||||
type: string
|
||||
|
@ -242,3 +241,43 @@ components:
|
|||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
||||
|
||||
AttackDiscoveryGenerationConfig:
|
||||
type: object
|
||||
required:
|
||||
- apiConfig
|
||||
- alertsIndexPattern
|
||||
- anonymizationFields
|
||||
- size
|
||||
- subAction
|
||||
properties:
|
||||
alertsIndexPattern:
|
||||
type: string
|
||||
anonymizationFields:
|
||||
items:
|
||||
$ref: '../anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse'
|
||||
type: array
|
||||
apiConfig:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
|
||||
description: LLM API configuration.
|
||||
end:
|
||||
type: string
|
||||
filter:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
langSmithProject:
|
||||
type: string
|
||||
langSmithApiKey:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
||||
size:
|
||||
type: number
|
||||
start:
|
||||
type: string
|
||||
subAction:
|
||||
type: string
|
||||
enum:
|
||||
- invokeAI
|
||||
- invokeStream
|
||||
|
|
|
@ -16,28 +16,10 @@
|
|||
|
||||
import { z } from '@kbn/zod';
|
||||
|
||||
import { AnonymizationFieldResponse } from '../anonymization_fields/bulk_crud_anonymization_fields_route.gen';
|
||||
import { ApiConfig, Replacements } from '../conversations/common_attributes.gen';
|
||||
import { AttackDiscoveryResponse } from './common_attributes.gen';
|
||||
import { AttackDiscoveryGenerationConfig, AttackDiscoveryResponse } from './common_attributes.gen';
|
||||
|
||||
export type AttackDiscoveryPostRequestBody = z.infer<typeof AttackDiscoveryPostRequestBody>;
|
||||
export const AttackDiscoveryPostRequestBody = z.object({
|
||||
alertsIndexPattern: z.string(),
|
||||
anonymizationFields: z.array(AnonymizationFieldResponse),
|
||||
/**
|
||||
* LLM API configuration.
|
||||
*/
|
||||
apiConfig: ApiConfig,
|
||||
end: z.string().optional(),
|
||||
filter: z.object({}).catchall(z.unknown()).optional(),
|
||||
langSmithProject: z.string().optional(),
|
||||
langSmithApiKey: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
replacements: Replacements.optional(),
|
||||
size: z.number(),
|
||||
start: z.string().optional(),
|
||||
subAction: z.enum(['invokeAI', 'invokeStream']),
|
||||
});
|
||||
export const AttackDiscoveryPostRequestBody = AttackDiscoveryGenerationConfig;
|
||||
export type AttackDiscoveryPostRequestBodyInput = z.input<typeof AttackDiscoveryPostRequestBody>;
|
||||
|
||||
export type AttackDiscoveryPostResponse = z.infer<typeof AttackDiscoveryPostResponse>;
|
||||
|
|
|
@ -21,45 +21,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- apiConfig
|
||||
- alertsIndexPattern
|
||||
- anonymizationFields
|
||||
- size
|
||||
- subAction
|
||||
properties:
|
||||
alertsIndexPattern:
|
||||
type: string
|
||||
anonymizationFields:
|
||||
items:
|
||||
$ref: '../anonymization_fields/bulk_crud_anonymization_fields_route.schema.yaml#/components/schemas/AnonymizationFieldResponse'
|
||||
type: array
|
||||
apiConfig:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
|
||||
description: LLM API configuration.
|
||||
end:
|
||||
type: string
|
||||
filter:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
langSmithProject:
|
||||
type: string
|
||||
langSmithApiKey:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
||||
size:
|
||||
type: number
|
||||
start:
|
||||
type: string
|
||||
subAction:
|
||||
type: string
|
||||
enum:
|
||||
- invokeAI
|
||||
- invokeStream
|
||||
$ref: './common_attributes.schema.yaml#/components/schemas/AttackDiscoveryGenerationConfig'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful response
|
||||
|
@ -80,4 +42,3 @@ paths:
|
|||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
|
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* 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 { coreMock, elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { actionsClientMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { AttackDiscoveryGenerationConfig } from '@kbn/elastic-assistant-common';
|
||||
import { OpenAiProviderType } from '@kbn/stack-connectors-plugin/common/openai/constants';
|
||||
|
||||
import { generateAttackDiscoveries } from './generate_discoveries';
|
||||
import { updateAttackDiscoveries } from './helpers';
|
||||
import { handleGraphError } from '../post/helpers/handle_graph_error';
|
||||
import { invokeAttackDiscoveryGraph } from '../post/helpers/invoke_attack_discovery_graph';
|
||||
import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence';
|
||||
import { mockAnonymizedAlerts } from '../../../lib/attack_discovery/evaluation/__mocks__/mock_anonymized_alerts';
|
||||
import { mockAttackDiscoveries } from '../../../lib/attack_discovery/evaluation/__mocks__/mock_attack_discoveries';
|
||||
|
||||
jest.mock('./helpers', () => ({
|
||||
...jest.requireActual('./helpers'),
|
||||
updateAttackDiscoveries: jest.fn(),
|
||||
}));
|
||||
jest.mock('../post/helpers/handle_graph_error', () => ({
|
||||
...jest.requireActual('../post/helpers/handle_graph_error'),
|
||||
handleGraphError: jest.fn(),
|
||||
}));
|
||||
jest.mock('../post/helpers/invoke_attack_discovery_graph', () => ({
|
||||
...jest.requireActual('../post/helpers/invoke_attack_discovery_graph'),
|
||||
invokeAttackDiscoveryGraph: jest.fn(),
|
||||
}));
|
||||
|
||||
const findAttackDiscoveryByConnectorId = jest.fn();
|
||||
const updateAttackDiscovery = jest.fn();
|
||||
const createAttackDiscovery = jest.fn();
|
||||
const getAttackDiscovery = jest.fn();
|
||||
const findAllAttackDiscoveries = jest.fn();
|
||||
const mockDataClient = {
|
||||
findAttackDiscoveryByConnectorId,
|
||||
updateAttackDiscovery,
|
||||
createAttackDiscovery,
|
||||
getAttackDiscovery,
|
||||
findAllAttackDiscoveries,
|
||||
} as unknown as AttackDiscoveryDataClient;
|
||||
|
||||
const mockActionsClient = actionsClientMock.create();
|
||||
const mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
|
||||
const mockLogger = loggerMock.create();
|
||||
const mockSavedObjectsClient = savedObjectsClientMock.create();
|
||||
const mockTelemetry = coreMock.createSetup().analytics;
|
||||
|
||||
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 mockConfig: AttackDiscoveryGenerationConfig = {
|
||||
subAction: 'invokeAI',
|
||||
apiConfig: mockApiConfig,
|
||||
alertsIndexPattern: 'alerts-*',
|
||||
anonymizationFields: [],
|
||||
replacements: {},
|
||||
model: 'gpt-4',
|
||||
size: 20,
|
||||
langSmithProject: 'langSmithProject',
|
||||
langSmithApiKey: 'langSmithApiKey',
|
||||
};
|
||||
|
||||
describe('generateAttackDiscoveries', () => {
|
||||
const testInvokeError = new Error('Failed to invoke AD graph.');
|
||||
const testUpdateError = new Error('Failed to update attack discoveries.');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(invokeAttackDiscoveryGraph as jest.Mock).mockResolvedValue({
|
||||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passed valid arguments', () => {
|
||||
it('should call `invokeAttackDiscoveryGraph`', async () => {
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(invokeAttackDiscoveryGraph).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionsClient: mockActionsClient,
|
||||
alertsIndexPattern: mockConfig.alertsIndexPattern,
|
||||
anonymizationFields: mockConfig.anonymizationFields,
|
||||
apiConfig: mockConfig.apiConfig,
|
||||
connectorTimeout: 580000,
|
||||
end: mockConfig.end,
|
||||
esClient: mockEsClient,
|
||||
filter: mockConfig.filter,
|
||||
langSmithProject: mockConfig.langSmithProject,
|
||||
langSmithApiKey: mockConfig.langSmithApiKey,
|
||||
latestReplacements: mockConfig.replacements,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
size: mockConfig.size,
|
||||
start: mockConfig.start,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call `updateAttackDiscoveries`', async () => {
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(updateAttackDiscoveries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
apiConfig: mockConfig.apiConfig,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
executionUuid,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
hasFilter: false,
|
||||
end: mockConfig.end,
|
||||
latestReplacements: mockConfig.replacements,
|
||||
logger: mockLogger,
|
||||
size: mockConfig.size,
|
||||
start: mockConfig.start,
|
||||
telemetry: mockTelemetry,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call `handleGraphError`', async () => {
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(handleGraphError).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should return valid results', async () => {
|
||||
const executionUuid = 'test-1';
|
||||
const results = await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(results).toEqual({
|
||||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
replacements: mockConfig.replacements,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `invokeAttackDiscoveryGraph` throws an error', () => {
|
||||
it('should call `handleGraphError`', async () => {
|
||||
(invokeAttackDiscoveryGraph as jest.Mock).mockRejectedValue(testInvokeError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(handleGraphError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiConfig: mockConfig.apiConfig,
|
||||
executionUuid,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
err: testInvokeError,
|
||||
latestReplacements: mockConfig.replacements,
|
||||
logger: mockLogger,
|
||||
telemetry: mockTelemetry,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call `updateAttackDiscoveries`', async () => {
|
||||
(invokeAttackDiscoveryGraph as jest.Mock).mockRejectedValue(testInvokeError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(updateAttackDiscoveries).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should return an error', async () => {
|
||||
(invokeAttackDiscoveryGraph as jest.Mock).mockRejectedValue(testInvokeError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
const results = await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(results).toEqual({ error: testInvokeError });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when `updateAttackDiscoveries` throws an error', () => {
|
||||
it('should call `invokeAttackDiscoveryGraph`', async () => {
|
||||
(updateAttackDiscoveries as jest.Mock).mockRejectedValue(testUpdateError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(invokeAttackDiscoveryGraph).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionsClient: mockActionsClient,
|
||||
alertsIndexPattern: mockConfig.alertsIndexPattern,
|
||||
anonymizationFields: mockConfig.anonymizationFields,
|
||||
apiConfig: mockConfig.apiConfig,
|
||||
connectorTimeout: 580000,
|
||||
end: mockConfig.end,
|
||||
esClient: mockEsClient,
|
||||
filter: mockConfig.filter,
|
||||
langSmithProject: mockConfig.langSmithProject,
|
||||
langSmithApiKey: mockConfig.langSmithApiKey,
|
||||
latestReplacements: mockConfig.replacements,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
size: mockConfig.size,
|
||||
start: mockConfig.start,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call `handleGraphError`', async () => {
|
||||
(updateAttackDiscoveries as jest.Mock).mockRejectedValue(testUpdateError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(handleGraphError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiConfig: mockConfig.apiConfig,
|
||||
executionUuid,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
err: testUpdateError,
|
||||
latestReplacements: mockConfig.replacements,
|
||||
logger: mockLogger,
|
||||
telemetry: mockTelemetry,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an error', async () => {
|
||||
(updateAttackDiscoveries as jest.Mock).mockRejectedValue(testUpdateError);
|
||||
|
||||
const executionUuid = 'test-1';
|
||||
const results = await generateAttackDiscoveries({
|
||||
actionsClient: mockActionsClient,
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
config: mockConfig,
|
||||
dataClient: mockDataClient,
|
||||
esClient: mockEsClient,
|
||||
executionUuid,
|
||||
logger: mockLogger,
|
||||
savedObjectsClient: mockSavedObjectsClient,
|
||||
telemetry: mockTelemetry,
|
||||
});
|
||||
|
||||
expect(results).toEqual({ error: testUpdateError });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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 moment from 'moment';
|
||||
|
||||
import {
|
||||
AnalyticsServiceSetup,
|
||||
AuthenticatedUser,
|
||||
Logger,
|
||||
SavedObjectsClientContract,
|
||||
} from '@kbn/core/server';
|
||||
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import { AttackDiscoveryGenerationConfig, Replacements } from '@kbn/elastic-assistant-common';
|
||||
import { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { ActionsClient } from '@kbn/actions-plugin/server';
|
||||
|
||||
import { updateAttackDiscoveries } from './helpers';
|
||||
import { handleGraphError } from '../post/helpers/handle_graph_error';
|
||||
import { invokeAttackDiscoveryGraph } from '../post/helpers/invoke_attack_discovery_graph';
|
||||
import { AttackDiscoveryDataClient } from '../../../lib/attack_discovery/persistence';
|
||||
|
||||
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
|
||||
const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds
|
||||
|
||||
export interface GenerateAttackDiscoveriesParams {
|
||||
actionsClient: PublicMethodsOf<ActionsClient>;
|
||||
authenticatedUser: AuthenticatedUser;
|
||||
config: AttackDiscoveryGenerationConfig;
|
||||
dataClient: AttackDiscoveryDataClient;
|
||||
esClient: ElasticsearchClient;
|
||||
executionUuid: string;
|
||||
logger: Logger;
|
||||
savedObjectsClient: SavedObjectsClientContract;
|
||||
telemetry: AnalyticsServiceSetup;
|
||||
}
|
||||
|
||||
export const generateAttackDiscoveries = async ({
|
||||
actionsClient,
|
||||
authenticatedUser,
|
||||
config,
|
||||
dataClient,
|
||||
esClient,
|
||||
executionUuid,
|
||||
logger,
|
||||
savedObjectsClient,
|
||||
telemetry,
|
||||
}: GenerateAttackDiscoveriesParams) => {
|
||||
const startTime = moment(); // start timing the generation
|
||||
|
||||
// get parameters from the request body
|
||||
const alertsIndexPattern = decodeURIComponent(config.alertsIndexPattern);
|
||||
const {
|
||||
apiConfig,
|
||||
anonymizationFields,
|
||||
end,
|
||||
filter,
|
||||
langSmithApiKey,
|
||||
langSmithProject,
|
||||
replacements,
|
||||
size,
|
||||
start,
|
||||
} = config;
|
||||
|
||||
// callback to accumulate the latest replacements:
|
||||
let latestReplacements: Replacements = { ...replacements };
|
||||
const onNewReplacements = (newReplacements: Replacements) => {
|
||||
latestReplacements = { ...latestReplacements, ...newReplacements };
|
||||
};
|
||||
|
||||
try {
|
||||
const { anonymizedAlerts, attackDiscoveries } = await invokeAttackDiscoveryGraph({
|
||||
actionsClient,
|
||||
alertsIndexPattern,
|
||||
anonymizationFields,
|
||||
apiConfig,
|
||||
connectorTimeout: CONNECTOR_TIMEOUT,
|
||||
end,
|
||||
esClient,
|
||||
filter,
|
||||
langSmithProject,
|
||||
langSmithApiKey,
|
||||
latestReplacements,
|
||||
logger,
|
||||
onNewReplacements,
|
||||
savedObjectsClient,
|
||||
size,
|
||||
start,
|
||||
});
|
||||
|
||||
await updateAttackDiscoveries({
|
||||
anonymizedAlerts,
|
||||
apiConfig,
|
||||
attackDiscoveries,
|
||||
executionUuid,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
hasFilter: !!(filter && Object.keys(filter).length),
|
||||
end,
|
||||
latestReplacements,
|
||||
logger,
|
||||
size,
|
||||
start,
|
||||
startTime,
|
||||
telemetry,
|
||||
});
|
||||
|
||||
return { anonymizedAlerts, attackDiscoveries, replacements: latestReplacements };
|
||||
} catch (err) {
|
||||
await handleGraphError({
|
||||
apiConfig,
|
||||
executionUuid,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
err,
|
||||
latestReplacements,
|
||||
logger,
|
||||
telemetry,
|
||||
});
|
||||
return { error: err };
|
||||
}
|
||||
};
|
|
@ -298,7 +298,7 @@ describe('helpers', () => {
|
|||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
apiConfig: mockApiConfig,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
attackDiscoveryId: 'attack-discovery-id',
|
||||
executionUuid: 'attack-discovery-id',
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
hasFilter: false,
|
||||
|
@ -338,7 +338,7 @@ describe('helpers', () => {
|
|||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
apiConfig: mockApiConfig,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
attackDiscoveryId: 'attack-discovery-id',
|
||||
executionUuid: 'attack-discovery-id',
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
hasFilter: false,
|
||||
|
@ -378,7 +378,7 @@ describe('helpers', () => {
|
|||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
apiConfig: mockApiConfig,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
attackDiscoveryId: 'attack-discovery-id',
|
||||
executionUuid: 'attack-discovery-id',
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
hasFilter: true,
|
||||
|
@ -419,7 +419,7 @@ describe('helpers', () => {
|
|||
anonymizedAlerts: mockAnonymizedAlerts,
|
||||
apiConfig: mockApiConfig,
|
||||
attackDiscoveries: mockAttackDiscoveries,
|
||||
attackDiscoveryId: 'attack-discovery-id',
|
||||
executionUuid: 'attack-discovery-id',
|
||||
authenticatedUser: mockAuthenticatedUser,
|
||||
dataClient: mockDataClient,
|
||||
hasFilter: false,
|
||||
|
|
|
@ -133,7 +133,7 @@ export const updateAttackDiscoveries = async ({
|
|||
anonymizedAlerts,
|
||||
apiConfig,
|
||||
attackDiscoveries,
|
||||
attackDiscoveryId,
|
||||
executionUuid,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
hasFilter,
|
||||
|
@ -148,7 +148,7 @@ export const updateAttackDiscoveries = async ({
|
|||
anonymizedAlerts: Document[];
|
||||
apiConfig: ApiConfig;
|
||||
attackDiscoveries: AttackDiscovery[] | null;
|
||||
attackDiscoveryId: string;
|
||||
executionUuid: string;
|
||||
authenticatedUser: AuthenticatedUser;
|
||||
dataClient: AttackDiscoveryDataClient;
|
||||
end?: string;
|
||||
|
@ -164,7 +164,7 @@ export const updateAttackDiscoveries = async ({
|
|||
}) => {
|
||||
try {
|
||||
const currentAd = await dataClient.getAttackDiscovery({
|
||||
id: attackDiscoveryId,
|
||||
id: executionUuid,
|
||||
authenticatedUser,
|
||||
});
|
||||
if (currentAd === null || currentAd?.status === 'canceled') {
|
||||
|
@ -185,7 +185,7 @@ export const updateAttackDiscoveries = async ({
|
|||
date: new Date().toISOString(),
|
||||
}),
|
||||
}),
|
||||
id: attackDiscoveryId,
|
||||
id: executionUuid,
|
||||
replacements: latestReplacements,
|
||||
backingIndex: currentAd.backingIndex,
|
||||
};
|
||||
|
|
|
@ -15,7 +15,7 @@ import { ATTACK_DISCOVERY_ERROR_EVENT } from '../../../../../lib/telemetry/event
|
|||
|
||||
export const handleGraphError = async ({
|
||||
apiConfig,
|
||||
attackDiscoveryId,
|
||||
executionUuid,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
err,
|
||||
|
@ -24,7 +24,7 @@ export const handleGraphError = async ({
|
|||
telemetry,
|
||||
}: {
|
||||
apiConfig: ApiConfig;
|
||||
attackDiscoveryId: string;
|
||||
executionUuid: string;
|
||||
authenticatedUser: AuthenticatedUser;
|
||||
dataClient: AttackDiscoveryDataClient;
|
||||
err: Error;
|
||||
|
@ -36,7 +36,7 @@ export const handleGraphError = async ({
|
|||
logger.error(err);
|
||||
const error = transformError(err);
|
||||
const currentAd = await dataClient.getAttackDiscovery({
|
||||
id: attackDiscoveryId,
|
||||
id: executionUuid,
|
||||
authenticatedUser,
|
||||
});
|
||||
|
||||
|
@ -48,7 +48,7 @@ export const handleGraphError = async ({
|
|||
attackDiscoveryUpdateProps: {
|
||||
attackDiscoveries: [],
|
||||
status: attackDiscoveryStatus.failed,
|
||||
id: attackDiscoveryId,
|
||||
id: executionUuid,
|
||||
replacements: latestReplacements,
|
||||
backingIndex: currentAd.backingIndex,
|
||||
failureReason: error.message,
|
||||
|
|
|
@ -10,23 +10,18 @@ import {
|
|||
AttackDiscoveryPostRequestBody,
|
||||
AttackDiscoveryPostResponse,
|
||||
API_VERSIONS,
|
||||
Replacements,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
|
||||
import moment from 'moment/moment';
|
||||
import { ATTACK_DISCOVERY } from '../../../../common/constants';
|
||||
import { handleGraphError } from './helpers/handle_graph_error';
|
||||
import { updateAttackDiscoveries, updateAttackDiscoveryStatusToRunning } from '../helpers/helpers';
|
||||
import { updateAttackDiscoveryStatusToRunning } from '../helpers/helpers';
|
||||
import { buildResponse } from '../../../lib/build_response';
|
||||
import { ElasticAssistantRequestHandlerContext } from '../../../types';
|
||||
import { invokeAttackDiscoveryGraph } from './helpers/invoke_attack_discovery_graph';
|
||||
import { requestIsValid } from './helpers/request_is_valid';
|
||||
import { generateAttackDiscoveries } from '../helpers/generate_discoveries';
|
||||
|
||||
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
|
||||
const CONNECTOR_TIMEOUT = LANG_CHAIN_TIMEOUT - 10_000; // 9 minutes 40 seconds
|
||||
|
||||
export const postAttackDiscoveryRoute = (
|
||||
router: IRouter<ElasticAssistantRequestHandlerContext>
|
||||
|
@ -61,7 +56,6 @@ 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;
|
||||
|
@ -89,17 +83,7 @@ export const postAttackDiscoveryRoute = (
|
|||
|
||||
// get parameters from the request body
|
||||
const alertsIndexPattern = decodeURIComponent(request.body.alertsIndexPattern);
|
||||
const {
|
||||
apiConfig,
|
||||
anonymizationFields,
|
||||
end,
|
||||
filter,
|
||||
langSmithApiKey,
|
||||
langSmithProject,
|
||||
replacements,
|
||||
size,
|
||||
start,
|
||||
} = request.body;
|
||||
const { apiConfig, size } = request.body;
|
||||
|
||||
if (
|
||||
!requestIsValid({
|
||||
|
@ -117,12 +101,6 @@ export const postAttackDiscoveryRoute = (
|
|||
// get an Elasticsearch client for the authenticated user:
|
||||
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
|
||||
|
||||
// callback to accumulate the latest replacements:
|
||||
let latestReplacements: Replacements = { ...replacements };
|
||||
const onNewReplacements = (newReplacements: Replacements) => {
|
||||
latestReplacements = { ...latestReplacements, ...newReplacements };
|
||||
};
|
||||
|
||||
const { currentAd, attackDiscoveryId } = await updateAttackDiscoveryStatusToRunning(
|
||||
dataClient,
|
||||
authenticatedUser,
|
||||
|
@ -131,54 +109,17 @@ export const postAttackDiscoveryRoute = (
|
|||
);
|
||||
|
||||
// Don't await the results of invoking the graph; (just the metadata will be returned from the route handler):
|
||||
invokeAttackDiscoveryGraph({
|
||||
generateAttackDiscoveries({
|
||||
actionsClient,
|
||||
alertsIndexPattern,
|
||||
anonymizationFields,
|
||||
apiConfig,
|
||||
connectorTimeout: CONNECTOR_TIMEOUT,
|
||||
end,
|
||||
executionUuid: attackDiscoveryId,
|
||||
authenticatedUser,
|
||||
config: request.body,
|
||||
dataClient,
|
||||
esClient,
|
||||
filter,
|
||||
langSmithProject,
|
||||
langSmithApiKey,
|
||||
latestReplacements,
|
||||
logger,
|
||||
onNewReplacements,
|
||||
savedObjectsClient,
|
||||
size,
|
||||
start,
|
||||
})
|
||||
.then(({ anonymizedAlerts, attackDiscoveries }) =>
|
||||
updateAttackDiscoveries({
|
||||
anonymizedAlerts,
|
||||
apiConfig,
|
||||
attackDiscoveries,
|
||||
attackDiscoveryId,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
hasFilter: !!(filter && Object.keys(filter).length),
|
||||
end,
|
||||
latestReplacements,
|
||||
logger,
|
||||
size,
|
||||
start,
|
||||
startTime,
|
||||
telemetry,
|
||||
})
|
||||
)
|
||||
.catch((err) =>
|
||||
handleGraphError({
|
||||
apiConfig,
|
||||
attackDiscoveryId,
|
||||
authenticatedUser,
|
||||
dataClient,
|
||||
err,
|
||||
latestReplacements,
|
||||
logger,
|
||||
telemetry,
|
||||
})
|
||||
);
|
||||
telemetry,
|
||||
}).catch(() => {}); // to silence @typescript-eslint/no-floating-promises
|
||||
|
||||
return response.ok({
|
||||
body: currentAd,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue