From 21dde1a343117fbc3a7ba1f72f8753a2f3a183eb Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Wed, 11 Feb 2026 16:08:52 +0100 Subject: [PATCH 1/2] [SecuritySolution] Alerts/Attacks promotion --- .../kbn-elastic-assistant-common/constants.ts | 2 + .../promote/post_attack_discovery_promote.ts | 24 ++ .../impl/schemas/index.ts | 5 + .../ad_hoc_execution_client.ts | 208 ++++++++++++++++++ .../server/application/ad_hoc/constants.ts | 8 + .../ad_hoc/lib/create_execution_error.ts | 24 ++ .../ad_hoc/methods/execute/execute.ts | 148 +++++++++++++ .../ad_hoc/methods/execute/index.ts | 9 + .../execute/schemas/execute_params_schema.ts | 14 ++ .../execute/schemas/execute_result_schema.ts | 13 ++ .../ad_hoc/methods/execute/schemas/index.ts | 13 ++ .../schedule_execution_params_schema.ts | 22 ++ .../server/application/ad_hoc/types/index.ts | 25 +++ .../plugins/shared/alerting/server/plugin.ts | 10 + .../server/rules_client/rules_client.ts | 5 + .../alerting/server/rules_client/types.ts | 4 +- .../alerting/server/rules_client_factory.ts | 8 +- .../server/task_runner/ad_hoc_task_runner.ts | 2 + .../schedules/promote_attack/definition.ts | 45 ++++ .../schedules/promote_attack/executor.ts | 150 +++++++++++++ .../schedules/promote_attack/types.ts | 36 +++ .../elastic_assistant/server/plugin.ts | 4 + .../promote/post_attack_discovery_promote.ts | 104 +++++++++ .../server/routes/register_routes.ts | 2 + .../plugins/elastic_assistant/server/types.ts | 2 + .../badges/shared_badge/index.tsx | 14 +- .../use_promote_attack_discovery/index.ts | 54 +++++ .../translations.ts | 22 ++ 28 files changed, 970 insertions(+), 7 deletions(-) create mode 100644 x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/ad_hoc_execution_client/ad_hoc_execution_client.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/constants.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/lib/create_execution_error.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/execute.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/index.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_params_schema.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_result_schema.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/index.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/schedule_execution_params_schema.ts create mode 100644 x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/types/index.ts create mode 100644 x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/definition.ts create mode 100644 x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/executor.ts create mode 100644 x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/types.ts create mode 100644 x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/translations.ts diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/constants.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/constants.ts index d814901a8ce48..67df67a330e58 100755 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/constants.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/constants.ts @@ -85,6 +85,7 @@ export const DEFEND_INSIGHTS_BY_ID = `${DEFEND_INSIGHTS}/{id}`; // Attack Discovery export const ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID = 'attack-discovery' as const; +export const ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID = 'security.attack.promotion' as const; export const ATTACK_DISCOVERY_SCHEDULES_CONSUMER_ID = 'siem' as const; // Attack discovery public API @@ -110,6 +111,7 @@ export const ATTACK_DISCOVERY_INTERNAL = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/attack_discovery` as const; export const ATTACK_DISCOVERY_INTERNAL_MISSING_PRIVILEGES = `${ATTACK_DISCOVERY_INTERNAL}/_missing_privileges` as const; +export const ATTACK_DISCOVERY_INTERNAL_PROMOTE = `${ATTACK_DISCOVERY_INTERNAL}/_promote` as const; /** A fake `kibana.alert.rule.uuid` for ad hock rules */ export const ATTACK_DISCOVERY_AD_HOC_RULE_ID = 'attack_discovery_ad_hoc_rule_id' as const; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts new file mode 100644 index 0000000000000..fde80000a39cd --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts @@ -0,0 +1,24 @@ +/* + * 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 { z } from '@kbn/zod'; + +export const PostAttackDiscoveryPromoteRequestBody = z.object({ + attack_ids: z.array(z.string()).min(1), +}); + +export type PostAttackDiscoveryPromoteRequestBody = z.infer< + typeof PostAttackDiscoveryPromoteRequestBody +>; + +export const PostAttackDiscoveryPromoteResponse = z.object({ + success: z.boolean(), + execution_uuid: z.string().optional(), + error: z.string().optional(), +}); + +export type PostAttackDiscoveryPromoteResponse = z.infer; diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/index.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/index.ts index adc78d99832e0..2e4858c021b12 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/index.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/index.ts @@ -74,6 +74,11 @@ export { PostAttackDiscoveryGenerationsDismissResponse, } from './attack_discovery/routes/public/post/post_attack_discovery_generations_dismiss.route.gen'; +export { + PostAttackDiscoveryPromoteRequestBody, + PostAttackDiscoveryPromoteResponse, +} from './attack_discovery/routes/internal/promote/post_attack_discovery_promote'; + export { GetAttackDiscoveryGenerationRequestParams, GetAttackDiscoveryGenerationRequestQuery, diff --git a/x-pack/platform/plugins/shared/alerting/server/ad_hoc_execution_client/ad_hoc_execution_client.ts b/x-pack/platform/plugins/shared/alerting/server/ad_hoc_execution_client/ad_hoc_execution_client.ts new file mode 100644 index 0000000000000..6836032a9e9e2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/ad_hoc_execution_client/ad_hoc_execution_client.ts @@ -0,0 +1,208 @@ +/* + * 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 { + ISavedObjectsRepository, + Logger, + SavedObjectReference, + SavedObjectsBulkCreateObject, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import type { AuditLogger } from '@kbn/security-plugin/server'; +import type { + TaskInstance, + TaskManagerStartContract, + TaskManagerSetupContract, + RunContext, +} from '@kbn/task-manager-plugin/server'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; +import type { IEventLogger, IEventLogClient } from '@kbn/event-log-plugin/server'; +import type { + ExecutionError, + ScheduleExecutionParams, + ScheduleExecutionResults, + AdHocRunResult, +} from '../application/ad_hoc/types'; +import { + transformAdHocRunToBackfillResult, + transformBackfillParamToAdHocRun, +} from '../application/backfill/transforms'; +import type { RuleDomain } from '../application/rule/types'; +import type { AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AdHocRunAuditAction, adHocRunAuditEvent } from '../rules_client/common/audit_events'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { createExecutionError } from '../application/ad_hoc/lib/create_execution_error'; +import type { TaskRunnerFactory } from '../task_runner'; + +import { AD_HOC_TASK_TYPE } from '../application/ad_hoc/constants'; + +interface ConstructorOpts { + logger: Logger; + taskManagerSetup: TaskManagerSetupContract; + taskManagerStartPromise: Promise; + taskRunnerFactory: TaskRunnerFactory; +} + +interface QueueAdHocExecutionOpts { + auditLogger?: AuditLogger; + params: ScheduleExecutionParams; + rules: RuleDomain[]; + spaceId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + eventLogClient: IEventLogClient; + internalSavedObjectsRepository: ISavedObjectsRepository; + eventLogger: IEventLogger | undefined; +} + +export class AdHocExecutionClient { + private logger: Logger; + private readonly taskManagerStartPromise: Promise; + + constructor(opts: ConstructorOpts) { + this.logger = opts.logger; + this.taskManagerStartPromise = opts.taskManagerStartPromise; + + opts.taskManagerSetup.registerTaskDefinitions({ + [AD_HOC_TASK_TYPE]: { + title: 'Alerting Ad-hoc Rule Run', + priority: TaskPriority.Low, + createTaskRunner: (context: RunContext) => opts.taskRunnerFactory.createAdHoc(context), + }, + }); + } + + public async queueAdHocExecution({ + auditLogger, + params, + rules, + spaceId, + unsecuredSavedObjectsClient, + }: QueueAdHocExecutionOpts): Promise { + const adHocSOsToCreate: Array> = []; + const soToCreateIndexOrErrorMap: Map = new Map(); + + for (let ndx = 0; ndx < params.length; ndx++) { + const param = params[ndx]; + // simplified rule lookup - we expect the rule to be passed in rules array + const rule = rules.find((r) => r.id === param.ruleId); + + if (rule) { + soToCreateIndexOrErrorMap.set(ndx, adHocSOsToCreate.length); + + const references: SavedObjectReference[] = [ + { + name: 'rule', + type: 'rule', + id: rule.id, + }, + ]; + + const start = param.start ?? new Date().toISOString(); + const end = param.end ?? new Date().toISOString(); + + const attributes = transformBackfillParamToAdHocRun( + { + ruleId: rule.id, + ranges: [{ start, end }], + runActions: false, + initiator: param.initiator, + initiatorId: param.initiatorId, + }, + rule, + [], + spaceId + ); + + adHocSOsToCreate.push({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes, + references, + }); + } else { + const error = createExecutionError(`Rule not found for ID ${param.ruleId}`, param.ruleId); + soToCreateIndexOrErrorMap.set(ndx, error); + this.logger.warn( + `Error for ruleId ${param.ruleId} - not scheduling ad-hoc execution for ${JSON.stringify( + param + )}` + ); + } + } + + if (!adHocSOsToCreate.length) { + return params.map((_, ndx: number) => soToCreateIndexOrErrorMap.get(ndx) as ExecutionError); + } + + // Create Saved Objects + const response = await unsecuredSavedObjectsClient.bulkCreate(adHocSOsToCreate); + + // Process results + const createdSOs = response.saved_objects; + + const transformedResponse: ScheduleExecutionResults = []; + + // Map back to original order and transform + for (let ndx = 0; ndx < params.length; ndx++) { + const indexOrError = soToCreateIndexOrErrorMap.get(ndx); + if (typeof indexOrError === 'number') { + const so = createdSOs[indexOrError]; + if (so.error) { + auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.CREATE, + error: new Error(so.error.message), + }) + ); + transformedResponse.push(createExecutionError(so.error.message, params[ndx].ruleId)); + } else { + auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.CREATE, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id: so.id }, + }) + ); + + const result = transformAdHocRunToBackfillResult({ + adHocRunSO: so, + isSystemAction: (id: string) => true, // System action check not relevant here? Or pass dummy + originalSO: adHocSOsToCreate[indexOrError], + }); + + transformedResponse.push(result); + } + } else { + transformedResponse.push(indexOrError as ExecutionError); + } + } + + // Schedule Tasks + const adHocTasksToSchedule: TaskInstance[] = []; + transformedResponse.forEach((result) => { + if (!('error' in result)) { + const createdSO = result as AdHocRunResult; + // We don't have ruleTypeRegistry here easily to check timeout, + // but we can pass it or just default. + adHocTasksToSchedule.push({ + id: createdSO.id, + taskType: AD_HOC_TASK_TYPE, + state: {}, + params: { + adHocRunParamsId: createdSO.id, + spaceId, + }, + }); + } + }); + + if (adHocTasksToSchedule.length > 0) { + const taskManager = await this.taskManagerStartPromise; + await taskManager.bulkSchedule(adHocTasksToSchedule); + } + + return transformedResponse; + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/constants.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/constants.ts new file mode 100644 index 0000000000000..29ee84c46c5da --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 const AD_HOC_TASK_TYPE = 'ad_hoc_run-execute'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/lib/create_execution_error.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/lib/create_execution_error.ts new file mode 100644 index 0000000000000..dda0e8c2c5bef --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/lib/create_execution_error.ts @@ -0,0 +1,24 @@ +/* + * 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 { ExecutionError } from '../types'; + +export function createExecutionError( + message: string, + ruleId: string, + ruleName?: string +): ExecutionError { + return { + error: { + message, + rule: { + id: ruleId, + name: ruleName, + }, + }, + }; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/execute.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/execute.ts new file mode 100644 index 0000000000000..5c8cb2719d42f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/execute.ts @@ -0,0 +1,148 @@ +/* + * 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'; +import { withSpan } from '@kbn/apm-utils'; +import { v4 as uuidv4 } from 'uuid'; +import type { ExecuteResult } from './schemas'; +import { generateAPIKeyName } from '../../../../rules_client/common/generate_api_key_name'; +import type { ExecuteParams } from './schemas'; +import type { RulesClientContext } from '../../../../rules_client/types'; +import type { RuleDomain } from '../../../rule/types/rule'; +import { backfillInitiator } from '../../../../../common/constants'; +import type { ScheduleExecutionParam } from '../../types'; + +export const execute = async ( + context: RulesClientContext, + params: ExecuteParams +): Promise => { + const { ruleTypeId, ruleParams, ruleConsumer } = params; + const client = context.adHocExecutionClient; + + // Validate rule type exists and is enabled + const ruleType = context.ruleTypeRegistry.get(ruleTypeId); + if (!ruleType) { + throw new Error( + i18n.translate('xpack.alerting.adHoc.execute.ruleTypeNotFound', { + defaultMessage: 'Rule type "{ruleTypeId}" not found', + values: { ruleTypeId }, + }) + ); + } + + const ruleId = uuidv4(); + const now = new Date(); + + // Generate API key for the ad-hoc execution + const name = generateAPIKeyName(ruleTypeId, 'Ad-hoc Execution'); + const isAuthTypeApiKey = context.isAuthenticationTypeAPIKey(); + + const apiKeyResult = isAuthTypeApiKey + ? context.getAuthenticationAPIKey(`${name}-user-created`) + : await withSpan( + { + name: 'createAPIKey', + type: 'rules', + }, + () => context.createAPIKey(name, '1h') + ); + + const apiKey = + apiKeyResult && 'apiKeysEnabled' in apiKeyResult && apiKeyResult.apiKeysEnabled + ? Buffer.from(`${apiKeyResult.result.id}:${apiKeyResult.result.api_key}`).toString('base64') + : null; + + if (!apiKey) { + throw new Error( + i18n.translate('xpack.alerting.adHoc.execute.apiKeyGenerationFailed', { + defaultMessage: 'Failed to generate API key for ad-hoc execution', + }) + ); + } + + // Construct a transient RuleDomain object + const rule: RuleDomain> = { + id: ruleId, // Generated ID for ad-hoc execution + consumer: ruleConsumer, + params: ruleParams, + name: 'Ad-hoc Execution', // Placeholder name + schedule: { interval: '1m' }, // Dummy schedule + actions: [], + tags: [], + notifyWhen: 'onActiveAlert', + enabled: true, + alertTypeId: ruleTypeId, + apiKeyOwner: 'user', + apiKey, + revision: 1, + createdBy: 'system', + updatedBy: 'system', + createdAt: now, + updatedAt: now, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastExecutionDate: now, + error: undefined, + warning: undefined, + }, + }; + + const range = { + start: now.toISOString(), + end: now.toISOString(), + }; + + const scheduleParam: ScheduleExecutionParam = { + ruleId, + start: range.start, + end: range.end, + initiator: backfillInitiator.USER, + }; + + const eventLogClient = await context.getEventLogClient(); + + const result = await client.queueAdHocExecution({ + auditLogger: context.auditLogger, + params: [scheduleParam], + rules: [rule as RuleDomain], + spaceId: context.spaceId, + unsecuredSavedObjectsClient: context.unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository: context.internalSavedObjectsRepository, + eventLogger: context.eventLogger, + }); + + const item = result[0]; + + if (!item) { + throw new Error( + i18n.translate('xpack.alerting.adHoc.execute.failedToQueue', { + defaultMessage: 'Failed to queue ad-hoc execution', + }) + ); + } + + if ('error' in item && item.error) { + throw new Error(item.error.message); + } + + // Check for bulkCreateError property which might exist if we unified error types or if legacy type leaking + // But our new types don't define bulkCreateError, they define ExecutionError. + // ExecutionError = { error: { message }, ruleId, ruleName } + // So 'error' check above covers it. + + if (!('id' in item)) { + throw new Error('Unknown error during ad-hoc execution queueing'); + } + + return { + id: item.id, + execution_date: now.toISOString(), + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/index.ts new file mode 100644 index 0000000000000..6adb77b3f304d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/index.ts @@ -0,0 +1,9 @@ +/* + * 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 * from './schemas'; +export * from './execute'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_params_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_params_schema.ts new file mode 100644 index 0000000000000..e30e5022e4944 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_params_schema.ts @@ -0,0 +1,14 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const executeParamsSchema = schema.object({ + ruleTypeId: schema.string(), + ruleParams: schema.recordOf(schema.string(), schema.any()), + ruleConsumer: schema.string(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_result_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_result_schema.ts new file mode 100644 index 0000000000000..140ef7dca9782 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/execute_result_schema.ts @@ -0,0 +1,13 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const executeResultSchema = schema.object({ + id: schema.string(), + execution_date: schema.string(), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/index.ts new file mode 100644 index 0000000000000..89fc8e1ec0f11 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { executeParamsSchema } from './execute_params_schema'; +import type { executeResultSchema } from './execute_result_schema'; + +export type ExecuteParams = TypeOf; +export type ExecuteResult = TypeOf; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/schedule_execution_params_schema.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/schedule_execution_params_schema.ts new file mode 100644 index 0000000000000..e85a62345ac86 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/methods/execute/schemas/schedule_execution_params_schema.ts @@ -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 { schema } from '@kbn/config-schema'; +import { backfillInitiator } from '../../../../../../common/constants'; + +export const scheduleExecutionParamSchema = schema.object({ + ruleId: schema.string(), + start: schema.maybe(schema.string()), + end: schema.maybe(schema.string()), + initiator: schema.oneOf([ + schema.literal(backfillInitiator.USER), + schema.literal(backfillInitiator.SYSTEM), + ]), + initiatorId: schema.maybe(schema.string()), +}); + +export const scheduleExecutionParamsSchema = schema.arrayOf(scheduleExecutionParamSchema); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/types/index.ts new file mode 100644 index 0000000000000..ac601907995c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/ad_hoc/types/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { + scheduleExecutionParamSchema, + scheduleExecutionParamsSchema, +} from '../methods/execute/schemas/schedule_execution_params_schema'; +import type { + ScheduleBackfillResult, + ScheduleBackfillResults, + ScheduleBackfillError, +} from '../../backfill/methods/schedule/types'; +import type { Backfill } from '../../backfill/result/types'; + +export type ScheduleExecutionParam = TypeOf; +export type ScheduleExecutionParams = TypeOf; +export type ScheduleExecutionResult = ScheduleBackfillResult; +export type ScheduleExecutionResults = ScheduleBackfillResults; +export type ExecutionError = ScheduleBackfillError; +export type AdHocRunResult = Backfill; diff --git a/x-pack/platform/plugins/shared/alerting/server/plugin.ts b/x-pack/platform/plugins/shared/alerting/server/plugin.ts index 39e7a0c00bfae..c04a386c6a4ad 100644 --- a/x-pack/platform/plugins/shared/alerting/server/plugin.ts +++ b/x-pack/platform/plugins/shared/alerting/server/plugin.ts @@ -112,6 +112,7 @@ import { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; import type { GetAlertIndicesAlias } from './lib'; import { createGetAlertIndicesAliasFn, spaceIdToNamespace } from './lib'; import { BackfillClient } from './backfill_client/backfill_client'; +import { AdHocExecutionClient } from './ad_hoc_execution_client/ad_hoc_execution_client'; import { MaintenanceWindowsService } from './task_runner/maintenance_windows'; import { AlertDeletionClient } from './alert_deletion'; import { registerGapAutoFillSchedulerTask } from './lib/rule_gaps/task/gap_auto_fill_scheduler_task'; @@ -242,6 +243,7 @@ export class AlertingPlugin { private pluginStop$: Subject; private dataStreamAdapter?: DataStreamAdapter; private backfillClient?: BackfillClient; + private adHocExecutionClient?: AdHocExecutionClient; private alertDeletionClient?: AlertDeletionClient; private readonly isServerless: boolean; private nodeRoles: PluginInitializerContext['node']['roles']; @@ -327,6 +329,13 @@ export class AlertingPlugin { taskRunnerFactory: this.taskRunnerFactory, }); + this.adHocExecutionClient = new AdHocExecutionClient({ + logger: this.logger, + taskManagerSetup: plugins.taskManager, + taskManagerStartPromise, + taskRunnerFactory: this.taskRunnerFactory, + }); + this.eventLogger = plugins.eventLog.getLogger({ event: { provider: EVENT_LOG_PROVIDER }, }); @@ -651,6 +660,7 @@ export class AlertingPlugin { getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!), alertsService: this.alertsService, backfillClient: this.backfillClient!, + adHocExecutionClient: this.adHocExecutionClient!, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: core.uiSettings, securityService: core.security, diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts index 1670dfc246dd3..60dd2cd7847e5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts @@ -116,6 +116,9 @@ import type { import type { FindGapAutoFillSchedulerLogsParams } from '../application/gaps/auto_fill_scheduler/methods/find_logs/types/find_gap_auto_fill_scheduler_logs_types'; import { findGapAutoFillSchedulerLogs } from '../application/gaps/auto_fill_scheduler/methods/find_logs/find_gap_auto_fill_scheduler_logs'; +import { execute as executeAdHoc } from '../application/ad_hoc/methods/execute/execute'; +import type { ExecuteParams as ExecuteAdHocParams } from '../application/ad_hoc/methods/execute/schemas'; + export type ConstructorOptions = Omit< RulesClientContext, 'fieldsToExcludeFromPublicApi' | 'minimumScheduleIntervalInMs' @@ -244,6 +247,8 @@ export class RulesClient { public deleteBackfill = (id: string) => deleteBackfill(this.context, id); + public executeAdHocRule = (params: ExecuteAdHocParams) => executeAdHoc(this.context, params); + public getSpaceId(): string | undefined { return this.context.spaceId; } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts index d5318ad921c03..b697eeb364f5b 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/types.ts @@ -38,6 +38,7 @@ import type { ConnectorAdapterRegistry } from '../connector_adapters/connector_a import type { GetAlertIndicesAlias } from '../lib'; import type { AlertsService } from '../alerts_service'; import type { BackfillClient } from '../backfill_client/backfill_client'; +import type { AdHocExecutionClient } from '../ad_hoc_execution_client/ad_hoc_execution_client'; export type { BulkEditOperation, @@ -69,7 +70,7 @@ export interface RulesClientContext { readonly minimumScheduleInterval: AlertingRulesConfig['minimumScheduleInterval']; readonly maxScheduledPerMinute: AlertingRulesConfig['maxScheduledPerMinute']; readonly minimumScheduleIntervalInMs: number; - readonly createAPIKey: (name: string) => Promise; + readonly createAPIKey: (name: string, expiration?: string) => Promise; readonly getActionsClient: () => Promise; readonly actionsAuthorization: ActionsAuthorization; readonly getEventLogClient: () => Promise; @@ -85,6 +86,7 @@ export interface RulesClientContext { readonly getAlertIndicesAlias: GetAlertIndicesAlias; readonly alertsService: AlertsService | null; readonly backfillClient: BackfillClient; + readonly adHocExecutionClient: AdHocExecutionClient; readonly isSystemAction: (actionId: string) => boolean; readonly uiSettings: UiSettingsServiceStart; } diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts index 8dcee379fc690..a02bf40b3b2fe 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client_factory.ts @@ -27,6 +27,7 @@ import type { AlertingRulesConfig } from './config'; import type { GetAlertIndicesAlias } from './lib'; import type { AlertsService } from './alerts_service/alerts_service'; import type { BackfillClient } from './backfill_client/backfill_client'; +import type { AdHocExecutionClient } from './ad_hoc_execution_client/ad_hoc_execution_client'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE, API_KEY_PENDING_INVALIDATION_TYPE, @@ -55,6 +56,7 @@ export interface RulesClientFactoryOpts { getAlertIndicesAlias: GetAlertIndicesAlias; alertsService: AlertsService | null; backfillClient: BackfillClient; + adHocExecutionClient: AdHocExecutionClient; connectorAdapterRegistry: ConnectorAdapterRegistry; uiSettings: CoreStart['uiSettings']; securityService: CoreStart['security']; @@ -81,6 +83,7 @@ export class RulesClientFactory { private getAlertIndicesAlias!: GetAlertIndicesAlias; private alertsService!: AlertsService | null; private backfillClient!: BackfillClient; + private adHocExecutionClient!: AdHocExecutionClient; private connectorAdapterRegistry!: ConnectorAdapterRegistry; private uiSettings!: CoreStart['uiSettings']; private securityService!: CoreStart['security']; @@ -109,6 +112,7 @@ export class RulesClientFactory { this.getAlertIndicesAlias = options.getAlertIndicesAlias; this.alertsService = options.alertsService; this.backfillClient = options.backfillClient; + this.adHocExecutionClient = options.adHocExecutionClient; this.connectorAdapterRegistry = options.connectorAdapterRegistry; this.uiSettings = options.uiSettings; this.securityService = options.securityService; @@ -196,6 +200,7 @@ export class RulesClientFactory { getAlertIndicesAlias: this.getAlertIndicesAlias, alertsService: this.alertsService, backfillClient: this.backfillClient, + adHocExecutionClient: this.adHocExecutionClient, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: this.uiSettings, @@ -203,7 +208,7 @@ export class RulesClientFactory { const user = securityService.authc.getCurrentUser(request); return user?.username ?? null; }, - async createAPIKey(name: string) { + async createAPIKey(name: string, expiration?: string) { if (!securityPluginStart) { return { apiKeysEnabled: false }; } @@ -216,6 +221,7 @@ export class RulesClientFactory { name, role_descriptors: {}, metadata: { managed: true, kibana: { type: 'alerting_rule' } }, + expiration, } ); if (!createAPIKeyResult) { diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts index 2b03812e5f6a3..e5b2d9d7a4cfb 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts @@ -670,6 +670,8 @@ export class AdHocTaskRunner implements CancellableTask { private async updateGapsAfterBackfillComplete() { if (this.scheduleToRunIndex < 0 || !this.adHocRange) return null; + if (this.adHocRunData?.rule.alertTypeId === 'security.attack.promotion') return null; + const fakeRequest = getFakeKibanaRequest( this.context, this.taskInstance.params.spaceId, diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/definition.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/definition.ts new file mode 100644 index 0000000000000..1ad939abe3d6a --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/definition.ts @@ -0,0 +1,45 @@ +/* + * 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 { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import { ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID } from '@kbn/elastic-assistant-common'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; +import { attackPromotionExecutor } from './executor'; +import type { AttackPromotionExecutorOptions, AttackPromotionRuleType } from './types'; +import { AttackPromotionParams } from './types'; +import { ATTACK_DISCOVERY_ALERTS_AAD_CONFIG } from '../constants'; + +export const getAttackPromotionRuleType = (): AttackPromotionRuleType => { + return { + id: ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + name: 'Attack Promotion', + ruleTaskTimeout: '10m', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + category: DEFAULT_APP_CATEGORIES.security.id, + producer: 'siem', + solution: 'security', + priority: TaskPriority.Normal, + validate: { + params: { + validate: (object: unknown) => { + return AttackPromotionParams.parse(object); + }, + }, + }, + schemas: { + params: { type: 'zod', schema: AttackPromotionParams }, + }, + minimumLicenseRequired: 'basic', + isExportable: false, + autoRecoverAlerts: false, + alerts: ATTACK_DISCOVERY_ALERTS_AAD_CONFIG, + executor: (options: AttackPromotionExecutorOptions) => { + return attackPromotionExecutor(options); + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/executor.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/executor.ts new file mode 100644 index 0000000000000..3992041756884 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/executor.ts @@ -0,0 +1,150 @@ +/* + * 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 { AlertsClientError } from '@kbn/alerting-plugin/server'; +import { ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID } from '@kbn/elastic-assistant-common'; +import { + ALERT_INSTANCE_ID, + ALERT_RULE_EXECUTION_UUID, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UUID, + ALERT_URL, + ALERT_UUID, +} from '@kbn/rule-data-utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { v4 as uuidv4 } from 'uuid'; +import { + ALERT_ATTACK_DISCOVERY_ALERT_IDS, + ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN, + ALERT_ATTACK_DISCOVERY_ENTITY_SUMMARY_MARKDOWN, + ALERT_ATTACK_DISCOVERY_MITRE_ATTACK_TACTICS, + ALERT_ATTACK_DISCOVERY_SUMMARY_MARKDOWN, + ALERT_ATTACK_DISCOVERY_TITLE, + ALERT_ATTACK_DISCOVERY_USERS, +} from '../fields/field_names'; +import { updateAlertsWithAttackIds } from '../register_schedule/updateAlertsWithAttackIds'; +import type { AttackDiscoveryAlertDocument, AttackDiscoveryScheduleContext } from '../types'; +import type { AttackPromotionExecutorOptions } from './types'; + +export const attackPromotionExecutor = async ({ + params, + rule, + services, + spaceId, + logger, +}: AttackPromotionExecutorOptions) => { + const { alertsClient, scopedClusterClient } = services; + if (!alertsClient) { + throw new AlertsClientError(); + } + + const { attackIds } = params; + if (!attackIds || !attackIds.length) { + logger.warn('No attack IDs provided for promotion'); + return { state: {} }; + } + + const esClient = scopedClusterClient.asCurrentUser; + + try { + // 1. Fetch ad-hoc attacks + // We search across all indices matching the prefix to find the source attacks. + const adhocIndex = `.adhoc.alerts-security.attack.discovery.alerts-${spaceId}`; + const searchResponse = await esClient.search({ + index: [adhocIndex], + ignore_unavailable: true, + query: { + ids: { + values: attackIds, + }, + }, + size: attackIds.length, + }); + + const hits = searchResponse.hits.hits; + + if (!hits.length) { + logger.warn(`No attacks found for IDs: ${attackIds.join(', ')}`); + return { state: {} }; + } + + const alertIdToAttackIds: Record = {}; + const executionUuid = uuidv4(); // Generate a new execution UUID for this promotion run + + hits.forEach((hit) => { + const source = hit._source as AttackDiscoveryAlertDocument; + delete source[ALERT_ATTACK_DISCOVERY_USERS]; + + // Generate a new alert instance ID for the promoted attack + const alertInstanceId = uuidv4(); + const { uuid: alertDocId } = alertsClient.report({ + id: alertInstanceId, + actionGroup: 'default', + }); + + // 2. Prepare context and payload + const alertIds = source[ALERT_ATTACK_DISCOVERY_ALERT_IDS] || []; + const title = `Promoted Attack - ${source[ALERT_ATTACK_DISCOVERY_TITLE]}`; + const summaryMarkdown = source[ALERT_ATTACK_DISCOVERY_SUMMARY_MARKDOWN]; + const detailsMarkdown = source[ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN]; + const entitySummaryMarkdown = source[ALERT_ATTACK_DISCOVERY_ENTITY_SUMMARY_MARKDOWN]; + const mitreAttackTactics = source[ALERT_ATTACK_DISCOVERY_MITRE_ATTACK_TACTICS]; + const timestamp = source['@timestamp']; + + // Collect alert IDs for updating them later + for (const alertId of alertIds) { + alertIdToAttackIds[alertId] = alertIdToAttackIds[alertId] ?? []; + alertIdToAttackIds[alertId].push(alertDocId); + } + + const context: AttackDiscoveryScheduleContext = { + attack: { + alertIds, + detailsMarkdown, + detailsUrl: source[ALERT_URL], + entitySummaryMarkdown, + mitreAttackTactics, + summaryMarkdown, + timestamp: new Date(timestamp).toISOString(), + title, + }, + }; + + const payload = { + ...source, + [ALERT_ATTACK_DISCOVERY_TITLE]: title, + [ALERT_RULE_TYPE_ID]: ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + [ALERT_RULE_UUID]: rule.id, + [ALERT_RULE_EXECUTION_UUID]: executionUuid, + [ALERT_INSTANCE_ID]: alertInstanceId, + [ALERT_UUID]: alertDocId, + '@timestamp': new Date().toISOString(), + }; + + // 3. Store the alert + alertsClient.setAlertData({ + id: alertInstanceId, + payload, + context, + }); + }); + + // 4. Update original alerts to point to this new attack + if (Object.keys(alertIdToAttackIds).length > 0) { + await updateAlertsWithAttackIds({ + alertIdToAttackIdsMap: alertIdToAttackIds, + esClient, + spaceId, + }); + } + } catch (error) { + logger.error(`Failed to promote attacks: ${error.message}`); + throw transformError(error); + } + + return { state: {} }; +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/types.ts new file mode 100644 index 0000000000000..19f4bc479fc8b --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/promote_attack/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { z } from '@kbn/zod'; +import type { RuleExecutorOptions, RuleType, RuleTypeState } from '@kbn/alerting-plugin/server'; +import type { AttackDiscoveryAlertDocument, AttackDiscoveryScheduleContext } from '../types'; + +export const AttackPromotionParams = z.object({ + attackIds: z.array(z.string()), +}); + +export type AttackPromotionParams = z.infer; + +export type AttackPromotionExecutorOptions = RuleExecutorOptions< + AttackPromotionParams, + RuleTypeState, + {}, + AttackDiscoveryScheduleContext, + 'default', + AttackDiscoveryAlertDocument +>; + +export type AttackPromotionRuleType = RuleType< + AttackPromotionParams, + never, + RuleTypeState, + {}, + AttackDiscoveryScheduleContext, + 'default', + never, + AttackDiscoveryAlertDocument +>; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts index e492adf3ea86e..e489cd0c7ea2a 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/plugin.ts @@ -44,6 +44,7 @@ import type { CallbackIds } from './services/app_context'; import { appContextService } from './services/app_context'; import { removeLegacyQuickPrompt } from './ai_assistant_service/helpers'; import { getAttackDiscoveryScheduleType } from './lib/attack_discovery/schedules/register_schedule/definition'; +import { getAttackPromotionRuleType } from './lib/attack_discovery/schedules/promote_attack/definition'; import type { ConfigSchema } from './config_schema'; import { attackDiscoveryAlertFieldMap } from './lib/attack_discovery/schedules/fields'; import { ATTACK_DISCOVERY_ALERTS_CONTEXT } from './lib/attack_discovery/schedules/constants'; @@ -273,6 +274,9 @@ export class ElasticAssistantPlugin }) ); + // Register the Attack Promotion rule type + plugins.alerting.registerType(getAttackPromotionRuleType()); + // Initialize the `default` index for ad-hoc generated Attack discoveries const { ruleDataService } = plugins.ruleRegistry; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts new file mode 100644 index 0000000000000..a4c428d467988 --- /dev/null +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts @@ -0,0 +1,104 @@ +/* + * 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 { + API_VERSIONS, + ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + ATTACK_DISCOVERY_SCHEDULES_CONSUMER_ID, + ATTACK_DISCOVERY_INTERNAL_PROMOTE, + PostAttackDiscoveryPromoteRequestBody, + PostAttackDiscoveryPromoteResponse, +} from '@kbn/elastic-assistant-common'; +import type { IKibanaResponse, IRouter } from '@kbn/core/server'; +import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; +import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { ElasticAssistantRequestHandlerContext } from '../../../types'; +import { buildResponse } from '../../../lib/build_response'; +import { performChecks } from '../../helpers'; + +export const postAttackDiscoveryPromoteRoute = ( + router: IRouter +) => { + router.versioned + .post({ + access: 'internal', + path: ATTACK_DISCOVERY_INTERNAL_PROMOTE, + security: { + authz: { + requiredPrivileges: [ATTACK_DISCOVERY_API_ACTION_ALL], + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: { + request: { + body: buildRouteValidationWithZod(PostAttackDiscoveryPromoteRequestBody), + }, + response: { + 200: { + body: { custom: buildRouteValidationWithZod(PostAttackDiscoveryPromoteResponse) }, + }, + }, + }, + }, + async ( + context, + request, + response + ): Promise> => { + const performChecksContext = await context.resolve([ + 'core', + 'elasticAssistant', + 'licensing', + ]); + const resp = buildResponse(response); + const { attack_ids: attackIds } = request.body; + + const checkResponse = await performChecks({ + context: performChecksContext, + request, + response, + }); + + if (!checkResponse.isSuccess) { + return checkResponse.response; + } + + try { + // Resolve plugins + const { elasticAssistant } = await context.resolve(['elasticAssistant']); + + const { rulesClient } = elasticAssistant; + + // Execute Ad-Hoc Rule + const result = await rulesClient.executeAdHocRule({ + ruleTypeId: ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + ruleParams: { + attackIds, + }, + ruleConsumer: ATTACK_DISCOVERY_SCHEDULES_CONSUMER_ID, + }); + + return response.ok({ + body: { + success: true, + execution_uuid: result.id, + }, + }); + } catch (err) { + const error = transformError(err); + return resp.error({ + body: { success: false, error: error.message }, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts index 18b16820d1d25..9cb1f8555384e 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/register_routes.ts @@ -11,6 +11,7 @@ import { findSecurityAIPromptsRoute } from './security_ai_prompts/find_prompts'; import { findAlertSummaryRoute } from './alert_summary/find_route'; import { findAttackDiscoveriesRoute } from './attack_discovery/public/get/find_attack_discoveries'; import { postAttackDiscoveryGenerateRoute } from './attack_discovery/public/post/post_attack_discovery_generate'; +import { postAttackDiscoveryPromoteRoute } from './attack_discovery/promote/post_attack_discovery_promote'; import { postAttackDiscoveryBulkRoute } from './attack_discovery/public/post/post_attack_discovery_bulk'; import type { ElasticAssistantPluginRouter } from '../types'; import { createConversationRoute } from './user_conversations/create_route'; @@ -132,6 +133,7 @@ export const registerRoutes = ( postAttackDiscoveryGenerationsDismissRoute(router); postAttackDiscoveryGenerateRoute(router); + postAttackDiscoveryPromoteRoute(router); getMissingIndexPrivilegesInternalRoute(router); diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts index 1647379d52f9d..158165184665a 100755 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/types.ts @@ -59,6 +59,7 @@ import type { import type { InferenceChatModel } from '@kbn/inference-langchain'; import type { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server'; import type { CheckPrivileges, SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { SecurityRequestHandlerContext } from '@kbn/core-security-server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { BaseCheckpointSaver } from '@langchain/langgraph-checkpoint'; import type { @@ -197,6 +198,7 @@ export interface ElasticAssistantApiRequestHandlerContext { export type ElasticAssistantRequestHandlerContext = CustomRequestHandlerContext<{ elasticAssistant: ElasticAssistantApiRequestHandlerContext; licensing: LicensingApiRequestHandlerContext; + security: SecurityRequestHandlerContext; }>; export type ElasticAssistantPluginRouter = IRouter; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx index 3be52d201eff4..9a37b2bdda9fc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/panel_header/primary_interactions/badges/shared_badge/index.tsx @@ -24,6 +24,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import * as i18n from './translations'; import { useAttackDiscoveryBulk } from '../../../../../../use_attack_discovery_bulk'; import { useInvalidateFindAttackDiscoveries } from '../../../../../../use_find_attack_discoveries'; +import { usePromoteAttackDiscovery } from '../../../../../../use_promote_attack_discovery'; import { isAttackDiscoveryAlert } from '../../../../../../utils/is_attack_discovery_alert'; const LIST_PROPS = { @@ -145,6 +146,7 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { ); const { mutateAsync: attackDiscoveryBulk } = useAttackDiscoveryBulk(); + const { mutateAsync: promoteAttackDiscovery } = usePromoteAttackDiscovery(); const onSelectableChange = useCallback( async (newOptions: EuiSelectableOption[]) => { @@ -153,13 +155,15 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { if (isAttackDiscoveryAlert(attackDiscovery)) { const visibility = newOptions[0].checked === 'on' ? 'not_shared' : 'shared'; - await attackDiscoveryBulk({ - ids: [attackDiscovery.id], - visibility, - }); + // await attackDiscoveryBulk({ + // ids: [attackDiscovery.id], + // visibility, + // }); // disable all options if the new visibility is 'shared' if (visibility === 'shared') { + await promoteAttackDiscovery({ attackIds: [attackDiscovery.id] }); + setItems( newOptions.map((item) => ({ ...item, @@ -171,7 +175,7 @@ const SharedBadgeComponent: React.FC = ({ attackDiscovery }) => { invalidateFindAttackDiscoveries(); } }, - [attackDiscovery, attackDiscoveryBulk, invalidateFindAttackDiscoveries] + [attackDiscovery, attackDiscoveryBulk, invalidateFindAttackDiscoveries, promoteAttackDiscovery] ); const allItemsDisabled = useMemo(() => items.every((item) => item.disabled), [items]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/index.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/index.ts new file mode 100644 index 0000000000000..e2aa9783f8c77 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/index.ts @@ -0,0 +1,54 @@ +/* + * 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 { PostAttackDiscoveryPromoteResponse } from '@kbn/elastic-assistant-common'; +import { API_VERSIONS, ATTACK_DISCOVERY_INTERNAL_PROMOTE } from '@kbn/elastic-assistant-common'; +import { useMutation } from '@kbn/react-query'; + +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { KibanaServices } from '../../../common/lib/kibana'; +import * as i18n from './translations'; + +interface PromoteAttackDiscoveryParams { + /** The IDs of the attacks to promote */ + attackIds: string[]; + /** Optional AbortSignal for cancelling request */ + signal?: AbortSignal; +} + +/** + * Promotes the given attacks to scheduled alerts. + * @param attackIds - The IDs of the attacks to promote + * @param signal - An optional AbortSignal to cancel the request + * @returns A mutation object that can be used to promote the attacks + */ +export const usePromoteAttackDiscovery = () => { + const { addError, addSuccess } = useAppToasts(); + + const promote = async ({ attackIds, signal }: PromoteAttackDiscoveryParams) => + KibanaServices.get().http.post( + ATTACK_DISCOVERY_INTERNAL_PROMOTE, + { + body: JSON.stringify({ attack_ids: attackIds }), + version: API_VERSIONS.internal.v1, + signal, + } + ); + + return useMutation( + ({ attackIds, signal }) => promote({ attackIds, signal }), + { + mutationKey: ['POST', ATTACK_DISCOVERY_INTERNAL_PROMOTE], + onSuccess: () => { + addSuccess(i18n.PROMOTE_ATTACK_DISCOVERY_SUCCESS); + }, + onError: (error) => { + addError(error, { title: i18n.PROMOTE_ATTACK_DISCOVERY_FAILURE }); + }, + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/translations.ts new file mode 100644 index 0000000000000..9668bac1c0c8f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_promote_attack_discovery/translations.ts @@ -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 PROMOTE_ATTACK_DISCOVERY_FAILURE = i18n.translate( + 'xpack.securitySolution.attackDiscovery.promoteAttackDiscoveryFailureTitle', + { + defaultMessage: 'Failed to promote attacks to scheduled alerts', + } +); + +export const PROMOTE_ATTACK_DISCOVERY_SUCCESS = i18n.translate( + 'xpack.securitySolution.attackDiscovery.promoteAttackDiscoverySuccessTitle', + { + defaultMessage: 'Successfully started promotion of attacks to scheduled alerts', + } +); From e3b049cd0c0f7032581d127e01af4133659301d0 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Thu, 5 Mar 2026 22:47:58 +0100 Subject: [PATCH 2/2] POC 2 --- .../promote/post_attack_discovery_promote.ts | 2 +- .../alerting/server/alerts_client/types.ts | 27 +++ .../server/alerts_service/alerts_service.ts | 142 +++++++++++++- .../alerting/server/alerts_service/index.ts | 6 +- .../plugins/shared/alerting/server/plugin.ts | 7 + .../promote/post_attack_discovery_promote.ts | 181 +++++++++++++++++- .../components/attacks/utils/dsl.ts | 12 +- 7 files changed, 361 insertions(+), 16 deletions(-) diff --git a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts index fde80000a39cd..588bff4567693 100644 --- a/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts +++ b/x-pack/platform/packages/shared/kbn-elastic-assistant-common/impl/schemas/attack_discovery/routes/internal/promote/post_attack_discovery_promote.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { z } from '@kbn/zod'; +import { z } from '@kbn/zod/v4'; export const PostAttackDiscoveryPromoteRequestBody = z.object({ attack_ids: z.array(z.string()).min(1), diff --git a/x-pack/platform/plugins/shared/alerting/server/alerts_client/types.ts b/x-pack/platform/plugins/shared/alerting/server/alerts_client/types.ts index b44b6139c07a6..a2b2ccce7aa4a 100644 --- a/x-pack/platform/plugins/shared/alerting/server/alerts_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/alerts_client/types.ts @@ -178,6 +178,33 @@ export interface PublicAlertsClient< getRecoveredAlerts: () => Array>; } +export interface IAdHocAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + report( + alert: ReportedAlert< + AlertData, + LegacyState, + LegacyContext, + WithoutReservedActionGroups + > + ): ReportedAlertData; + setAlertData( + alert: UpdateableAlert< + AlertData, + LegacyState, + LegacyContext, + WithoutReservedActionGroups + > + ): void; + processAlerts(): void; + persistAlerts(): Promise; +} + export interface ReportedAlert< AlertData extends RuleAlertData, State extends AlertInstanceState, diff --git a/x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.ts b/x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.ts index 7a0cdc0562573..b3ef516468d59 100644 --- a/x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.ts +++ b/x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.ts @@ -6,12 +6,14 @@ */ import { isEmpty, isEqual, omit } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; import type { Logger, ElasticsearchClient } from '@kbn/core/server'; import type { Observable } from 'rxjs'; import { filter, firstValueFrom } from 'rxjs'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { alertFieldMap, ecsFieldMap, legacyAlertFieldMap } from '@kbn/alerts-as-data-utils'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import type { IEventLogger } from '@kbn/event-log-plugin/server'; import { ALERT_MUTED, ALERT_INSTANCE_ID, @@ -55,13 +57,15 @@ import { } from './lib'; import type { LegacyAlertsClientParams, AlertRuleData } from '../alerts_client'; import { AlertsClient } from '../alerts_client'; -import type { IAlertsClient } from '../alerts_client/types'; +import type { IAlertsClient, IAdHocAlertsClient } from '../alerts_client/types'; import type { SetAlertsToUntrackedParams } from './lib/set_alerts_to_untracked'; import { setAlertsToUntracked } from './lib/set_alerts_to_untracked'; import type { ClearAlertFlappingHistoryParams } from './lib/clear_alert_flapping_history'; import { clearAlertFlappingHistory } from './lib/clear_alert_flapping_history'; import type { IsExistingAlertParams } from './lib/is_existing_alert'; import { isExistingAlert } from './lib/is_existing_alert'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; + export const TOTAL_FIELDS_LIMIT = 2500; const LEGACY_ALERT_CONTEXT = 'legacy-alert'; export const ECS_CONTEXT = `ecs`; @@ -82,6 +86,20 @@ export interface CreateAlertsClientParams extends LegacyAlertsClientParams { rule: AlertRuleData; } +export interface CreateAdHocAlertsClientParams + extends Omit { + alertingEventLogger?: AlertingEventLogger; + eventLogger?: IEventLogger; + namespace: string; + ruleData: { + id: string; + name: string; + consumer: string; + tags?: string[]; + executionId?: string; + }; +} + export type MuteInstances = Array<{ ruleId: string; alertInstanceIds?: string[] }>; interface IAlertsService { @@ -130,9 +148,28 @@ interface IAlertsService { ActionGroupIds, RecoveryActionGroupId > | null>; + + createAdHocAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >( + opts: CreateAdHocAlertsClientParams + ): Promise | null>; } -export type PublicAlertsService = Pick; +export type PublicAlertsService = Pick< + IAlertsService, + 'getContextInitializationPromise' | 'createAdHocAlertsClient' +>; export type PublicFrameworkAlertsService = PublicAlertsService & { enabled: () => boolean; }; @@ -170,6 +207,107 @@ export class AlertsService implements IAlertsService { return this.initialized; } + public async createAdHocAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >( + opts: CreateAdHocAlertsClientParams + ): Promise | null> { + const rule: AlertRuleData = { + id: opts.ruleData.id, + name: opts.ruleData.name, + consumer: opts.ruleData.consumer, + tags: opts.ruleData.tags || [], + executionId: opts.ruleData.executionId || uuidv4(), + parameters: {}, + revision: 1, + spaceId: opts.spaceId, + alertDelay: 0, + muteAll: false, + mutedInstanceIds: [], + }; + + let alertingEventLogger = opts.alertingEventLogger; + if (!alertingEventLogger && opts.eventLogger) { + alertingEventLogger = new AlertingEventLogger(opts.eventLogger); + alertingEventLogger.initialize({ + context: { + savedObjectId: rule.id, + savedObjectType: 'alert', + namespace: opts.namespace, + spaceId: opts.spaceId, + executionId: rule.executionId, + taskScheduledAt: new Date(), + }, + runDate: new Date(), + ruleData: { + id: rule.id, + type: opts.ruleType, + consumer: rule.consumer, + name: rule.name, + revision: rule.revision, + }, + }); + } + + if (!alertingEventLogger) { + throw new Error( + 'Either alertingEventLogger or eventLogger is required for ad-hoc alerts client' + ); + } + + const alertsClient = await this.createAlertsClient< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + ...omit(opts, ['ruleData', 'eventLogger']), + rule, + alertingEventLogger, + }); + + if (!alertsClient) { + return null; + } + + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: rule.name, + runTimestamp: new Date(), + startedAt: new Date(), + flappingSettings: { + lookBackWindow: 3600000, // 1h in ms + statusChangeThreshold: 5, + enabled: false, + }, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }); + + const publicClient = alertsClient.client(); + if (!publicClient) { + return null; + } + + return { + report: (alert) => publicClient.report(alert), + setAlertData: (alert) => publicClient.setAlertData(alert), + processAlerts: () => alertsClient.processAlerts(), + persistAlerts: () => alertsClient.persistAlerts(), + }; + } + public async createAlertsClient< AlertData extends RuleAlertData, LegacyState extends AlertInstanceState, diff --git a/x-pack/platform/plugins/shared/alerting/server/alerts_service/index.ts b/x-pack/platform/plugins/shared/alerting/server/alerts_service/index.ts index 0b32802bdf250..49bde362f1e08 100644 --- a/x-pack/platform/plugins/shared/alerting/server/alerts_service/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/alerts_service/index.ts @@ -16,7 +16,11 @@ export { successResult, errorResult, } from './create_resource_installation_helper'; -export { AlertsService, type PublicFrameworkAlertsService } from './alerts_service'; +export { + AlertsService, + type PublicFrameworkAlertsService, + type CreateAdHocAlertsClientParams, +} from './alerts_service'; export { createOrUpdateIlmPolicy, createOrUpdateComponentTemplate, diff --git a/x-pack/platform/plugins/shared/alerting/server/plugin.ts b/x-pack/platform/plugins/shared/alerting/server/plugin.ts index 35c8a1cce4cc0..0f1d4bd2df0dc 100644 --- a/x-pack/platform/plugins/shared/alerting/server/plugin.ts +++ b/x-pack/platform/plugins/shared/alerting/server/plugin.ts @@ -102,6 +102,7 @@ import { AlertsService, type PublicFrameworkAlertsService, type InitializationPromise, + type CreateAdHocAlertsClientParams, errorResult, } from './alerts_service'; import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; @@ -590,6 +591,12 @@ export class AlertingPlugin { return Promise.resolve(errorResult(`Framework alerts service not available`)); }, + createAdHocAlertsClient: (opts: CreateAdHocAlertsClientParams) => { + if (this.alertsService) { + return this.alertsService.createAdHocAlertsClient(opts); + } + return Promise.resolve(null); + }, }, getDataStreamAdapter: () => this.dataStreamAdapter!, }; diff --git a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts index a4c428d467988..619eb0364de89 100644 --- a/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts +++ b/x-pack/solutions/security/plugins/elastic_assistant/server/routes/attack_discovery/promote/post_attack_discovery_promote.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import { API_VERSIONS, ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, @@ -17,9 +18,33 @@ import type { IKibanaResponse, IRouter } from '@kbn/core/server'; import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common'; import { ATTACK_DISCOVERY_API_ACTION_ALL } from '@kbn/security-solution-features/actions'; import { transformError } from '@kbn/securitysolution-es-utils'; +import { + ALERT_INSTANCE_ID, + ALERT_RULE_EXECUTION_UUID, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UUID, + ALERT_URL, + ALERT_UUID, +} from '@kbn/rule-data-utils'; +import type { AlertInstanceContext, AlertInstanceState } from '@kbn/alerting-plugin/server'; import type { ElasticAssistantRequestHandlerContext } from '../../../types'; import { buildResponse } from '../../../lib/build_response'; import { performChecks } from '../../helpers'; +import { + ALERT_ATTACK_DISCOVERY_ALERT_IDS, + ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN, + ALERT_ATTACK_DISCOVERY_ENTITY_SUMMARY_MARKDOWN, + ALERT_ATTACK_DISCOVERY_MITRE_ATTACK_TACTICS, + ALERT_ATTACK_DISCOVERY_SUMMARY_MARKDOWN, + ALERT_ATTACK_DISCOVERY_TITLE, + ALERT_ATTACK_DISCOVERY_USERS, + ALERT_ATTACK_IDS, +} from '../../../lib/attack_discovery/schedules/fields/field_names'; +import type { + AttackDiscoveryAlertDocument, + AttackDiscoveryScheduleContext, +} from '../../../lib/attack_discovery/schedules/types'; +import { updateAlertsWithAttackIds } from '../../../lib/attack_discovery/schedules/register_schedule/updateAlertsWithAttackIds'; export const postAttackDiscoveryPromoteRoute = ( router: IRouter @@ -43,7 +68,9 @@ export const postAttackDiscoveryPromoteRoute = ( }, response: { 200: { - body: { custom: buildRouteValidationWithZod(PostAttackDiscoveryPromoteResponse) }, + body: { + custom: buildRouteValidationWithZod(PostAttackDiscoveryPromoteResponse), + }, }, }, }, @@ -59,7 +86,7 @@ export const postAttackDiscoveryPromoteRoute = ( 'licensing', ]); const resp = buildResponse(response); - const { attack_ids: attackIds } = request.body; + const { attack_ids: attackIds } = request.body as PostAttackDiscoveryPromoteRequestBody; const checkResponse = await performChecks({ context: performChecksContext, @@ -74,22 +101,156 @@ export const postAttackDiscoveryPromoteRoute = ( try { // Resolve plugins const { elasticAssistant } = await context.resolve(['elasticAssistant']); + const { rulesClient, frameworkAlerts } = elasticAssistant; + + if (!frameworkAlerts.enabled()) { + throw new Error('Framework alerts are not enabled'); + } - const { rulesClient } = elasticAssistant; + const ruleTypeId = ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID; + const ruleType = rulesClient.getContext().ruleTypeRegistry.get(ruleTypeId); - // Execute Ad-Hoc Rule - const result = await rulesClient.executeAdHocRule({ - ruleTypeId: ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, - ruleParams: { - attackIds, + if (!ruleType) { + throw new Error(`Rule type ${ruleTypeId} not found`); + } + + const spaceId = rulesClient.getSpaceId() || 'default'; + const ruleId = uuidv4(); + const ruleName = 'Promoted Attack (Ad-Hoc)'; + const executionUuid = uuidv4(); + const namespace = rulesClient.getContext().namespace || 'default'; + + const alertsClient = await frameworkAlerts.createAdHocAlertsClient< + Record, + AlertInstanceState, + AlertInstanceContext, + 'default', + 'recovered' + >({ + namespace, + ruleData: { + id: ruleId, + name: ruleName, + consumer: ATTACK_DISCOVERY_SCHEDULES_CONSUMER_ID, + executionId: executionUuid, }, - ruleConsumer: ATTACK_DISCOVERY_SCHEDULES_CONSUMER_ID, + spaceId, + ruleType, + eventLogger: elasticAssistant.eventLogger, + logger: elasticAssistant.logger, + request, }); + if (!alertsClient) { + throw new Error('Failed to create alerts client'); + } + + const core = await context.core; + const esClient = core.elasticsearch.client.asCurrentUser; + + // 1. Fetch ad-hoc attacks + // We search across all indices matching the prefix to find the source attacks. + const adhocIndex = `.adhoc.alerts-security.attack.discovery.alerts-${spaceId}`; + const searchResponse = await esClient.search({ + index: [adhocIndex], + ignore_unavailable: true, + query: { + ids: { + values: attackIds, + }, + }, + size: attackIds.length, + }); + + const hits = searchResponse.hits.hits; + + if (!hits.length) { + elasticAssistant.logger.warn(`No attacks found for IDs: ${attackIds.join(', ')}`); + return response.ok({ + body: { + success: true, + execution_uuid: executionUuid, + }, + }); + } + + const alertIdToAttackIds: Record = {}; + + for (const hit of hits) { + const source = hit._source as AttackDiscoveryAlertDocument; + delete source[ALERT_ATTACK_DISCOVERY_USERS]; + + // Generate a new alert instance ID for the promoted attack + const alertInstanceId = uuidv4(); + const { uuid: alertDocId } = alertsClient.report({ + id: alertInstanceId, + actionGroup: 'default', + }); + + // 2. Prepare context and payload + const alertIds = source[ALERT_ATTACK_DISCOVERY_ALERT_IDS] || []; + const title = `Promoted Attack - ${source[ALERT_ATTACK_DISCOVERY_TITLE]}`; + const summaryMarkdown = source[ALERT_ATTACK_DISCOVERY_SUMMARY_MARKDOWN]; + const detailsMarkdown = source[ALERT_ATTACK_DISCOVERY_DETAILS_MARKDOWN]; + const entitySummaryMarkdown = source[ALERT_ATTACK_DISCOVERY_ENTITY_SUMMARY_MARKDOWN]; + const mitreAttackTactics = source[ALERT_ATTACK_DISCOVERY_MITRE_ATTACK_TACTICS]; + const timestamp = source['@timestamp']; + + // Collect alert IDs for updating them later + for (const alertId of alertIds) { + alertIdToAttackIds[alertId] = alertIdToAttackIds[alertId] ?? []; + alertIdToAttackIds[alertId].push(alertDocId); + } + + const alertContext: AttackDiscoveryScheduleContext = { + attack: { + alertIds, + detailsMarkdown, + detailsUrl: source[ALERT_URL], + entitySummaryMarkdown, + mitreAttackTactics, + summaryMarkdown, + timestamp: new Date(timestamp).toISOString(), + title, + }, + }; + + const payload = { + ...source, + [ALERT_ATTACK_DISCOVERY_TITLE]: title, + [ALERT_RULE_TYPE_ID]: ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + [ALERT_RULE_UUID]: ruleId, + [ALERT_RULE_EXECUTION_UUID]: executionUuid, + [ALERT_INSTANCE_ID]: alertInstanceId, + [ALERT_ATTACK_IDS]: [alertDocId], + [ALERT_UUID]: alertDocId, + '@timestamp': new Date().toISOString(), + }; + + // 3. Store the alert + alertsClient.setAlertData({ + id: alertInstanceId, + payload, + context: alertContext, + }); + } + + alertsClient.processAlerts(); + await alertsClient.persistAlerts(); + + // 4. Update original alerts to point to this new attack + if (Object.keys(alertIdToAttackIds).length > 0) { + await updateAlertsWithAttackIds({ + alertIdToAttackIdsMap: alertIdToAttackIds, + esClient, + spaceId, + }); + } + return response.ok({ body: { success: true, - execution_uuid: result.id, + execution_uuid: executionUuid, }, }); } catch (err) { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/utils/dsl.ts b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/utils/dsl.ts index 4dcb943d4d5a3..3f6053d9a5852 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/utils/dsl.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detections/components/attacks/utils/dsl.ts @@ -6,11 +6,19 @@ */ import type { estypes } from '@elastic/elasticsearch'; -import { ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID } from '@kbn/elastic-assistant-common'; +import { + ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID, +} from '@kbn/elastic-assistant-common'; export const dsl = { isAttack: (): estypes.QueryDslQueryContainer => ({ - term: { 'kibana.alert.rule.rule_type_id': ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID }, + terms: { + 'kibana.alert.rule.rule_type_id': [ + ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID, + ATTACK_DISCOVERY_PROMOTE_ATTACK_RULE_TYPE_ID, + ], + }, }), isNotAttack: (): estypes.QueryDslQueryContainer => ({ bool: { must_not: dsl.isAttack() },