diff --git a/src/platform/plugins/shared/workflows_extensions/test/scout/api/fixtures/approved_step_definitions.ts b/src/platform/plugins/shared/workflows_extensions/test/scout/api/fixtures/approved_step_definitions.ts index 31d1164a03fe2..5f94ba49c7613 100644 --- a/src/platform/plugins/shared/workflows_extensions/test/scout/api/fixtures/approved_step_definitions.ts +++ b/src/platform/plugins/shared/workflows_extensions/test/scout/api/fixtures/approved_step_definitions.ts @@ -84,6 +84,14 @@ export const APPROVED_STEP_DEFINITIONS: Array<{ id: string; handlerHash: string id: 'search.rerank', handlerHash: '2bdde599ac1b8f38faecbd72a2d17a3d7b2740b874e047e92e9c30ba0ff01a4f', }, + { + id: 'security.buildAlertEntityGraph', + handlerHash: '90e95df7b6deaa5b6ab908c1ff8f4a3606b6e8f7fce5d59e0411d3d577d0be44', + }, + { + id: 'security.renderAlertNarrative', + handlerHash: '1719b8db582f3695a5bac6df5434285f101ebf4fa032702613f28dd33d978988', + }, { id: 'cases.addAlerts', handlerHash: '1704c6d46ccb5432e1df6c24f7ebde8d4b1686c007dcaf6a5c5cac02b0222e3e', diff --git a/x-pack/solutions/security/plugins/security_solution/common/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/constants.ts index 901b4ce8d080e..091533a2cedf7 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/constants.ts @@ -722,3 +722,7 @@ export enum SecurityAgentBuilderAttachments { } export const SECURITY_RULE_ATTACHMENT_ID = 'ai-rule-creation'; + +export const REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG = + 'securitySolution.registerAlertValidationStepsEnabled' as const; +export const REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT = false as const; diff --git a/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step_common.ts b/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step_common.ts new file mode 100644 index 0000000000000..eaae70cea1221 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step_common.ts @@ -0,0 +1,225 @@ +/* + * 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/v4'; +import { StepCategory } from '@kbn/workflows'; +import type { BaseStepDefinition } from '@kbn/workflows'; +import { i18n } from '@kbn/i18n'; + +const DEFAULT_ENTITY_FIELDS = ['host.name', 'user.name', 'service.name'] as const; +interface EntityFieldConfig { + field: string; + score?: number; + aliases?: Array<{ field: string; score?: number }>; +} +const DEFAULT_ENTITY_FIELD_CONFIG: EntityFieldConfig[] = DEFAULT_ENTITY_FIELDS.map((field) => ({ + field, +})); + +export const buildAlertEntityGraphInputSchema = z.object({ + alertId: z.string().describe('The alert ID to find related alerts for'), + alertIndex: z.string().describe('The alert index'), + entity_fields: z + .array( + z.object({ + field: z.string(), + score: z.coerce.number().finite().optional(), + aliases: z + .array( + z.object({ + field: z.string(), + score: z.coerce.number().finite().optional(), + }) + ) + .optional(), + }) + ) + .optional() + .default(DEFAULT_ENTITY_FIELD_CONFIG) + .describe( + 'Entity fields to extract and use for correlation. Each entry may provide an optional score override for that field, plus optional aliases (additional fields to match against using the same value). (default: host.name, user.name, service.name)' + ), + seed_window: z + .string() + .optional() + .default('1h') + .describe( + 'Initial time window around the seed alert timestamp (e.g., "1h", "24h"). Default: "1h"' + ), + expand_window: z + .string() + .optional() + .default('1h') + .describe( + 'Time window padding applied to the growing min/max timestamps as related alerts are found. Default: "1h"' + ), + max_depth: z.coerce + .number() + .finite() + .int() + .min(0) + .optional() + .default(3) + .describe('Maximum recursion depth (number of expansion rounds). Default: 3'), + max_alerts: z.coerce + .number() + .finite() + .int() + .min(1) + .optional() + .default(300) + .describe('Hard cap on number of discovered alerts (including seed if included). Default: 300'), + page_size: z.coerce + .number() + .finite() + .int() + .min(1) + .max(1000) + .optional() + .default(200) + .describe('Elasticsearch page size per query. Default: 200'), + max_terms_per_query: z.coerce + .number() + .finite() + .int() + .min(1) + .optional() + .default(500) + .describe( + 'Maximum number of terms per `terms` query clause (chunked if exceeded). Default: 500' + ), + max_entities_per_field: z.coerce + .number() + .finite() + .int() + .min(1) + .optional() + .default(200) + .describe('Maximum number of distinct entity values to track per entity field. Default: 200'), + ignore_entities: z + .array(z.object({ field: z.string(), values: z.array(z.string()) })) + .optional() + .default([]) + .describe( + 'Entity values to ignore for correlation/scoring (e.g. field=user.name values=[root,SYSTEM]). Ignored values do not contribute to edge scores and are not used for graph expansion.' + ), + min_entity_score: z.coerce + .number() + .finite() + .min(1) + .optional() + .default(2) + .describe( + 'Minimum total entity match score required to create an edge (sum of per-label scores). Default: 2' + ), + include_seed: z + .boolean() + .optional() + .default(true) + .describe('Whether to include the seed alert as a node in the output graph. Default: true'), +}); + +export const buildAlertEntityGraphOutputSchema = z.object({ + nodes: z.array(z.object({ id: z.string() })), + edges: z.array( + z.object({ + from: z.string(), + to: z.string(), + score: z.number(), + label_scores: z.record(z.string(), z.number()), + }) + ), + alerts: z.array( + z.object({ + alert_id: z.string(), + alert_index: z.string(), + timestamp: z.string().optional(), + rule_name: z.string().optional(), + severity: z.string().optional(), + }) + ), + stats: z + .object({ + depth_reached: z.number(), + nodes: z.number(), + edges: z.number(), + queries: z.number(), + time_range: z.object({ gte: z.string(), lte: z.string() }), + }) + .optional(), +}); + +export const buildAlertEntityGraphStepCommonDefinition: BaseStepDefinition< + typeof buildAlertEntityGraphInputSchema, + typeof buildAlertEntityGraphOutputSchema +> = { + id: 'security.buildAlertEntityGraph', + label: i18n.translate('xpack.securitySolution.workflows.steps.buildAlertEntityGraph.label', { + defaultMessage: 'Build Alert Entity Graph', + }), + description: i18n.translate( + 'xpack.securitySolution.workflows.steps.buildAlertEntityGraph.description', + { + defaultMessage: + 'Build a scored graph of correlated alerts by performing a breadth-first search over shared entity fields (host, user, process, IP, cloud identity, etc.). Starting from a seed alert, the step searches a configurable time window (seed_window) for alerts that share entity values, then iteratively expands the window (expand_window) at each hop to chain through transitively linked entities up to max_depth rounds, producing a nodes-and-edges graph with per-field edge scores.', + } + ), + category: StepCategory.Kibana, + stability: 'tech_preview', + inputSchema: buildAlertEntityGraphInputSchema, + outputSchema: buildAlertEntityGraphOutputSchema, + documentation: { + details: i18n.translate( + 'xpack.securitySolution.workflows.steps.buildAlertEntityGraph.documentation.details', + { + defaultMessage: + 'Starting from a seed alert, performs a breadth-first search to discover correlated alerts that share entity field values (host, user, process, IP, cloud identity, service, container, etc.). ' + + 'Round 0 queries a fixed time window (seed_window) around the seed timestamp. Each subsequent round widens the search bounds by expand_window based on the min/max timestamps of newly discovered alerts, chaining through transitively linked entities up to max_depth hops or max_alerts total. ' + + 'Entity fields carry configurable scores that reflect identifier specificity (e.g. process.entity_id=5, host.id=4, user.name=2, source.ip=1). Aliases allow cross-field matching (e.g. source.ip ↔ destination.ip for lateral-movement detection). ' + + 'An edge is created between two alerts when their summed per-label entity scores meet or exceed min_entity_score. ignore_entities filters out noisy values (service accounts, loopback IPs) that would create false correlation chains. ' + + 'The output is a scored nodes-and-edges graph with per-alert metadata (rule name, severity, timestamp), per-edge label scores, and traversal stats (depth reached, query count, effective time range).', + } + ), + examples: [ + `## Build alert entity graph +\`\`\`yaml +- name: build_alert_entity_graph + type: security.buildAlertEntityGraph + with: + alertId: "{{ variables.alert_id }}" + alertIndex: "{{ variables.alert_index }}" + include_seed: true + entity_fields: + - field: "process.entity_id" + score: 5 + - field: "agent.id" + score: 4 + - field: "user.id" + score: 4 + - field: "host.name" + score: 2 + - field: "user.name" + score: 2 + - field: "service.name" + score: 1 + - field: "source.ip" + score: 1 + aliases: + - field: "destination.ip" + score: 4 + min_entity_score: 4 + ignore_entities: + - field: "user.name" + values: ["root", "SYSTEM", "Administrator"] + seed_window: "1h" + expand_window: "1h" + max_depth: 3 + max_alerts: 300 +\`\`\``, + ], + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step_common.ts b/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step_common.ts new file mode 100644 index 0000000000000..2b1082c485e78 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step_common.ts @@ -0,0 +1,63 @@ +/* + * 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/v4'; +import { StepCategory } from '@kbn/workflows'; +import type { BaseStepDefinition } from '@kbn/workflows'; +import { i18n } from '@kbn/i18n'; + +export const renderAlertNarrativeInputSchema = z.object({ + alertId: z.string().describe('The alert ID'), + alertIndex: z.string().describe('The index that contains the alert'), +}); + +export const renderAlertNarrativeOutputSchema = z.object({ + alert_id: z.string(), + alert_index: z.string(), + timeline_string: z.string().describe('A Timeline-like English string for the alert'), + message: z.string(), +}); + +export const renderAlertNarrativeStepCommonDefinition: BaseStepDefinition< + typeof renderAlertNarrativeInputSchema, + typeof renderAlertNarrativeOutputSchema +> = { + id: 'security.renderAlertNarrative', + label: i18n.translate('xpack.securitySolution.workflows.steps.renderAlertNarrative.label', { + defaultMessage: 'Render Alert Narrative', + }), + description: i18n.translate( + 'xpack.securitySolution.workflows.steps.renderAlertNarrative.description', + { + defaultMessage: + 'Render a human-readable narrative string for an alert based on its event, process, network, and host fields', + } + ), + category: StepCategory.Kibana, + stability: 'tech_preview', + inputSchema: renderAlertNarrativeInputSchema, + outputSchema: renderAlertNarrativeOutputSchema, + documentation: { + details: i18n.translate( + 'xpack.securitySolution.workflows.steps.renderAlertNarrative.documentation.details', + { + defaultMessage: + 'Fetches the alert from Elasticsearch and renders a plain-English narrative string that summarizes the event and (when present) associated process context. The output is designed for use in workflows (e.g., notes, summaries, LLM prompts).', + } + ), + examples: [ + `## Render an alert narrative +\`\`\`yaml +- name: render_alert_narrative + type: security.renderAlertNarrative + with: + alertId: "{{ variables.alert_id }}" + alertIndex: "{{ variables.alert_index }}" +\`\`\``, + ], + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc index 8bb609df8b5b0..637f1783e3680 100644 --- a/x-pack/solutions/security/plugins/security_solution/kibana.jsonc +++ b/x-pack/solutions/security/plugins/security_solution/kibana.jsonc @@ -86,6 +86,7 @@ "serverless", "agentBuilder", "llmTasks", + "workflowsExtensions", "cps", "searchInferenceEndpoints" ], diff --git a/x-pack/solutions/security/plugins/security_solution/moon.yml b/x-pack/solutions/security/plugins/security_solution/moon.yml index fb36abdf314e0..2e94775223f3c 100644 --- a/x-pack/solutions/security/plugins/security_solution/moon.yml +++ b/x-pack/solutions/security/plugins/security_solution/moon.yml @@ -285,12 +285,14 @@ dependsOn: - '@kbn/controls-constants' - '@kbn/anonymization-plugin' - '@kbn/anonymization-common' + - '@kbn/workflows-extensions' - '@kbn/shared-ux-ai-components' - '@kbn/controls-schemas' - '@kbn/search-inference-endpoints' - '@kbn/evals-plugin' - '@kbn/shared-ux-column-presets' - '@kbn/core-overlays-browser' + - '@kbn/workflows-management-plugin' tags: - plugin - prod diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index 9dad3fa8215b1..d88a0d3660473 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -120,13 +120,29 @@ export class Plugin implements IPlugin { + const [coreStart] = await core.getStartServices(); + return registerWorkflowSteps(workflowsExtensions, coreStart); + }) + .catch((error) => { + this.logger.error( + `Error registering security workflow steps: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + } + // Lazily instantiate subPlugins and initialize services const mountDependencies = async (params?: AppMountParameters) => { const { renderApp } = await this.lazyApplicationDependencies(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/types.ts b/x-pack/solutions/security/plugins/security_solution/public/types.ts index f112174799c6f..9728286526b91 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/types.ts @@ -70,6 +70,7 @@ import type { KqlPluginStart } from '@kbn/kql/public'; import type { AgentBuilderPluginStart } from '@kbn/agent-builder-plugin/public'; import type { Logger } from '@kbn/logging'; import type { CPSPluginStart } from '@kbn/cps/public'; +import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; import type { EvalsPublicStart } from '@kbn/evals-plugin/public'; import type { ResolverPluginSetup } from './resolver/types'; import type { Inspect } from '../common/search_strategy'; @@ -120,6 +121,7 @@ export interface SetupPlugins { cases?: CasesPublicSetup; data: DataPublicPluginSetup; discoverShared: DiscoverSharedPublicStart; + workflowsExtensions?: WorkflowsExtensionsPublicPluginSetup; } /** diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/eui_icons.d.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/eui_icons.d.ts new file mode 100644 index 0000000000000..dbacfe2681740 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/eui_icons.d.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +declare module '@elastic/eui/es/components/icon/assets/*' { + import type * as React from 'react'; + + interface SVGRProps { + title?: string; + titleId?: string; + } + export const icon: ({ + title, + titleId, + ...props + }: React.SVGProps & SVGRProps) => React.JSX.Element; + export {}; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts new file mode 100644 index 0000000000000..c63b30e4b05d3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { PublicStepDefinition } from '@kbn/workflows-extensions/public'; +import { buildAlertEntityGraphStepCommonDefinition } from '../../../../common/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step_common'; + +export const buildAlertEntityGraphStepDefinition: PublicStepDefinition = { + ...buildAlertEntityGraphStepCommonDefinition, + icon: React.lazy(() => + import('@elastic/eui/es/components/icon/assets/link') + .then(({ icon }) => ({ default: icon })) + .catch(() => + import('@elastic/eui/es/components/icon/assets/search').then(({ icon }) => ({ + default: icon, + })) + ) + ), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/index.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/index.ts new file mode 100644 index 0000000000000..530fe6cbe6674 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/build_alert_entity_graph_step/index.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 { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/index.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/index.ts new file mode 100644 index 0000000000000..0f53ce718a7a5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/index.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 { registerWorkflowSteps } from './register_workflow_steps'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts new file mode 100644 index 0000000000000..d94ec08351671 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts @@ -0,0 +1,33 @@ +/* + * 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 { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; +import type { CoreStart } from '@kbn/core/public'; +import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; +import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; +import { + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT, +} from '../../../common/constants'; + +/** + * Registers all security workflow steps with the workflowsExtensions plugin + */ +export const registerWorkflowSteps = async ( + workflowsExtensions: WorkflowsExtensionsPublicPluginSetup, + core: CoreStart +): Promise => { + const registerAlertValidationStepsEnabled = await core.featureFlags.getBooleanValue( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ); + + if (registerAlertValidationStepsEnabled) { + workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition); + workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/index.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/index.ts new file mode 100644 index 0000000000000..69289766105da --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/index.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 { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts new file mode 100644 index 0000000000000..c2a459cde2621 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { PublicStepDefinition } from '@kbn/workflows-extensions/public'; +import { renderAlertNarrativeStepCommonDefinition } from '../../../../common/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step_common'; + +export const renderAlertNarrativeStepDefinition: PublicStepDefinition = { + ...renderAlertNarrativeStepCommonDefinition, + icon: React.lazy(() => + import('@elastic/eui/es/components/icon/assets/timeslider') + .then(({ icon }) => ({ default: icon })) + .catch(() => + import('@elastic/eui/es/components/icon/assets/documents').then(({ icon }) => ({ + default: icon, + })) + ) + ), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 712a97c8c0a38..b6cfd8dcfd3a4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -167,6 +167,7 @@ import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_repo import type { TrialCompanionRoutesDeps } from './lib/trial_companion/types'; import { setupAlertsCapabilitiesSwitcher } from './lib/capabilities/alerts_capabilities_switcher'; import { securityAlertsProfileInitializer } from './lib/anonymization'; +import { registerWorkflowSteps } from './workflows/step_types'; import { registerWatchlistMaintainer } from './lib/entity_analytics/watchlists/maintainer/register_watchlist_maintainer'; import { registerEndpointExceptionsRoutes } from './endpoint/routes/endpoint_exceptions_per_policy_opt_in'; import { @@ -814,6 +815,23 @@ export class Plugin implements ISecuritySolutionPlugin { this.registerAgentBuilderAttachmentsAndTools(plugins, core, this.logger); + if (plugins.workflowsExtensions) { + const workflowsExtensions = plugins.workflowsExtensions; + core + .getStartServices() + .then(async ([coreStart]) => { + await registerWorkflowSteps(workflowsExtensions, coreStart); + }) + .catch((error) => { + this.logger.error( + `[RegisterAlertValidationSteps] Error registering alert validation steps: ${error.message}`, + { + error: error.stack, + } + ); + }); + } + setupAlertsCapabilitiesSwitcher({ core, logger: this.logger, diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts index 97501ffd138b3..4f30bed2d0c93 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin_contract.ts @@ -51,6 +51,14 @@ import type { AgentBuilderPluginStart, } from '@kbn/agent-builder-plugin/server'; import type { LlmTasksPluginStart } from '@kbn/llm-tasks-plugin/server'; +import type { + WorkflowsServerPluginSetup, + WorkflowsServerPluginStart, +} from '@kbn/workflows-management-plugin/server'; +import type { + WorkflowsExtensionsServerPluginSetup, + WorkflowsExtensionsServerPluginStart, +} from '@kbn/workflows-extensions/server'; import type { EntityStoreSetupContract, EntityStoreStartContract } from '@kbn/entity-store/server'; import type { SearchInferenceEndpointsPluginSetup } from '@kbn/search-inference-endpoints/server'; import type { ProductFeaturesService } from './lib/product_features_service/product_features_service'; @@ -78,6 +86,8 @@ export interface SecuritySolutionPluginSetupDependencies { kql: KqlServerPluginSetup; share?: SharePluginSetup; agentBuilder?: AgentBuilderPluginSetup; + workflowsManagement?: WorkflowsServerPluginSetup; + workflowsExtensions?: WorkflowsExtensionsServerPluginSetup; entityStore?: EntityStoreSetupContract; searchInferenceEndpoints?: SearchInferenceEndpointsPluginSetup; } @@ -105,6 +115,8 @@ export interface SecuritySolutionPluginStartDependencies { anonymization: AnonymizationPluginStart; llmTasks?: LlmTasksPluginStart; agentBuilder?: AgentBuilderPluginStart; + workflowsManagement?: WorkflowsServerPluginStart; + workflowsExtensions?: WorkflowsExtensionsServerPluginStart; } export interface SecuritySolutionPluginSetup { diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/jest.config.js b/x-pack/solutions/security/plugins/security_solution/server/workflows/jest.config.js new file mode 100644 index 0000000000000..b115d859abcba --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/jest.config.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: ['/x-pack/solutions/security/plugins/security_solution/server/workflows'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/server/workflows', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/solutions/security/plugins/security_solution/server/workflows/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../__mocks__/module_name_map'), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.test.ts new file mode 100644 index 0000000000000..a4933923f7240 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { symmetrizeAliases, expandEntitiesByAliases } from './alias_utils'; +import type { AliasMap } from './alias_utils'; + +describe('alias_utils', () => { + describe('symmetrizeAliases', () => { + it('returns empty map for undefined input', () => { + expect(symmetrizeAliases(undefined)).toEqual(new Map()); + }); + + it('returns empty map for empty object', () => { + expect(symmetrizeAliases({})).toEqual(new Map()); + }); + + it('creates forward mapping from raw config', () => { + const result = symmetrizeAliases({ + 'source.ip': [{ field: 'destination.ip' }], + }); + + expect(result.get('source.ip')).toEqual([{ field: 'destination.ip' }]); + }); + + it('creates reverse mapping automatically', () => { + const result = symmetrizeAliases({ + 'source.ip': [{ field: 'destination.ip', score: 3 }], + }); + + expect(result.get('destination.ip')).toEqual([{ field: 'source.ip', score: 3 }]); + }); + + it('does not create self-referencing aliases', () => { + const result = symmetrizeAliases({ + 'host.name': [{ field: 'host.name' }], + }); + + expect(result.size).toBe(0); + }); + + it('filters out invalid alias entries', () => { + const result = symmetrizeAliases({ + 'source.ip': [ + { field: '' }, + { field: 'destination.ip' }, + null as unknown as { field: string }, + ], + }); + + expect(result.get('source.ip')).toEqual([{ field: 'destination.ip' }]); + }); + + it('preserves scores on alias entries', () => { + const result = symmetrizeAliases({ + 'source.ip': [{ field: 'destination.ip', score: 5 }], + }); + + expect(result.get('source.ip')?.[0]?.score).toBe(5); + }); + + it('does not duplicate reverse mappings when already present', () => { + const result = symmetrizeAliases({ + 'source.ip': [{ field: 'destination.ip' }], + 'destination.ip': [{ field: 'source.ip' }], + }); + + const sourceAliases = result.get('source.ip') ?? []; + const destRefs = sourceAliases.filter((a) => a.field === 'destination.ip'); + expect(destRefs).toHaveLength(1); + }); + }); + + describe('expandEntitiesByAliases', () => { + it('returns original values when no aliases exist', () => { + const aliasMap: AliasMap = new Map(); + const entities = new Map([['host.name', new Set(['h1'])]]); + + const result = expandEntitiesByAliases(entities, aliasMap); + + expect(result.get('host.name')).toEqual(new Set(['h1'])); + expect(result.size).toBe(1); + }); + + it('copies values to alias fields', () => { + const aliasMap: AliasMap = new Map([['source.ip', [{ field: 'destination.ip' }]]]); + const entities = new Map([['source.ip', new Set(['10.0.0.1'])]]); + + const result = expandEntitiesByAliases(entities, aliasMap); + + expect(result.get('source.ip')).toEqual(new Set(['10.0.0.1'])); + expect(result.get('destination.ip')).toEqual(new Set(['10.0.0.1'])); + }); + + it('merges values when alias field already has values', () => { + const aliasMap: AliasMap = new Map([['source.ip', [{ field: 'destination.ip' }]]]); + const entities = new Map([ + ['source.ip', new Set(['10.0.0.1'])], + ['destination.ip', new Set(['10.0.0.2'])], + ]); + + const result = expandEntitiesByAliases(entities, aliasMap); + + expect(result.get('destination.ip')).toEqual(new Set(['10.0.0.2', '10.0.0.1'])); + }); + + it('skips empty value sets', () => { + const aliasMap: AliasMap = new Map([['source.ip', [{ field: 'destination.ip' }]]]); + const entities = new Map([['source.ip', new Set()]]); + + const result = expandEntitiesByAliases(entities, aliasMap); + + expect(result.size).toBe(0); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.ts new file mode 100644 index 0000000000000..cf95f22769138 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/alias_utils.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface AliasEntry { + field: string; + score?: number; +} + +export type AliasMap = Map; + +/** + * Builds a symmetrized alias map from raw config. + * + * Given `{ 'source.ip': [{ field: 'destination.ip' }] }`, the result includes + * both `source.ip -> [destination.ip]` and `destination.ip -> [source.ip]` + * so either side can drive entity expansion and parent-link matching. + */ +export const symmetrizeAliases = (raw: Record | undefined): AliasMap => { + const map: AliasMap = new Map(); + + for (const [from, aliases] of Object.entries(raw ?? {})) { + if (from.length > 0 && Array.isArray(aliases) && aliases.length > 0) { + const cleaned = aliases + .filter((a): a is AliasEntry => typeof a?.field === 'string' && a.field.length > 0) + .filter((a) => a.field !== from); + if (cleaned.length) { + map.set(from, cleaned); + } + } + } + + // Add reverse mappings so that lookups work in both directions. + for (const [from, aliases] of map.entries()) { + for (const alias of aliases) { + const to = alias.field; + const reverse = map.get(to) ?? []; + const hasReverse = reverse.some((r) => r.field === from); + if (!hasReverse) { + reverse.push({ field: from, score: alias.score }); + map.set(to, reverse); + } + } + } + + return map; +}; + +/** + * Expands entity values by copying them to alias fields. + * + * For example, if `source.ip` has alias `destination.ip`, and the input contains + * `source.ip -> { '10.0.0.1' }`, the output will include both + * `source.ip -> { '10.0.0.1' }` and `destination.ip -> { '10.0.0.1' }`. + */ +export const expandEntitiesByAliases = ( + entitiesByField: Map>, + aliasMap: AliasMap +): Map> => { + const out = new Map>(); + + for (const [field, values] of entitiesByField.entries()) { + if (values.size) { + const own = out.get(field) ?? new Set(); + for (const v of values) own.add(v); + out.set(field, own); + + const aliases = aliasMap.get(field) ?? []; + for (const alias of aliases) { + const aliasSet = out.get(alias.field) ?? new Set(); + for (const v of values) aliasSet.add(v); + out.set(alias.field, aliasSet); + } + } + } + + return out; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.test.ts new file mode 100644 index 0000000000000..450a4f1266141 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.test.ts @@ -0,0 +1,316 @@ +/* + * 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 { RelatedAlertsGraphOutput } from './types'; + +jest.mock('./graph_builder', () => ({ + buildRelatedAlertsGraph: jest.fn(), +})); + +jest.mock('./time_window', () => ({ + parseTimeWindowToMs: jest.fn((v: string) => { + const match = v.match(/^(\d+)([hmd])$/); + if (!match) return 3600000; + const [, num, unit] = match; + const multipliers: Record = { h: 3600000, m: 60000, d: 86400000 }; + return Number(num) * (multipliers[unit] ?? 3600000); + }), +})); + +import { + buildAlertEntityGraphInputSchema, + buildAlertEntityGraphStepDefinition, +} from './build_alert_entity_graph_step'; +import { buildRelatedAlertsGraph } from './graph_builder'; +import { parseTimeWindowToMs } from './time_window'; + +const mockBuildGraph = buildRelatedAlertsGraph as jest.MockedFunction< + typeof buildRelatedAlertsGraph +>; +const mockParseWindow = parseTimeWindowToMs as jest.MockedFunction; + +const createMockContext = (input: Record) => ({ + input, + config: {}, + rawInput: input, + contextManager: { + getContext: jest.fn().mockReturnValue({ workflow: { spaceId: 'default' } }), + getScopedEsClient: jest.fn().mockReturnValue({ search: jest.fn() }), + renderInputTemplate: jest.fn(), + getFakeRequest: jest.fn(), + }, + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + abortSignal: new AbortController().signal, + stepId: 'test-step', + stepType: 'security.buildAlertEntityGraph', +}); + +const GRAPH_RESULT: RelatedAlertsGraphOutput = { + nodes: [{ id: 'alert-1' }, { id: 'alert-2' }], + edges: [{ from: 'alert-1', to: 'alert-2', score: 3, label_scores: { host: 2, user: 1 } }], + alerts: [ + { + alert_id: 'alert-1', + alert_index: '.alerts-default', + timestamp: '2025-01-01T00:00:00Z', + rule_name: 'Rule A', + severity: 'high', + }, + { + alert_id: 'alert-2', + alert_index: '.alerts-default', + timestamp: '2025-01-01T01:00:00Z', + rule_name: 'Rule B', + severity: 'medium', + }, + ], + stats: { + depth_reached: 2, + nodes: 2, + edges: 1, + queries: 4, + time_range: { gte: '2025-01-01T00:00:00Z', lte: '2025-01-01T02:00:00Z' }, + }, +}; + +describe('buildAlertEntityGraph step', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('input schema', () => { + it('coerces numeric strings and applies defaults', () => { + const parsed = buildAlertEntityGraphInputSchema.parse({ + alertId: 'abc', + alertIndex: '.internal.alerts-security.alerts-default-000001', + max_alerts: '20', + page_size: '200', + }); + + expect(parsed.max_alerts).toBe(20); + expect(parsed.page_size).toBe(200); + expect(typeof parsed.max_terms_per_query).toBe('number'); + expect(typeof parsed.max_entities_per_field).toBe('number'); + }); + + it('rejects NaN/Infinity so downstream queries cannot emit empty terms', () => { + const badNaN = buildAlertEntityGraphInputSchema.safeParse({ + alertId: 'abc', + alertIndex: '.internal.alerts-security.alerts-default-000001', + max_terms_per_query: NaN, + }); + expect(badNaN.success).toBe(false); + + const badInfinity = buildAlertEntityGraphInputSchema.safeParse({ + alertId: 'abc', + alertIndex: '.internal.alerts-security.alerts-default-000001', + page_size: Infinity, + }); + expect(badInfinity.success).toBe(false); + }); + + it('applies default entity_fields when not provided', () => { + const parsed = buildAlertEntityGraphInputSchema.parse({ + alertId: 'abc', + alertIndex: '.alerts-idx', + }); + expect(parsed.entity_fields).toEqual([ + { field: 'host.name' }, + { field: 'user.name' }, + { field: 'service.name' }, + ]); + }); + + it('applies all numeric defaults', () => { + const parsed = buildAlertEntityGraphInputSchema.parse({ + alertId: 'abc', + alertIndex: '.alerts-idx', + }); + expect(parsed.max_depth).toBe(3); + expect(parsed.max_alerts).toBe(300); + expect(parsed.page_size).toBe(200); + expect(parsed.max_terms_per_query).toBe(500); + expect(parsed.max_entities_per_field).toBe(200); + expect(parsed.min_entity_score).toBe(2); + expect(parsed.include_seed).toBe(true); + expect(parsed.seed_window).toBe('1h'); + expect(parsed.expand_window).toBe('1h'); + }); + }); + + describe('handler', () => { + it('passes parsed input to buildRelatedAlertsGraph and returns the result', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'alert-1', + alertIndex: '.alerts-security.alerts-default', + }); + const context = createMockContext(input); + const result = await buildAlertEntityGraphStepDefinition.handler(context as never); + + expect(result.error).toBeUndefined(); + expect(result.output).toEqual(GRAPH_RESULT); + expect(mockBuildGraph).toHaveBeenCalledTimes(1); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.seed).toEqual({ + alertId: 'alert-1', + alertIndex: '.alerts-security.alerts-default', + }); + expect(params.entityFields).toEqual(['host.name', 'user.name', 'service.name']); + expect(params.maxDepth).toBe(3); + expect(params.maxAlerts).toBe(300); + expect(params.pageSize).toBe(200); + expect(params.includeSeed).toBe(true); + }); + + it('uses the concrete internal index when alertIndex starts with .internal', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'alert-1', + alertIndex: '.internal.alerts-security.alerts-default-000001', + }); + const context = createMockContext(input); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.searchIndex).toBe('.internal.alerts-security.alerts-default-000001'); + }); + + it('uses the public alias when alertIndex is not internal', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'alert-1', + alertIndex: '.alerts-security.alerts-default', + }); + const context = createMockContext(input); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.searchIndex).toBe('.alerts-security.alerts-default'); + }); + + it('passes the scoped ES client to buildRelatedAlertsGraph', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.idx', + }); + const context = createMockContext(input); + const expectedEsClient = context.contextManager.getScopedEsClient(); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + expect(mockBuildGraph.mock.calls[0][0].esClient).toBe(expectedEsClient); + }); + + it('parses seed_window and expand_window through parseTimeWindowToMs', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.idx', + seed_window: '24h', + expand_window: '2h', + }); + const context = createMockContext(input); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + expect(mockParseWindow).toHaveBeenCalledWith('24h'); + expect(mockParseWindow).toHaveBeenCalledWith('2h'); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.seedWindowMs).toBe(24 * 3600000); + expect(params.expandWindowMs).toBe(2 * 3600000); + }); + + it('passes custom entity_fields and extracts field scores and aliases', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.idx', + entity_fields: [ + { field: 'host.name', score: 3 }, + { + field: 'source.ip', + score: 2, + aliases: [{ field: 'destination.ip', score: 1 }], + }, + ], + }); + const context = createMockContext(input); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.entityFields).toContain('host.name'); + expect(params.entityFields).toContain('source.ip'); + expect(params.entityFields).toContain('destination.ip'); + expect(params.entityFieldScores).toEqual({ 'host.name': 3, 'source.ip': 2 }); + expect(params.entityFieldAliases).toEqual({ + 'source.ip': [{ field: 'destination.ip', score: 1 }], + }); + }); + + it('passes ignore_entities and min_entity_score to buildRelatedAlertsGraph', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.idx', + ignore_entities: [{ field: 'user.name', values: ['root', 'SYSTEM'] }], + min_entity_score: 5, + }); + const context = createMockContext(input); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.ignoreEntities).toEqual([{ field: 'user.name', values: ['root', 'SYSTEM'] }]); + expect(params.minEntityScore).toBe(5); + }); + + it('uses the correct space-aware index from context', async () => { + mockBuildGraph.mockResolvedValue(GRAPH_RESULT); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.alerts-security.alerts-custom-space', + }); + const context = createMockContext(input); + context.contextManager.getContext.mockReturnValue({ + workflow: { spaceId: 'custom-space' }, + }); + await buildAlertEntityGraphStepDefinition.handler(context as never); + + const params = mockBuildGraph.mock.calls[0][0]; + expect(params.searchIndex).toBe('.alerts-security.alerts-custom-space'); + }); + + it('returns an error when buildRelatedAlertsGraph throws', async () => { + mockBuildGraph.mockRejectedValue(new Error('graph build failed')); + + const input = buildAlertEntityGraphInputSchema.parse({ + alertId: 'a', + alertIndex: '.idx', + }); + const context = createMockContext(input); + const result = await buildAlertEntityGraphStepDefinition.handler(context as never); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toBe('graph build failed'); + expect(context.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts new file mode 100644 index 0000000000000..c71db26f71cab --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step.ts @@ -0,0 +1,114 @@ +/* + * 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 { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import { buildRelatedAlertsGraph } from './graph_builder'; +import { parseTimeWindowToMs } from './time_window'; +import { + buildAlertEntityGraphStepCommonDefinition, + buildAlertEntityGraphInputSchema, +} from '../../../../common/workflows/step_types/build_alert_entity_graph_step/build_alert_entity_graph_step_common'; + +export { buildAlertEntityGraphInputSchema }; + +const DEFAULT_ENTITY_FIELDS = ['host.name', 'user.name', 'service.name'] as const; +interface EntityFieldConfig { + field: string; + score?: number; + aliases?: Array<{ field: string; score?: number }>; +} +const DEFAULT_ENTITY_FIELD_CONFIG: EntityFieldConfig[] = DEFAULT_ENTITY_FIELDS.map((field) => ({ + field, +})); + +export const buildAlertEntityGraphStepDefinition = createServerStepDefinition({ + ...buildAlertEntityGraphStepCommonDefinition, + handler: async (context) => { + try { + const { + alertId, + alertIndex, + entity_fields, + seed_window, + expand_window, + max_depth, + max_alerts, + page_size, + max_terms_per_query, + max_entities_per_field, + ignore_entities = [], + min_entity_score, + include_seed, + } = context.input; + const searchIndex = alertIndex; + const esClient = context.contextManager.getScopedEsClient(); + + const entityFieldConfigs = ( + entity_fields?.length ? entity_fields : DEFAULT_ENTITY_FIELD_CONFIG + ).filter((f) => typeof f?.field === 'string' && f.field.length > 0); + const entityFields = Array.from( + new Set( + entityFieldConfigs.flatMap((c) => [ + c.field, + ...(Array.isArray(c.aliases) ? c.aliases : []) + .map((a) => a.field) + .filter((f) => typeof f === 'string'), + ]) + ) + ).filter((f) => f.length > 0); + const entityFieldScores: Record = {}; + for (const c of entityFieldConfigs) { + if (typeof c.score === 'number' && Number.isFinite(c.score)) { + entityFieldScores[c.field] = Math.max(entityFieldScores[c.field] ?? -Infinity, c.score); + } + } + + const entityFieldAliases: Record> = {}; + for (const c of entityFieldConfigs) { + const aliases = Array.isArray(c.aliases) ? c.aliases : []; + const cleaned = aliases + .filter( + (a): a is { field: string; score?: number } => + typeof a?.field === 'string' && a.field.length > 0 + ) + .filter((a) => a.field !== c.field); + if (cleaned.length) { + entityFieldAliases[c.field] = cleaned.map((a) => ({ + field: a.field, + score: typeof a.score === 'number' && Number.isFinite(a.score) ? a.score : undefined, + })); + } + } + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId, alertIndex }, + searchIndex, + entityFields, + entityFieldAliases, + seedWindowMs: parseTimeWindowToMs(seed_window), + expandWindowMs: parseTimeWindowToMs(expand_window), + maxDepth: max_depth, + maxAlerts: max_alerts, + pageSize: page_size, + maxTermsPerQuery: max_terms_per_query, + maxEntitiesPerField: max_entities_per_field, + ignoreEntities: ignore_entities, + entityFieldScores, + minEntityScore: min_entity_score, + includeSeed: include_seed, + }); + + return { output: result }; + } catch (error) { + context.logger.error('Failed to get related alerts', error); + return { + error: new Error(error instanceof Error ? error.message : 'Failed to get related alerts'), + }; + } + }, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.test.ts new file mode 100644 index 0000000000000..bd24dc22e683c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { + buildIgnoreMap, + extractEntityValues, + fieldToEntityLabel, + getValuesAtPath, +} from './entity_utils'; + +describe('entity_utils', () => { + describe('fieldToEntityLabel', () => { + it('returns the top-level field segment', () => { + expect(fieldToEntityLabel('host.name')).toBe('host'); + expect(fieldToEntityLabel('user.name')).toBe('user'); + expect(fieldToEntityLabel('service.name')).toBe('service'); + expect(fieldToEntityLabel('source.ip')).toBe('source'); + }); + }); + + describe('getValuesAtPath', () => { + it('walks objects', () => { + expect(getValuesAtPath({ a: { b: 'c' } }, ['a', 'b'])).toEqual(['c']); + }); + + it('walks arrays of objects', () => { + expect(getValuesAtPath({ a: [{ b: 'c1' }, { b: 'c2' }] }, ['a', 'b']).sort()).toEqual([ + 'c1', + 'c2', + ]); + }); + + it('returns empty when path missing', () => { + expect(getValuesAtPath({ a: { b: 'c' } }, ['a', 'x'])).toEqual([]); + }); + }); + + describe('extractEntityValues', () => { + it('extracts trimmed strings and removes ignored values', () => { + const source = { + user: { name: ' root ' }, + host: { name: ['host-1', ' host-2 '] }, + }; + + const ignoreMap = buildIgnoreMap([{ field: 'user.name', values: ['root'] }]); + + expect(Array.from(extractEntityValues(source, 'user.name', ignoreMap))).toEqual([]); + expect(Array.from(extractEntityValues(source, 'host.name', ignoreMap)).sort()).toEqual([ + 'host-1', + 'host-2', + ]); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.ts new file mode 100644 index 0000000000000..492ff7c9f724d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/entity_utils.ts @@ -0,0 +1,79 @@ +/* + * 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 type IgnoreEntitiesConfig = Array<{ field: string; values: string[] }>; + +export const fieldToEntityLabel = (field: string): string => { + const top = field.split('.')[0]; + return top || field; +}; + +export const buildIgnoreMap = (ignoreEntities: IgnoreEntitiesConfig): Map> => { + const ignoreMap = new Map>(); + for (const entry of ignoreEntities) { + const existing = ignoreMap.get(entry.field); + if (!existing) { + ignoreMap.set(entry.field, new Set(entry.values)); + } else { + for (const v of entry.values) existing.add(v); + } + } + return ignoreMap; +}; + +export const getValuesAtPath = (obj: unknown, path: readonly string[]): unknown[] => { + if (obj == null) return []; + if (path.length === 0) return Array.isArray(obj) ? obj : [obj]; + + // If the current value is an array, apply the same path to each item. + if (Array.isArray(obj)) { + return obj.flatMap((item) => getValuesAtPath(item, path)); + } + + if (typeof obj !== 'object') return []; + + const [head, ...tail] = path; + const next = (obj as Record)[head]; + return getValuesAtPath(next, tail); +}; + +export const extractEntityValues = ( + source: Record | undefined, + field: string, + ignoreMap: Map> +): Set => { + const values = getValuesAtPath(source, field.split('.')) + .filter((v): v is string => typeof v === 'string') + .map((v) => v.trim()) + .filter((v) => v.length > 0); + + const ignore = ignoreMap.get(field); + const out = new Set(); + for (const v of values) { + if (!ignore?.has(v)) { + out.add(v); + } + } + return out; +}; + +export const addEntitiesWithLimit = (params: { + known: Map>; + field: string; + values: Set; + maxEntitiesPerField: number; +}) => { + const { known, field, values, maxEntitiesPerField } = params; + if (values.size === 0) return; + + const existing = known.get(field) ?? new Set(); + for (const v of values) { + if (existing.size >= maxEntitiesPerField) break; + existing.add(v); + } + known.set(field, existing); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.test.ts new file mode 100644 index 0000000000000..2edf8535006f5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.test.ts @@ -0,0 +1,569 @@ +/* + * 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 { buildRelatedAlertsGraph } from './graph_builder'; +import type { DetectionAlert800 } from '../../../../common/api/detection_engine/model/alerts'; +import type { EsSearchClient, EsSearchResponse } from './types'; + +/** Test doc: _source is a partial alert shape; we assert to DetectionAlert800 for type compatibility */ +interface Doc { + _id: string; + _index: string; + _source: DetectionAlert800; +} + +/** Cast partial _source to DetectionAlert800 for test fixtures */ +const src = (s: Record): DetectionAlert800 => s as DetectionAlert800; + +const iso = (ms: number) => new Date(ms).toISOString(); + +interface TermsClause { + terms: Record; +} +type FilterClause = Record; + +interface SearchRequest { + index: string; + size?: number; + query?: { bool?: { filter?: FilterClause[]; must_not?: FilterClause[] } }; + search_after?: [string, string]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const makeEsClient = (params: { seedIndex: string; seedId: string; docs: Doc[] }) => { + const { seedIndex, seedId, docs } = params; + + return { + search: jest.fn(async (req: Record) => { + const typedReq = req as unknown as SearchRequest; + // Seed fetch + if (typedReq.index === seedIndex) { + const hit = docs.find((d) => d._index === seedIndex && d._id === seedId); + return { hits: { hits: hit ? [hit] : [] } } as unknown as EsSearchResponse; + } + + // Search queries + const filters = typedReq.query?.bool?.filter ?? []; + + let range: { gte: string; lte: string } | undefined; + for (const f of filters) { + if (isRecord(f)) { + const rangeObj = f.range; + if (isRecord(rangeObj)) { + const timestampRange = rangeObj['@timestamp']; + if ( + isRecord(timestampRange) && + typeof timestampRange.gte === 'string' && + typeof timestampRange.lte === 'string' + ) { + range = timestampRange as { gte: string; lte: string }; + break; + } + } + } + } + if (!range) { + throw new Error('Expected @timestamp range filter in query'); + } + const gte = Date.parse(range.gte); + const lte = Date.parse(range.lte); + + let should: TermsClause[] = []; + for (const f of filters) { + if (isRecord(f)) { + const boolObj = f.bool; + if (isRecord(boolObj)) { + const shouldArray = boolObj.should; + if (Array.isArray(shouldArray)) { + should = shouldArray as TermsClause[]; + break; + } + } + } + } + const mustNot = typedReq.query?.bool?.must_not ?? []; + + const after = typedReq.search_after as [string, string] | undefined; + + let hits = docs + .filter((d) => d._index === typedReq.index) + .filter((d) => { + const ts = d._source['@timestamp']; + const ms = typeof ts === 'string' ? Date.parse(ts) : NaN; + return Number.isFinite(ms) && ms >= gte && ms <= lte; + }) + .filter((d) => { + // OR of terms clauses + if (!should.length) return false; + return should.some((clause) => { + if (!isRecord(clause)) return false; + const termsObj = clause.terms; + if (!isRecord(termsObj)) return false; + const field = Object.keys(termsObj)[0]; + if (!field) return false; + const values = termsObj[field]; + if (!Array.isArray(values)) return false; + const fieldParts = field.split('.'); + let v: unknown = d._source; + for (const part of fieldParts) { + if (!isRecord(v)) { + v = undefined; + break; + } + v = v[part]; + } + if (typeof v === 'string') return values.includes(v); + if (Array.isArray(v)) return v.some((x) => typeof x === 'string' && values.includes(x)); + return false; + }); + }) + .filter((d) => { + // must_not terms + return !mustNot.some((clause) => { + if (!isRecord(clause) || !isRecord(clause.terms)) return false; + const terms = clause.terms as Record; + const field = Object.keys(terms)[0]; + if (!field) return false; + const values = terms[field] ?? []; + const fieldParts = field.split('.'); + let v: unknown = d._source; + for (const part of fieldParts) { + if (!isRecord(v)) { + v = undefined; + break; + } + v = v[part]; + } + if (typeof v === 'string') return values.includes(v); + if (Array.isArray(v)) return v.some((x) => typeof x === 'string' && values.includes(x)); + return false; + }); + }) + .sort((a, b) => { + const tsa = a._source['@timestamp']; + const tsb = b._source['@timestamp']; + const ta = typeof tsa === 'string' ? Date.parse(tsa) : 0; + const tb = typeof tsb === 'string' ? Date.parse(tsb) : 0; + return ta === tb ? a._id.localeCompare(b._id) : ta - tb; + }); + + if (after) { + const [afterTs, afterId] = after; + hits = hits.filter((d) => { + const ts = d._source['@timestamp']; + if (typeof ts !== 'string') return false; + return ts > afterTs || (ts === afterTs && d._id > afterId); + }); + } + + const size = typedReq.size ?? 10; + const page = hits.slice(0, size).map((h) => ({ + ...h, + sort: [h._source['@timestamp'], h._id], + })); + + return { hits: { hits: page } } as unknown as EsSearchResponse; + }), + } as unknown as EsSearchClient; +}; + +describe('buildRelatedAlertsGraph', () => { + it('expands via entity-delta and time-delta and builds traversal edges', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + host: { name: 'host-1' }, + }), + }, + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 50 * 60 * 1000), // within seed window + host: { name: 'host-1' }, + kibana: { alert: { rule: { name: 'rule-b' } } }, + }), + }, + { + _id: 'C', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 80 * 60 * 1000), // outside seed window, inside expanded window + host: { name: 'host-1' }, + kibana: { alert: { rule: { name: 'rule-c' } } }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['host.name'], + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 3, + maxAlerts: 10, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['B', 'C']); + expect(result.edges).toEqual( + expect.arrayContaining([{ from: 'B', to: 'C', score: 1, label_scores: { host: 1 } }]) + ); + expect(result.stats?.depth_reached).toBeGreaterThanOrEqual(1); + }); + + it('links alerts via min_entity_score when configured', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + host: { name: 'host-1' }, + user: { name: 'u1', id: 'uid-1' }, + process: { entity_id: 'p1' }, + source: { ip: '1.1.1.1' }, + destination: { ip: '2.2.2.2' }, + }), + }, + // host only => score 2 (below threshold 4) + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 5 * 60 * 1000), + host: { name: 'host-1' }, + }), + }, + // host + user => 2 + 2 = 4 (meets threshold) + { + _id: 'C', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 10 * 60 * 1000), + host: { name: 'host-1' }, + user: { name: 'u1' }, + }), + }, + // process.entity_id only => 5 (meets threshold) + { + _id: 'D', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 15 * 60 * 1000), + process: { entity_id: 'p1' }, + }), + }, + // source + destination => 1 + 1 = 2 (below threshold) + { + _id: 'E', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 20 * 60 * 1000), + source: { ip: '1.1.1.1' }, + destination: { ip: '2.2.2.2' }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['host.name', 'user.name', 'process.entity_id', 'source.ip', 'destination.ip'], + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { + 'process.entity_id': 5, + 'host.name': 2, + 'user.name': 2, + 'source.ip': 1, + 'destination.ip': 1, + }, + minEntityScore: 4, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['A', 'C', 'D']); + expect(result.edges).toEqual( + expect.arrayContaining([ + { from: 'A', to: 'C', score: 4, label_scores: { host: 2, user: 2 } }, + { from: 'A', to: 'D', score: 5, label_scores: { process: 5 } }, + ]) + ); + }); + + it('does not generate empty `terms` queries when numeric inputs are NaN', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + host: { name: 'host-1' }, + }), + }, + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 10 * 60 * 1000), + host: { name: 'host-1' }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['host.name'], + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 1, + maxAlerts: 10, + pageSize: 100, + // simulate bad upstream values (this previously produced `terms: []`) + maxTermsPerQuery: Number('nope'), + maxEntitiesPerField: Number('nope'), + ignoreEntities: [], + entityFieldScores: { 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + // Verify we found the related alert (i.e. the query didn't become impossible to match). + expect(result.nodes.map((n) => n.id)).toEqual(['B']); + + // Verify the query the ES client received had non-empty `terms` arrays. + const calls = (esClient.search as jest.Mock).mock.calls; + const searchCall = calls.find(([req]) => (req as SearchRequest).index === searchIndex); + expect(searchCall).toBeTruthy(); + const typedReq = searchCall?.[0] as SearchRequest | undefined; + + let should: TermsClause[] = []; + for (const f of typedReq?.query?.bool?.filter ?? []) { + if (isRecord(f)) { + const boolObj = f.bool; + if (isRecord(boolObj)) { + const shouldArray = boolObj.should; + if (Array.isArray(shouldArray)) { + should = shouldArray as TermsClause[]; + break; + } + } + } + } + + expect(should.length).toBeGreaterThan(0); + const firstTerms = should[0]?.terms?.['host.name']; + expect(firstTerms).toEqual(['host-1']); + }); + + it('does not exclude alerts just because they contain ignored entity values', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + process: { entity_id: 'p1' }, + user: { name: 'u1' }, + }), + }, + // This alert contains an ignored value (user.name=root) but also matches strongly via process.entity_id. + // It should still be eligible to link. + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 5 * 60 * 1000), + process: { entity_id: 'p1' }, + user: { name: 'root' }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['process.entity_id', 'user.name'], + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 1, + maxAlerts: 10, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [{ field: 'user.name', values: ['root'] }], + entityFieldScores: { 'process.entity_id': 5, 'user.name': 2 }, + minEntityScore: 4, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['A', 'B']); + expect(result.edges).toEqual( + expect.arrayContaining([{ from: 'A', to: 'B', score: 5, label_scores: { process: 5 } }]) + ); + }); + + it('links alerts via aliased entity fields (e.g. source.ip -> destination.ip)', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + source: { ip: '10.0.0.10' }, + }), + }, + // Does NOT share source.ip, but does share the seed source.ip as destination.ip. + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 5 * 60 * 1000), + destination: { ip: '10.0.0.10' }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['source.ip', 'destination.ip'], + entityFieldAliases: { + 'source.ip': [{ field: 'destination.ip', score: 3 }], + }, + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 1, + maxAlerts: 10, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'source.ip': 1, 'destination.ip': 1 }, + minEntityScore: 3, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['A', 'B']); + expect(result.edges).toEqual( + expect.arrayContaining([{ from: 'A', to: 'B', score: 3, label_scores: { destination: 3 } }]) + ); + }); + + it('ignore_entities only ignores configured values (does not ignore the whole field)', async () => { + const t0 = Date.UTC(2026, 0, 1, 0, 0, 0); + const seedIndex = 'seed-index'; + const searchIndex = 'alerts-index'; + + const docs: Doc[] = [ + { + _id: 'A', + _index: seedIndex, + _source: src({ + '@timestamp': iso(t0), + user: { name: 'patyk' }, + }), + }, + { + _id: 'B', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 5 * 60 * 1000), + user: { name: 'patyk' }, + }), + }, + { + _id: 'C', + _index: searchIndex, + _source: src({ + '@timestamp': iso(t0 + 10 * 60 * 1000), + user: { name: 'root' }, + }), + }, + ]; + + const esClient = makeEsClient({ seedIndex, seedId: 'A', docs }); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: seedIndex }, + searchIndex, + entityFields: ['user.name'], + seedWindowMs: 60 * 60 * 1000, + expandWindowMs: 60 * 60 * 1000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [{ field: 'user.name', values: ['root'] }], + entityFieldScores: { 'user.name': 2 }, + minEntityScore: 2, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['A', 'B']); + expect(result.edges).toEqual( + expect.arrayContaining([{ from: 'A', to: 'B', score: 2, label_scores: { user: 2 } }]) + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.ts new file mode 100644 index 0000000000000..a2370c2327930 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/graph_builder.ts @@ -0,0 +1,465 @@ +/* + * 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 { addEntitiesWithLimit, buildIgnoreMap, extractEntityValues } from './entity_utils'; +import { buildEntityShouldClauses } from './query_utils'; +import { clampPositiveInt, computePageSize, toIso } from './number_utils'; +import { symmetrizeAliases, expandEntitiesByAliases } from './alias_utils'; +import { computeParentLinks } from './scoring'; +import { mergeEntities, indexEntitiesForAlert, addTraversalEdges } from './traversal_utils'; +import { searchWindow } from './search_window'; +import type { + AlertMeta, + EsHit, + EsSearchClient, + EdgeAccumulator, + ScoringConfig, + RelatedAlertsGraphOutput, +} from './types'; + +// ── Public parameter interface ────────────────────────────────────────────── + +export interface BuildRelatedAlertsGraphParams { + esClient: EsSearchClient; + seed: { alertId: string; alertIndex: string }; + searchIndex: string; + entityFields: string[]; + /** + * Optional mapping of "field -> alias fields". When a seed alert has a value for `field`, + * we also search for alerts with that same value in each alias field. + * + * This supports cases like lateral movement where `source.ip` in one alert can correspond to + * `destination.ip` in another. + */ + entityFieldAliases?: Record>; + seedWindowMs: number; + expandWindowMs: number; + maxDepth: number; + maxAlerts: number; + pageSize: number; + maxTermsPerQuery: number; + maxEntitiesPerField: number; + ignoreEntities: Array<{ field: string; values: string[] }>; + /** + * Per-field score overrides. + * Keys are entity field names (e.g. "host.name", "process.entity_id"). + */ + entityFieldScores?: Record; + /** + * Link alerts when the sum of per-label scores meets/exceeds this threshold. + */ + minEntityScore: number; + includeSeed: boolean; +} + +// ── Input sanitization ────────────────────────────────────────────────────── + +const sanitizeParams = (params: BuildRelatedAlertsGraphParams) => { + // Workflow step inputs may arrive as non-numbers (or NaN). + // JSON serialization of NaN becomes `null`, which Elasticsearch rejects. + const maxAlerts = clampPositiveInt(params.maxAlerts, 300); + const pageSize = clampPositiveInt(params.pageSize, 200); + const maxTermsPerQuery = clampPositiveInt(params.maxTermsPerQuery, 500); + const maxEntitiesPerField = clampPositiveInt(params.maxEntitiesPerField, 200); + + const ignoreMap = buildIgnoreMap( + Array.isArray(params.ignoreEntities) ? params.ignoreEntities : [] + ); + + const minEntityScore = + typeof params.minEntityScore === 'number' && + Number.isFinite(params.minEntityScore) && + params.minEntityScore > 0 + ? params.minEntityScore + : 2; + + const entityFieldScores = new Map( + Object.entries(params.entityFieldScores ?? {}).filter( + ([, v]) => typeof v === 'number' && Number.isFinite(v) + ) + ); + + const aliasMap = symmetrizeAliases(params.entityFieldAliases); + + const scoring: ScoringConfig = { + minEntityScore, + entityFieldScores, + defaultScorePerField: 1, + }; + + return { + maxAlerts, + pageSize, + maxTermsPerQuery, + maxEntitiesPerField, + ignoreMap, + aliasMap, + scoring, + }; +}; + +// ── Seed alert fetching ───────────────────────────────────────────────────── + +const fetchSeedAlert = async ( + esClient: EsSearchClient, + seed: { alertId: string; alertIndex: string }, + sourceFields: string[] +) => { + const seedResponse = await esClient.search({ + index: seed.alertIndex, + expand_wildcards: ['open', 'hidden'], + size: 1, + _source: sourceFields, + query: { term: { _id: seed.alertId } }, + }); + + const seedHit = seedResponse.hits.hits[0]; + if (!seedHit?._id) { + throw new Error(`Alert with ID ${seed.alertId} not found`); + } + + const seedSource = seedHit._source; + const seedTimestamp = seedSource?.['@timestamp']; + const seedTsMs = typeof seedTimestamp === 'string' ? Date.parse(seedTimestamp) : NaN; + if (!Number.isFinite(seedTsMs)) { + throw new Error(`Seed alert ${seed.alertId} does not have a valid @timestamp`); + } + + return { seedHit, seedSource, seedTimestamp, seedTsMs }; +}; + +// ── Alert metadata extraction ─────────────────────────────────────────────── + +const extractAlertMeta = (id: string, hit: EsHit): AlertMeta => { + const source = hit._source as Record | undefined; + const ts = source?.['@timestamp']; + const tsMs = typeof ts === 'string' ? Date.parse(ts) : NaN; + + return { + alert_id: id, + alert_index: hit._index, + timestamp: typeof ts === 'string' ? ts : undefined, + rule_name: source?.['kibana.alert.rule.name'] as string | undefined, + severity: source?.['kibana.alert.severity'] as string | undefined, + ts_ms: Number.isFinite(tsMs) ? tsMs : undefined, + }; +}; + +// ── Output formatting ─────────────────────────────────────────────────────── + +const formatOutput = (params: { + nodesInternal: Set; + edgesByKey: EdgeAccumulator; + alertMetaById: Map; + seedId: string; + includeSeed: boolean; + depthReached: number; + queriesRef: { queries: number }; + searchedFromMs: number; + searchedToMs: number; +}): RelatedAlertsGraphOutput => { + const { + nodesInternal, + edgesByKey, + alertMetaById, + seedId, + includeSeed, + depthReached, + queriesRef, + searchedFromMs, + searchedToMs, + } = params; + + const nodesFiltered = includeSeed + ? nodesInternal + : new Set(Array.from(nodesInternal).filter((id) => id !== seedId)); + + const alertsSorted = Array.from(alertMetaById.values()) + .filter((m) => nodesFiltered.has(m.alert_id)) + .sort((a, b) => (a.ts_ms ?? 0) - (b.ts_ms ?? 0)); + + const nodes = alertsSorted.map((m) => ({ id: m.alert_id })); + const edges = Array.from(edgesByKey.values()) + .filter((e) => nodesFiltered.has(e.from) && nodesFiltered.has(e.to)) + .map((e) => ({ + from: e.from, + to: e.to, + score: e.score, + label_scores: Object.fromEntries( + Array.from(e.labelScores.entries()).sort(([a], [b]) => a.localeCompare(b)) + ), + })) + .sort((a, b) => (a.from === b.from ? a.to.localeCompare(b.to) : a.from.localeCompare(b.from))); + + return { + nodes, + edges, + alerts: alertsSorted.map(({ ts_ms: _tsMs, ...rest }) => rest), + stats: { + depth_reached: depthReached, + nodes: nodes.length, + edges: edges.length, + queries: queriesRef.queries, + time_range: { gte: toIso(searchedFromMs), lte: toIso(searchedToMs) }, + }, + }; +}; + +// ── Main orchestrator ─────────────────────────────────────────────────────── + +export const buildRelatedAlertsGraph = async ( + params: BuildRelatedAlertsGraphParams +): Promise => { + const { + esClient, + seed, + searchIndex, + entityFields, + seedWindowMs, + expandWindowMs, + maxDepth, + includeSeed, + } = params; + const { + maxAlerts, + pageSize, + maxTermsPerQuery, + maxEntitiesPerField, + ignoreMap, + aliasMap, + scoring, + } = sanitizeParams(params); + + const sourceFields = Array.from( + new Set([ + '@timestamp', + 'kibana.alert.rule.name', + 'kibana.alert.severity', + ...entityFields, + ]) + ); + + // ── Step 1: Fetch the seed alert ──────────────────────────────────────── + + const { seedHit, seedSource, seedTimestamp, seedTsMs } = await fetchSeedAlert( + esClient, + seed, + sourceFields + ); + + // ── Step 2: Initialize traversal state ────────────────────────────────── + + const seenAlertIds = new Set([seed.alertId]); + const alertMetaById = new Map(); + const nodesInternal = new Set([seed.alertId]); + const edgesByKey: EdgeAccumulator = new Map(); + const entityToAlertIds = new Map>(); + const knownEntitiesByField = new Map>(); + + // Extract entities from the seed alert. + const seedEntities = new Map>(); + for (const field of entityFields) { + const values = extractEntityValues(seedSource, field, ignoreMap); + seedEntities.set(field, values); + addEntitiesWithLimit({ known: knownEntitiesByField, field, values, maxEntitiesPerField }); + } + + alertMetaById.set(seed.alertId, { + alert_id: seed.alertId, + alert_index: seedHit._index, + timestamp: typeof seedTimestamp === 'string' ? seedTimestamp : undefined, + rule_name: (seedSource as Record | undefined)?.['kibana.alert.rule.name'] as + | string + | undefined, + severity: (seedSource as Record | undefined)?.['kibana.alert.severity'] as + | string + | undefined, + ts_ms: seedTsMs, + }); + + indexEntitiesForAlert({ entityToAlertIds, alertId: seed.alertId, entities: seedEntities }); + + // ── Step 3: Early exit if the seed has no usable entities ─────────────── + + const hasAnyEntity = Array.from(knownEntitiesByField.values()).some((s) => s.size > 0); + if (!hasAnyEntity) { + const nodes = includeSeed ? [{ id: seed.alertId }] : []; + return { + nodes, + edges: [], + alerts: includeSeed + ? [ + { + alert_id: seed.alertId, + alert_index: seedHit._index, + timestamp: typeof seedTimestamp === 'string' ? seedTimestamp : undefined, + }, + ] + : [], + stats: { + depth_reached: 0, + nodes: nodes.length, + edges: 0, + queries: 1, + time_range: { gte: toIso(seedTsMs), lte: toIso(seedTsMs) }, + }, + }; + } + + // ── Step 4: BFS expansion loop ────────────────────────────────────────── + + let minTs = seedTsMs; + let maxTs = seedTsMs; + let searchedFromMs = seedTsMs - seedWindowMs; + let searchedToMs = seedTsMs + seedWindowMs; + let frontierEntitiesByField = new Map>(seedEntities); + let frontierAlertIds = new Set([seed.alertId]); + const queriesRef = { queries: 1 }; + let depthReached = 0; + const stop = () => seenAlertIds.size >= maxAlerts; + + for (let depth = 1; depth <= maxDepth; depth++) { + if (stop()) break; + + // Compute expanded time bounds for this depth. + const expandedFromMs = minTs - expandWindowMs; + const expandedToMs = maxTs + expandWindowMs; + + // Determine time slices that haven't been searched yet. + const timeDeltas: Array<{ gte: number; lte: number }> = []; + if (expandedFromMs < searchedFromMs) { + timeDeltas.push({ gte: expandedFromMs, lte: searchedFromMs }); + } + if (expandedToMs > searchedToMs) { + timeDeltas.push({ gte: searchedToMs, lte: expandedToMs }); + } + + const frontierShould = buildEntityShouldClauses({ + entitiesByField: expandEntitiesByAliases(frontierEntitiesByField, aliasMap), + maxTermsPerQuery, + }); + + if (!frontierShould.length && !timeDeltas.length) break; + + // Accumulators for this depth level. + const newlyDiscoveredEntitiesByField = new Map>(); + const newlyDiscoveredAlertIds: string[] = []; + + /** Processes a single ES hit: validates, scores, and records the alert. */ + const processHit = (hit: EsHit, parentCandidates: Set) => { + const id = hit?._id; + if (!id || seenAlertIds.has(id)) return; + + // Extract entities for scoring. + const entitiesForAlert = new Map>(); + for (const field of entityFields) { + entitiesForAlert.set(field, extractEntityValues(hit._source, field, ignoreMap)); + } + + // Compute parent links based on shared entities. + const parentLinks = computeParentLinks({ + entityToAlertIds, + parentCandidates, + childEntities: entitiesForAlert, + aliasesByField: aliasMap, + scoring, + }); + + if (parentLinks.size === 0) return; + + // Accept the alert. + seenAlertIds.add(id); + nodesInternal.add(id); + newlyDiscoveredAlertIds.push(id); + + const meta = extractAlertMeta(id, hit); + if (meta.ts_ms != null) { + minTs = Math.min(minTs, meta.ts_ms); + maxTs = Math.max(maxTs, meta.ts_ms); + } + alertMetaById.set(id, meta); + + // Record newly discovered entities for future expansion. + for (const [field, values] of entitiesForAlert.entries()) { + const set = newlyDiscoveredEntitiesByField.get(field) ?? new Set(); + for (const v of values) set.add(v); + newlyDiscoveredEntitiesByField.set(field, set); + } + + addTraversalEdges({ edgesByKey, childId: id, parentLinks }); + indexEntitiesForAlert({ entityToAlertIds, alertId: id, entities: entitiesForAlert }); + }; + + // 4a) Entity-delta search: new entities over the already-searched time range. + if (frontierShould.length) { + await searchWindow({ + esClient, + index: searchIndex, + gteMs: searchedFromMs, + lteMs: searchedToMs, + shouldClauses: frontierShould, + sourceFields, + pageSize: computePageSize(pageSize, maxAlerts - seenAlertIds.size, 200), + onHit: (hit) => processHit(hit, frontierAlertIds), + stop, + queriesRef, + }); + } + + // 4b) Time-delta search: expanded time slices over all known entities. + if (timeDeltas.length && !stop()) { + const knownShould = buildEntityShouldClauses({ + entitiesByField: expandEntitiesByAliases(knownEntitiesByField, aliasMap), + maxTermsPerQuery, + }); + + for (const delta of timeDeltas) { + if (!knownShould.length || stop()) break; + await searchWindow({ + esClient, + index: searchIndex, + gteMs: delta.gte, + lteMs: delta.lte, + shouldClauses: knownShould, + sourceFields, + pageSize: computePageSize(pageSize, maxAlerts - seenAlertIds.size, 200), + onHit: (hit) => processHit(hit, nodesInternal), + stop, + queriesRef, + }); + } + } + + if (!newlyDiscoveredAlertIds.length) break; + depthReached = depth; + + // Update frontier for the next round. + frontierAlertIds = new Set(newlyDiscoveredAlertIds); + frontierEntitiesByField = mergeEntities({ + known: knownEntitiesByField, + added: newlyDiscoveredEntitiesByField, + maxEntitiesPerField, + }); + + searchedFromMs = Math.min(searchedFromMs, expandedFromMs); + searchedToMs = Math.max(searchedToMs, expandedToMs); + } + + // ── Step 5: Format and return the output ──────────────────────────────── + + return formatOutput({ + nodesInternal, + edgesByKey, + alertMetaById, + seedId: seed.alertId, + includeSeed, + depthReached, + queriesRef, + searchedFromMs, + searchedToMs, + }); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/index.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/index.ts new file mode 100644 index 0000000000000..530fe6cbe6674 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/index.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 { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.integration.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.integration.test.ts new file mode 100644 index 0000000000000..82e5abcea0be4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.integration.test.ts @@ -0,0 +1,378 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { ToolingLog } from '@kbn/tooling-log'; +import type { EsTestCluster } from '@kbn/test'; +import { createTestEsCluster } from '@kbn/test'; +import { buildRelatedAlertsGraph } from '../graph_builder'; +import type { EsSearchClient } from '../types'; + +const TEST_INDEX = 'test-alerts'; + +const iso = (ms: number): string => new Date(ms).toISOString(); + +/** Wraps the raw `@elastic/elasticsearch` Client as our minimal `EsSearchClient` interface. */ +const wrapClient = (client: Client): EsSearchClient => ({ + search: async (request: Record) => { + const response = await client.search(request); + return { + hits: { + hits: response.hits.hits.map((h) => ({ + _id: h._id, + _index: h._index, + _source: h._source ?? undefined, + sort: h.sort, + })), + }, + }; + }, +}); + +describe('buildRelatedAlertsGraph — integration', () => { + let esServer: EsTestCluster; + let rawClient: Client; + let esClient: EsSearchClient; + + // Baseline timestamp: 2026-06-01T12:00:00Z + const T0 = Date.UTC(2026, 5, 1, 12, 0, 0); + + beforeAll(async () => { + jest.setTimeout(120_000); + + esServer = createTestEsCluster({ + log: new ToolingLog({ writeTo: process.stdout, level: 'info' }), + }); + await esServer.start(); + rawClient = esServer.getClient(); + esClient = wrapClient(rawClient); + }); + + afterAll(async () => { + await esServer?.stop(); + }); + + beforeEach(async () => { + // Clean up the test index before each test. + await rawClient.indices.delete({ index: TEST_INDEX, ignore_unavailable: true }).catch(() => {}); + }); + + /** + * Helper: indexes a batch of alert documents with explicit `_id` values, + * creates the index with a minimal mapping, and waits for refresh. + */ + const indexAlerts = async ( + alerts: Array<{ + _id: string; + '@timestamp': string; + [key: string]: unknown; + }> + ) => { + // Create the index with a mapping that supports our sort fields. + await rawClient.indices.create({ + index: TEST_INDEX, + mappings: { + properties: { + '@timestamp': { type: 'date' }, + 'kibana.alert.uuid': { type: 'keyword' }, + 'kibana.alert.rule.name': { type: 'keyword' }, + 'kibana.alert.severity': { type: 'keyword' }, + host: { + properties: { + name: { type: 'keyword' }, + }, + }, + user: { + properties: { + name: { type: 'keyword' }, + }, + }, + source: { + properties: { + ip: { type: 'keyword' }, + }, + }, + destination: { + properties: { + ip: { type: 'keyword' }, + }, + }, + process: { + properties: { + entity_id: { type: 'keyword' }, + }, + }, + }, + }, + }); + + const body = alerts.flatMap((alert) => { + const { _id, ...doc } = alert; + return [{ index: { _index: TEST_INDEX, _id } }, { 'kibana.alert.uuid': _id, ...doc }]; + }); + + await rawClient.bulk({ body, refresh: 'wait_for' }); + }; + + it('discovers related alerts via shared host.name and builds correct edges', async () => { + // Seed (A) --host.name=h1--> B --host.name=h1--> C + await indexAlerts([ + { _id: 'A', '@timestamp': iso(T0), host: { name: 'h1' }, user: { name: 'u1' } }, + { + _id: 'B', + '@timestamp': iso(T0 + 10 * 60_000), + host: { name: 'h1' }, + user: { name: 'u1' }, + }, + { + _id: 'C', + '@timestamp': iso(T0 + 20 * 60_000), + host: { name: 'h1' }, + user: { name: 'u2' }, + }, + // D shares nothing with A — should NOT appear. + { + _id: 'D', + '@timestamp': iso(T0 + 30 * 60_000), + host: { name: 'h-unrelated' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 3, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1, 'user.name': 1 }, + minEntityScore: 1, + includeSeed: true, + }); + + // Nodes: A (seed), B (host.name=h1), C (host.name=h1). D excluded. + const nodeIds = result.nodes.map((n) => n.id).sort(); + expect(nodeIds).toEqual(['A', 'B', 'C']); + + // D should not appear anywhere. + expect(nodeIds).not.toContain('D'); + + // Edges should connect related alerts. + expect(result.edges.length).toBeGreaterThanOrEqual(1); + + // Every edge should reference only nodes in the graph. + const nodeSet = new Set(nodeIds); + for (const edge of result.edges) { + expect(nodeSet.has(edge.from)).toBe(true); + expect(nodeSet.has(edge.to)).toBe(true); + expect(edge.score).toBeGreaterThan(0); + } + + // Stats should reflect the traversal. + expect(result.stats?.nodes).toBe(3); + expect(result.stats?.queries).toBeGreaterThanOrEqual(2); // seed fetch + at least 1 search + expect(result.stats?.depth_reached).toBeGreaterThanOrEqual(1); + }); + + it('respects minEntityScore threshold to exclude weakly-linked alerts', async () => { + await indexAlerts([ + { + _id: 'SEED', + '@timestamp': iso(T0), + host: { name: 'h1' }, + user: { name: 'u1' }, + process: { entity_id: 'p1' }, + }, + // STRONG: shares host + user (score 2+2 = 4, meets threshold) + { + _id: 'STRONG', + '@timestamp': iso(T0 + 5 * 60_000), + host: { name: 'h1' }, + user: { name: 'u1' }, + }, + // WEAK: shares only host (score 2, below threshold of 4) + { + _id: 'WEAK', + '@timestamp': iso(T0 + 10 * 60_000), + host: { name: 'h1' }, + }, + // PROCESS: shares only process.entity_id (score 5, meets threshold) + { + _id: 'PROCESS', + '@timestamp': iso(T0 + 15 * 60_000), + process: { entity_id: 'p1' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'SEED', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name', 'process.entity_id'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 2, 'user.name': 2, 'process.entity_id': 5 }, + minEntityScore: 4, + includeSeed: false, + }); + + const nodeIds = result.nodes.map((n) => n.id).sort(); + + // STRONG (4) and PROCESS (5) meet threshold; WEAK (2) does not. + expect(nodeIds).toEqual(['PROCESS', 'STRONG']); + expect(nodeIds).not.toContain('WEAK'); + }); + + it('excludes seed from output when includeSeed is false', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), host: { name: 'h1' } }, + { _id: 'R', '@timestamp': iso(T0 + 5 * 60_000), host: { name: 'h1' } }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + expect(result.nodes.map((n) => n.id)).toEqual(['R']); + expect(result.alerts.every((a) => a.alert_id !== 'S')).toBe(true); + }); + + it('ignores configured entity values during scoring', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), user: { name: 'root' }, host: { name: 'h1' } }, + // Shares user=root (ignored) and host=h1 (kept). + { + _id: 'R', + '@timestamp': iso(T0 + 5 * 60_000), + user: { name: 'root' }, + host: { name: 'h1' }, + }, + // Shares only user=root (ignored) — no valid link. + { + _id: 'X', + '@timestamp': iso(T0 + 10 * 60_000), + user: { name: 'root' }, + host: { name: 'h-other' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['user.name', 'host.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [{ field: 'user.name', values: ['root'] }], + entityFieldScores: { 'user.name': 1, 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + // R links via host.name=h1; X only shares the ignored user.name=root. + expect(result.nodes.map((n) => n.id)).toEqual(['R']); + }); + + it('links alerts via aliased entity fields (source.ip <-> destination.ip)', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), source: { ip: '10.0.0.1' } }, + // Shares the seed's source.ip value as destination.ip. + { + _id: 'LATERAL', + '@timestamp': iso(T0 + 5 * 60_000), + destination: { ip: '10.0.0.1' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['source.ip', 'destination.ip'], + entityFieldAliases: { + 'source.ip': [{ field: 'destination.ip', score: 3 }], + }, + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'source.ip': 1, 'destination.ip': 1 }, + minEntityScore: 3, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['LATERAL', 'S']); + expect(result.edges).toEqual( + expect.arrayContaining([expect.objectContaining({ from: 'S', to: 'LATERAL', score: 3 })]) + ); + }); + + it('returns empty graph when seed has no entity values', async () => { + await indexAlerts([ + // Seed has a timestamp but no entity fields. + { _id: 'EMPTY', '@timestamp': iso(T0) }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'EMPTY', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: {}, + minEntityScore: 1, + includeSeed: true, + }); + + // Only the seed itself (no related alerts, no edges). + expect(result.nodes).toEqual([{ id: 'EMPTY' }]); + expect(result.edges).toEqual([]); + expect(result.stats?.depth_reached).toBe(0); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.test.ts new file mode 100644 index 0000000000000..1e5d788979e56 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/integration_tests/graph_builder.test.ts @@ -0,0 +1,433 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { ToolingLog } from '@kbn/tooling-log'; +import type { EsTestCluster } from '@kbn/test'; +import { createTestEsCluster } from '@kbn/test'; +import { buildRelatedAlertsGraph } from '../graph_builder'; +import type { EsSearchClient } from '../types'; + +const TEST_INDEX = 'test-alerts'; + +const iso = (ms: number): string => new Date(ms).toISOString(); + +/** Wraps the raw `@elastic/elasticsearch` Client as our minimal `EsSearchClient` interface. */ +const wrapClient = (client: Client): EsSearchClient => ({ + search: async (request: Record) => { + const response = await client.search(request); + return { + hits: { + hits: response.hits.hits.map((h) => ({ + _id: h._id, + _index: h._index, + _source: h._source ?? undefined, + sort: h.sort, + })), + }, + }; + }, +}); + +describe('buildRelatedAlertsGraph — integration', () => { + let esServer: EsTestCluster; + let rawClient: Client; + let esClient: EsSearchClient; + + // Baseline timestamp: 2026-06-01T12:00:00Z + const T0 = Date.UTC(2026, 5, 1, 12, 0, 0); + + beforeAll(async () => { + jest.setTimeout(120_000); + + esServer = createTestEsCluster({ + log: new ToolingLog({ writeTo: process.stdout, level: 'info' }), + }); + await esServer.start(); + rawClient = esServer.getClient(); + esClient = wrapClient(rawClient); + }); + + afterAll(async () => { + await esServer?.stop(); + }); + + beforeEach(async () => { + // Clean up the test index before each test. + await rawClient.indices.delete({ index: TEST_INDEX, ignore_unavailable: true }).catch(() => {}); + }); + + /** + * Helper: indexes a batch of alert documents with explicit `_id` values, + * creates the index with a minimal mapping, and waits for refresh. + */ + const indexAlerts = async ( + alerts: Array<{ + _id: string; + '@timestamp': string; + [key: string]: unknown; + }> + ) => { + // Create the index with a mapping that supports our sort fields. + await rawClient.indices.create({ + index: TEST_INDEX, + mappings: { + properties: { + '@timestamp': { type: 'date' }, + 'kibana.alert.uuid': { type: 'keyword' }, + 'kibana.alert.rule.name': { type: 'keyword' }, + 'kibana.alert.severity': { type: 'keyword' }, + host: { + properties: { + name: { type: 'keyword' }, + }, + }, + user: { + properties: { + name: { type: 'keyword' }, + }, + }, + source: { + properties: { + ip: { type: 'keyword' }, + }, + }, + destination: { + properties: { + ip: { type: 'keyword' }, + }, + }, + process: { + properties: { + entity_id: { type: 'keyword' }, + }, + }, + }, + }, + }); + + const body = alerts.flatMap((alert) => { + const { _id, ...doc } = alert; + return [{ index: { _index: TEST_INDEX, _id } }, { 'kibana.alert.uuid': _id, ...doc }]; + }); + + await rawClient.bulk({ body, refresh: 'wait_for' }); + }; + + it('discovers related alerts via shared host.name and builds correct edges', async () => { + // Seed (A) --host.name=h1--> B --host.name=h1--> C + await indexAlerts([ + { _id: 'A', '@timestamp': iso(T0), host: { name: 'h1' }, user: { name: 'u1' } }, + { + _id: 'B', + '@timestamp': iso(T0 + 10 * 60_000), + host: { name: 'h1' }, + user: { name: 'u1' }, + }, + { + _id: 'C', + '@timestamp': iso(T0 + 20 * 60_000), + host: { name: 'h1' }, + user: { name: 'u2' }, + }, + // D shares nothing with A — should NOT appear. + { + _id: 'D', + '@timestamp': iso(T0 + 30 * 60_000), + host: { name: 'h-unrelated' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'A', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 3, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1, 'user.name': 1 }, + minEntityScore: 1, + includeSeed: true, + }); + + // Nodes: A (seed), B (host.name=h1), C (host.name=h1). D excluded. + const nodeIds = result.nodes.map((n) => n.id).sort(); + expect(nodeIds).toEqual(['A', 'B', 'C']); + + // D should not appear anywhere. + expect(nodeIds).not.toContain('D'); + + // Edges should connect related alerts. + expect(result.edges.length).toBeGreaterThanOrEqual(1); + + // Every edge should reference only nodes in the graph. + const nodeSet = new Set(nodeIds); + for (const edge of result.edges) { + expect(nodeSet.has(edge.from)).toBe(true); + expect(nodeSet.has(edge.to)).toBe(true); + expect(edge.score).toBeGreaterThan(0); + } + + // Stats should reflect the traversal. + expect(result.stats?.nodes).toBe(3); + expect(result.stats?.queries).toBeGreaterThanOrEqual(2); // seed fetch + at least 1 search + expect(result.stats?.depth_reached).toBeGreaterThanOrEqual(1); + }); + + it('discovers alert entity chains across expansion rounds', async () => { + // Alert 1: host 1, user 1 (seed) + // Alert 2: host 1, user 2 (discovered via host.name in depth 1) + // Alert 3: host 2, user 2 (discovered via user.name in depth 2) + // + // We place alert 3 outside the seed window but inside the expanded window, to ensure + // the chain relies on the traversal's window expansion. + await indexAlerts([ + { _id: 'ALERT_1', '@timestamp': iso(T0), host: { name: 'host-1' }, user: { name: 'user-1' } }, + { + _id: 'ALERT_2', + '@timestamp': iso(T0 + 5 * 60_000), // within seed window + host: { name: 'host-1' }, + user: { name: 'user-2' }, + }, + { + _id: 'ALERT_3', + '@timestamp': iso(T0 + 45 * 60_000), // outside seed window, inside expanded window + host: { name: 'host-2' }, + user: { name: 'user-2' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'ALERT_1', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name'], + seedWindowMs: 10 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 3, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1, 'user.name': 1 }, + minEntityScore: 1, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['ALERT_1', 'ALERT_2', 'ALERT_3']); + + // Chain edges: 1 -> 2 via host, 2 -> 3 via user + expect(result.edges).toEqual( + expect.arrayContaining([ + { from: 'ALERT_1', to: 'ALERT_2', score: 1, label_scores: { host: 1 } }, + { from: 'ALERT_2', to: 'ALERT_3', score: 1, label_scores: { user: 1 } }, + ]) + ); + + // Should take at least 2 expansion rounds to discover ALERT_3. + expect(result.stats?.depth_reached).toBeGreaterThanOrEqual(2); + }); + + it('respects minEntityScore threshold to exclude weakly-linked alerts', async () => { + await indexAlerts([ + { + _id: 'SEED', + '@timestamp': iso(T0), + host: { name: 'h1' }, + user: { name: 'u1' }, + process: { entity_id: 'p1' }, + }, + // STRONG: shares host + user (score 2+2 = 4, meets threshold) + { + _id: 'STRONG', + '@timestamp': iso(T0 + 5 * 60_000), + host: { name: 'h1' }, + user: { name: 'u1' }, + }, + // WEAK: shares only host (score 2, below threshold of 4) + { + _id: 'WEAK', + '@timestamp': iso(T0 + 10 * 60_000), + host: { name: 'h1' }, + }, + // PROCESS: shares only process.entity_id (score 5, meets threshold) + { + _id: 'PROCESS', + '@timestamp': iso(T0 + 15 * 60_000), + process: { entity_id: 'p1' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'SEED', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name', 'process.entity_id'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 2, 'user.name': 2, 'process.entity_id': 5 }, + minEntityScore: 4, + includeSeed: false, + }); + + const nodeIds = result.nodes.map((n) => n.id).sort(); + + // STRONG (4) and PROCESS (5) meet threshold; WEAK (2) does not. + expect(nodeIds).toEqual(['PROCESS', 'STRONG']); + expect(nodeIds).not.toContain('WEAK'); + }); + + it('excludes seed from output when includeSeed is false', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), host: { name: 'h1' } }, + { _id: 'R', '@timestamp': iso(T0 + 5 * 60_000), host: { name: 'h1' } }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + expect(result.nodes.map((n) => n.id)).toEqual(['R']); + expect(result.alerts.every((a) => a.alert_id !== 'S')).toBe(true); + }); + + it('ignores configured entity values during scoring', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), user: { name: 'root' }, host: { name: 'h1' } }, + // Shares user=root (ignored) and host=h1 (kept). + { + _id: 'R', + '@timestamp': iso(T0 + 5 * 60_000), + user: { name: 'root' }, + host: { name: 'h1' }, + }, + // Shares only user=root (ignored) — no valid link. + { + _id: 'X', + '@timestamp': iso(T0 + 10 * 60_000), + user: { name: 'root' }, + host: { name: 'h-other' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['user.name', 'host.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [{ field: 'user.name', values: ['root'] }], + entityFieldScores: { 'user.name': 1, 'host.name': 1 }, + minEntityScore: 1, + includeSeed: false, + }); + + // R links via host.name=h1; X only shares the ignored user.name=root. + expect(result.nodes.map((n) => n.id)).toEqual(['R']); + }); + + it('links alerts via aliased entity fields (source.ip <-> destination.ip)', async () => { + await indexAlerts([ + { _id: 'S', '@timestamp': iso(T0), source: { ip: '10.0.0.1' } }, + // Shares the seed's source.ip value as destination.ip. + { + _id: 'LATERAL', + '@timestamp': iso(T0 + 5 * 60_000), + destination: { ip: '10.0.0.1' }, + }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'S', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['source.ip', 'destination.ip'], + entityFieldAliases: { + 'source.ip': [{ field: 'destination.ip', score: 3 }], + }, + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: { 'source.ip': 1, 'destination.ip': 1 }, + minEntityScore: 3, + includeSeed: true, + }); + + expect(result.nodes.map((n) => n.id).sort()).toEqual(['LATERAL', 'S']); + expect(result.edges).toEqual( + expect.arrayContaining([expect.objectContaining({ from: 'S', to: 'LATERAL', score: 3 })]) + ); + }); + + it('returns empty graph when seed has no entity values', async () => { + await indexAlerts([ + // Seed has a timestamp but no entity fields. + { _id: 'EMPTY', '@timestamp': iso(T0) }, + ]); + + const result = await buildRelatedAlertsGraph({ + esClient, + seed: { alertId: 'EMPTY', alertIndex: TEST_INDEX }, + searchIndex: TEST_INDEX, + entityFields: ['host.name', 'user.name'], + seedWindowMs: 60 * 60_000, + expandWindowMs: 60 * 60_000, + maxDepth: 1, + maxAlerts: 50, + pageSize: 100, + maxTermsPerQuery: 100, + maxEntitiesPerField: 100, + ignoreEntities: [], + entityFieldScores: {}, + minEntityScore: 1, + includeSeed: true, + }); + + // Only the seed itself (no related alerts, no edges). + expect(result.nodes).toEqual([{ id: 'EMPTY' }]); + expect(result.edges).toEqual([]); + expect(result.stats?.depth_reached).toBe(0); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.test.ts new file mode 100644 index 0000000000000..22c98883a9f96 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { toIso, toFiniteIntOr, clampPositiveInt, computePageSize } from './number_utils'; + +describe('number_utils', () => { + describe('toIso', () => { + it('converts epoch ms to ISO string', () => { + expect(toIso(0)).toBe('1970-01-01T00:00:00.000Z'); + expect(toIso(1704067200000)).toBe('2024-01-01T00:00:00.000Z'); + }); + }); + + describe('toFiniteIntOr', () => { + it('returns truncated number for finite numeric input', () => { + expect(toFiniteIntOr(5.9, 0)).toBe(5); + expect(toFiniteIntOr(-3.1, 0)).toBe(-3); + }); + + it('returns fallback for NaN', () => { + expect(toFiniteIntOr(NaN, 42)).toBe(42); + }); + + it('returns fallback for Infinity', () => { + expect(toFiniteIntOr(Infinity, 42)).toBe(42); + expect(toFiniteIntOr(-Infinity, 42)).toBe(42); + }); + + it('coerces string values', () => { + expect(toFiniteIntOr('10', 0)).toBe(10); + expect(toFiniteIntOr('3.7', 0)).toBe(3); + }); + + it('returns fallback for non-numeric strings', () => { + expect(toFiniteIntOr('nope', 42)).toBe(42); + }); + + it('coerces null and empty string to 0 (Number semantics)', () => { + // Number(null) === 0, Number('') === 0 + expect(toFiniteIntOr(null, 42)).toBe(0); + expect(toFiniteIntOr('', 42)).toBe(0); + }); + + it('returns fallback for undefined', () => { + expect(toFiniteIntOr(undefined, 42)).toBe(42); + }); + }); + + describe('clampPositiveInt', () => { + it('returns the truncated value when >= 1', () => { + expect(clampPositiveInt(5, 10)).toBe(5); + expect(clampPositiveInt(1.9, 10)).toBe(1); + }); + + it('returns fallback for zero', () => { + expect(clampPositiveInt(0, 10)).toBe(10); + }); + + it('returns fallback for negative values', () => { + expect(clampPositiveInt(-5, 10)).toBe(10); + }); + + it('returns fallback for NaN', () => { + expect(clampPositiveInt(NaN, 10)).toBe(10); + }); + + it('returns fallback for Infinity', () => { + expect(clampPositiveInt(Infinity, 10)).toBe(10); + }); + }); + + describe('computePageSize', () => { + it('returns the requested size when remaining is larger', () => { + expect(computePageSize(100, 500, 200)).toBe(100); + }); + + it('returns the remaining when it is smaller than requested', () => { + expect(computePageSize(100, 50, 200)).toBe(50); + }); + + it('returns at least 1 when remaining is zero or negative', () => { + expect(computePageSize(100, 0, 200)).toBe(1); + expect(computePageSize(100, -10, 200)).toBe(1); + }); + + it('uses fallback when requested is NaN', () => { + expect(computePageSize(NaN, 500, 200)).toBe(200); + }); + + it('uses fallback when remaining is NaN', () => { + // When remaining is NaN, it falls back to the requested value + expect(computePageSize(100, NaN, 200)).toBe(100); + }); + + it('coerces string inputs', () => { + expect(computePageSize('50', '30', 200)).toBe(30); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.ts new file mode 100644 index 0000000000000..c317ae39d7a30 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/number_utils.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. + */ + +/** Converts epoch milliseconds to an ISO-8601 string. */ +export const toIso = (ms: number): string => new Date(ms).toISOString(); + +/** + * Coerces an unknown value to a finite integer, returning `fallback` if coercion fails. + * + * Handles both `number` inputs and stringified numbers (e.g. from JSON). + */ +export const toFiniteIntOr = (value: unknown, fallback: number): number => { + if (typeof value === 'number') { + return Number.isFinite(value) ? Math.trunc(value) : fallback; + } + + const coerced = Number(value); + return Number.isFinite(coerced) ? Math.trunc(coerced) : fallback; +}; + +/** + * Ensures `value` is a positive integer (>= 1), returning `fallback` otherwise. + * + * Useful for clamping user-supplied limits that must be at least 1. + */ +export const clampPositiveInt = (value: number, fallback: number): number => { + if (!Number.isFinite(value)) return fallback; + const v = Math.trunc(value); + return v >= 1 ? v : fallback; +}; + +/** + * Computes the effective page size for a paginated ES query. + * + * Takes the requested page size and the remaining alert budget, returning the + * smaller of the two (but always at least 1). + */ +export const computePageSize = ( + requestedRaw: unknown, + remainingRaw: unknown, + fallback: number +): number => { + const requested = clampPositiveInt(toFiniteIntOr(requestedRaw, fallback), fallback); + const remaining = toFiniteIntOr(remainingRaw, requested); + + if (!Number.isFinite(remaining)) return requested; + if (remaining <= 0) return 1; + + return Math.max(1, Math.min(requested, remaining)); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.test.ts new file mode 100644 index 0000000000000..2aa009a5697b4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { buildEntityShouldClauses } from './query_utils'; + +describe('query_utils', () => { + it('chunks terms per field', () => { + const entitiesByField = new Map>([ + ['host.name', new Set(['a', 'b', 'c'])], + ['user.name', new Set(['u1'])], + ]); + + const clauses = buildEntityShouldClauses({ entitiesByField, maxTermsPerQuery: 2 }); + // host.name -> 2 chunks, user.name -> 1 chunk + expect(clauses).toHaveLength(3); + }); + + // NOTE: ignore_entities are applied during entity extraction/scoring, not as ES query must_not filters. +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.ts new file mode 100644 index 0000000000000..09ef386e3a244 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/query_utils.ts @@ -0,0 +1,30 @@ +/* + * 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 buildEntityShouldClauses = (params: { + entitiesByField: Map>; + maxTermsPerQuery: number; +}): Array> => { + const { entitiesByField, maxTermsPerQuery } = params; + const shouldClauses: Array> = []; + + for (const [field, valuesSet] of entitiesByField.entries()) { + const values = Array.from(valuesSet); + if (values.length > 0) { + for (let i = 0; i < values.length; i += maxTermsPerQuery) { + const chunk = values.slice(i, i + maxTermsPerQuery); + shouldClauses.push({ + terms: { + [field]: chunk, + }, + }); + } + } + } + + return shouldClauses; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.test.ts new file mode 100644 index 0000000000000..e2e1abece2ab9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { computeParentLinks } from './scoring'; +import type { AliasMap } from './alias_utils'; +import type { ScoringConfig } from './types'; + +const defaultScoring: ScoringConfig = { + minEntityScore: 2, + entityFieldScores: new Map(), + defaultScorePerField: 1, +}; + +/** + * Helper: indexes an alert's entities into the entity-to-alertIds lookup map. + */ +const indexAlert = ( + entityToAlertIds: Map>, + alertId: string, + entities: Map> +) => { + for (const [field, values] of entities.entries()) { + for (const v of values) { + const key = `${field}\u0000${v}`; + const set = entityToAlertIds.get(key) ?? new Set(); + set.add(alertId); + entityToAlertIds.set(key, set); + } + } +}; + +describe('scoring', () => { + describe('computeParentLinks', () => { + it('returns empty map when no entities match any parent', () => { + const entityToAlertIds = new Map>(); + indexAlert(entityToAlertIds, 'parent-1', new Map([['host.name', new Set(['h1'])]])); + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + childEntities: new Map([['host.name', new Set(['h2'])]]), + aliasesByField: new Map(), + scoring: defaultScoring, + }); + + expect(result.size).toBe(0); + }); + + it('returns empty map when score is below threshold', () => { + const entityToAlertIds = new Map>(); + indexAlert(entityToAlertIds, 'parent-1', new Map([['host.name', new Set(['h1'])]])); + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + childEntities: new Map([['host.name', new Set(['h1'])]]), + aliasesByField: new Map(), + scoring: { ...defaultScoring, minEntityScore: 5 }, + }); + + expect(result.size).toBe(0); + }); + + it('links parent when score meets threshold', () => { + const entityToAlertIds = new Map>(); + indexAlert( + entityToAlertIds, + 'parent-1', + new Map([ + ['host.name', new Set(['h1'])], + ['user.name', new Set(['u1'])], + ]) + ); + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + childEntities: new Map([ + ['host.name', new Set(['h1'])], + ['user.name', new Set(['u1'])], + ]), + aliasesByField: new Map(), + scoring: { ...defaultScoring, minEntityScore: 2 }, + }); + + expect(result.size).toBe(1); + expect(result.get('parent-1')?.score).toBe(2); + expect(result.get('parent-1')?.labelScores.get('host')).toBe(1); + expect(result.get('parent-1')?.labelScores.get('user')).toBe(1); + }); + + it('uses per-field score overrides', () => { + const entityToAlertIds = new Map>(); + indexAlert(entityToAlertIds, 'parent-1', new Map([['process.entity_id', new Set(['p1'])]])); + + const scoring: ScoringConfig = { + minEntityScore: 4, + entityFieldScores: new Map([['process.entity_id', 5]]), + defaultScorePerField: 1, + }; + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + childEntities: new Map([['process.entity_id', new Set(['p1'])]]), + aliasesByField: new Map(), + scoring, + }); + + expect(result.size).toBe(1); + expect(result.get('parent-1')?.score).toBe(5); + }); + + it('ignores alerts that are not in parentCandidates', () => { + const entityToAlertIds = new Map>(); + indexAlert( + entityToAlertIds, + 'not-a-candidate', + new Map([ + ['host.name', new Set(['h1'])], + ['user.name', new Set(['u1'])], + ]) + ); + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['some-other-id']), + childEntities: new Map([ + ['host.name', new Set(['h1'])], + ['user.name', new Set(['u1'])], + ]), + aliasesByField: new Map(), + scoring: defaultScoring, + }); + + expect(result.size).toBe(0); + }); + + it('matches via alias fields', () => { + const entityToAlertIds = new Map>(); + // Parent has destination.ip = 10.0.0.1 + indexAlert( + entityToAlertIds, + 'parent-1', + new Map([['destination.ip', new Set(['10.0.0.1'])]]) + ); + + const aliasesByField: AliasMap = new Map([ + ['source.ip', [{ field: 'destination.ip', score: 3 }]], + ]); + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + // Child has source.ip = 10.0.0.1, which aliases to destination.ip + childEntities: new Map([['source.ip', new Set(['10.0.0.1'])]]), + aliasesByField, + scoring: { ...defaultScoring, minEntityScore: 3 }, + }); + + expect(result.size).toBe(1); + expect(result.get('parent-1')?.score).toBe(3); + }); + + it('takes max score when multiple fields match the same label', () => { + const entityToAlertIds = new Map>(); + indexAlert( + entityToAlertIds, + 'parent-1', + new Map([ + ['user.name', new Set(['u1'])], + ['user.id', new Set(['uid-1'])], + ]) + ); + + const scoring: ScoringConfig = { + minEntityScore: 1, + entityFieldScores: new Map([ + ['user.name', 2], + ['user.id', 3], + ]), + defaultScorePerField: 1, + }; + + const result = computeParentLinks({ + entityToAlertIds, + parentCandidates: new Set(['parent-1']), + childEntities: new Map([ + ['user.name', new Set(['u1'])], + ['user.id', new Set(['uid-1'])], + ]), + aliasesByField: new Map(), + scoring, + }); + + expect(result.size).toBe(1); + // Both user.name and user.id share the "user" label; max(2, 3) = 3 + expect(result.get('parent-1')?.labelScores.get('user')).toBe(3); + expect(result.get('parent-1')?.score).toBe(3); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.ts new file mode 100644 index 0000000000000..f125c3790823f --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/scoring.ts @@ -0,0 +1,90 @@ +/* + * 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 { fieldToEntityLabel } from './entity_utils'; +import type { AliasMap } from './alias_utils'; +import type { ScoringConfig } from './types'; + +export interface ParentLinkResult { + labels: Set; + /** Per-label scores (label = top-level field segment, e.g. `user.name` -> `user`). */ + labelScores: Map; + score: number; +} + +/** + * Computes which parent alerts a child alert links to, based on shared entity values. + * + * For each entity field on the child, finds parent alerts that share those values + * (including via alias fields), and computes a score per label. Only parents + * whose total score meets `scoring.minEntityScore` are returned. + */ +export const computeParentLinks = (params: { + entityToAlertIds: Map>; + parentCandidates: Set; + childEntities: Map>; + aliasesByField: AliasMap; + scoring: ScoringConfig; +}): Map => { + const { entityToAlertIds, parentCandidates, childEntities, aliasesByField, scoring } = params; + + const parents = new Map(); + + for (const [field, values] of childEntities.entries()) { + const label = fieldToEntityLabel(field); + const fieldScore = scoring.entityFieldScores.get(field) ?? scoring.defaultScorePerField; + const aliases = aliasesByField.get(field) ?? []; + + // Build the list of fields to match against (the field itself + its aliases). + const matchFields: Array<{ field: string; score: number }> = [{ field, score: fieldScore }]; + for (const alias of aliases) { + if (alias?.field && alias.field !== field) { + const aliasScore = + (typeof alias.score === 'number' && Number.isFinite(alias.score) + ? alias.score + : undefined) ?? + scoring.entityFieldScores.get(alias.field) ?? + scoring.defaultScorePerField; + matchFields.push({ field: alias.field, score: aliasScore }); + } + } + + for (const v of values) { + for (const matchField of matchFields) { + const key = `${matchField.field}\u0000${v}`; + const neighbors = entityToAlertIds.get(key); + if (neighbors) { + for (const neighborId of neighbors) { + if (parentCandidates.has(neighborId)) { + const entry = parents.get(neighborId) ?? { + labels: new Set(), + labelScores: new Map(), + score: 0, + }; + entry.labels.add(label); + const prev = entry.labelScores.get(label) ?? 0; + entry.labelScores.set(label, Math.max(prev, matchField.score)); + parents.set(neighborId, entry); + } + } + } + } + } + } + + // Filter out parents that don't meet the minimum score threshold. + const filtered = new Map(); + for (const [parentId, match] of parents.entries()) { + const score = Array.from(match.labelScores.values()).reduce((sum, s) => sum + s, 0); + match.score = score; + if (score >= scoring.minEntityScore) { + filtered.set(parentId, match); + } + } + + return filtered; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.test.ts new file mode 100644 index 0000000000000..a81332b87b40a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { searchWindow } from './search_window'; +import type { EsHit, EsSearchClient } from './types'; + +const makeHit = (id: string, ts: string): EsHit => ({ + _id: id, + _index: 'alerts', + _source: { '@timestamp': ts } as EsHit['_source'], + sort: [ts, id], +}); + +const makeClient = (pages: EsHit[][]): EsSearchClient => { + let callIndex = 0; + const searchImpl: EsSearchClient['search'] = async () => { + const hits = (pages[callIndex] ?? []) as Array>; + callIndex++; + return { hits: { hits } }; + }; + return { + search: jest.fn(searchImpl) as unknown as EsSearchClient['search'], + }; +}; + +describe('searchWindow', () => { + it('invokes onHit for each hit', async () => { + const hits = [ + makeHit('a', '2026-01-01T00:00:00.000Z'), + makeHit('b', '2026-01-01T00:01:00.000Z'), + ]; + const client = makeClient([hits]); + const collected: string[] = []; + + await searchWindow({ + esClient: client, + index: 'alerts', + gteMs: 0, + lteMs: Date.now(), + shouldClauses: [{ terms: { 'host.name': ['h1'] } }], + sourceFields: ['@timestamp'], + pageSize: 10, + onHit: (hit) => collected.push(hit._id ?? ''), + stop: () => false, + queriesRef: { queries: 0 }, + }); + + expect(collected).toEqual(['a', 'b']); + }); + + it('paginates using search_after', async () => { + const page1 = [makeHit('a', '2026-01-01T00:00:00.000Z')]; + const page2 = [makeHit('b', '2026-01-01T00:01:00.000Z')]; + const client = makeClient([page1, page2, []]); + const collected: string[] = []; + + await searchWindow({ + esClient: client, + index: 'alerts', + gteMs: 0, + lteMs: Date.now(), + shouldClauses: [{ terms: { 'host.name': ['h1'] } }], + sourceFields: ['@timestamp'], + pageSize: 1, + onHit: (hit) => collected.push(hit._id ?? ''), + stop: () => false, + queriesRef: { queries: 0 }, + }); + + expect(collected).toEqual(['a', 'b']); + expect(client.search).toHaveBeenCalledTimes(3); + }); + + it('stops when stop() returns true', async () => { + const hits = [ + makeHit('a', '2026-01-01T00:00:00.000Z'), + makeHit('b', '2026-01-01T00:01:00.000Z'), + ]; + const client = makeClient([hits]); + const collected: string[] = []; + let count = 0; + + await searchWindow({ + esClient: client, + index: 'alerts', + gteMs: 0, + lteMs: Date.now(), + shouldClauses: [{ terms: { 'host.name': ['h1'] } }], + sourceFields: ['@timestamp'], + pageSize: 10, + onHit: (hit) => { + collected.push(hit._id ?? ''); + count++; + }, + stop: () => count >= 1, + queriesRef: { queries: 0 }, + }); + + expect(collected).toEqual(['a']); + }); + + it('stops when an empty page is returned', async () => { + const client = makeClient([[]]); + const collected: string[] = []; + + await searchWindow({ + esClient: client, + index: 'alerts', + gteMs: 0, + lteMs: Date.now(), + shouldClauses: [{ terms: { 'host.name': ['h1'] } }], + sourceFields: ['@timestamp'], + pageSize: 10, + onHit: (hit) => collected.push(hit._id ?? ''), + stop: () => false, + queriesRef: { queries: 0 }, + }); + + expect(collected).toEqual([]); + expect(client.search).toHaveBeenCalledTimes(1); + }); + + it('increments the queries counter', async () => { + const client = makeClient([[makeHit('a', '2026-01-01T00:00:00.000Z')], []]); + const queriesRef = { queries: 5 }; + + await searchWindow({ + esClient: client, + index: 'alerts', + gteMs: 0, + lteMs: Date.now(), + shouldClauses: [{ terms: { 'host.name': ['h1'] } }], + sourceFields: ['@timestamp'], + pageSize: 10, + onHit: () => {}, + stop: () => false, + queriesRef, + }); + + expect(queriesRef.queries).toBe(7); // 5 + 2 queries + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.ts new file mode 100644 index 0000000000000..ca2bd53e67a6c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/search_window.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import { toIso } from './number_utils'; +import type { DetectionAlert800 } from '../../../../common/api/detection_engine/model/alerts'; +import type { EsHit, EsSearchClient } from './types'; + +/** + * Paginates through an ES alert index using `search_after`, invoking `onHit` + * for each document. Stops when hits are exhausted or `stop()` returns true. + * + * Uses `@timestamp` + `kibana.alert.uuid` as the sort/tiebreaker + * (not `_id`, which requires fielddata that is disabled by default). + */ +export const searchWindow = async (params: { + esClient: EsSearchClient; + index: string; + gteMs: number; + lteMs: number; + shouldClauses: Array>; + sourceFields: string[]; + pageSize: number; + onHit: (hit: EsHit) => void; + stop: () => boolean; + queriesRef: { queries: number }; +}): Promise => { + const { + esClient, + index, + gteMs, + lteMs, + shouldClauses, + sourceFields, + pageSize, + onHit, + stop, + queriesRef, + } = params; + + let searchAfter: estypes.SortResults | undefined; + while (!stop()) { + const response = await esClient.search({ + index, + // Hidden indices (like `.internal.*`) require explicit expansion. + expand_wildcards: ['open', 'hidden'], + size: pageSize, + _source: sourceFields, + track_total_hits: false, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: toIso(gteMs), + lte: toIso(lteMs), + }, + }, + }, + { + bool: { + should: shouldClauses, + minimum_should_match: 1, + }, + }, + ], + }, + }, + sort: [{ '@timestamp': 'asc' }, { 'kibana.alert.uuid': 'asc' }], + ...(searchAfter ? { search_after: searchAfter } : {}), + }); + queriesRef.queries++; + + const hits = response.hits.hits; + if (!hits.length) break; + + for (const hit of hits) { + onHit(hit); + if (stop()) break; + } + + const lastSort = hits[hits.length - 1]?.sort; + if (!lastSort) break; + searchAfter = lastSort; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.test.ts new file mode 100644 index 0000000000000..d7d40b5d5f330 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { parseTimeWindowToMs } from './time_window'; + +describe('parseTimeWindowToMs', () => { + it('parses supported units', () => { + expect(parseTimeWindowToMs('1s')).toBe(1000); + expect(parseTimeWindowToMs('30m')).toBe(30 * 60 * 1000); + expect(parseTimeWindowToMs('2h')).toBe(2 * 60 * 60 * 1000); + expect(parseTimeWindowToMs('7d')).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it('defaults to 1 hour on invalid input', () => { + expect(parseTimeWindowToMs('bad')).toBe(60 * 60 * 1000); + expect(parseTimeWindowToMs('1w')).toBe(60 * 60 * 1000); + expect(parseTimeWindowToMs('')).toBe(60 * 60 * 1000); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.ts new file mode 100644 index 0000000000000..f2b5849076ba9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/time_window.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +/** + * Parse time window string to milliseconds. + * + * Supported format: `` where unit is one of: d, h, m, s + * Examples: `1h`, `24h`, `7d`, `30m`, `10s` + * + * Defaults to 1 hour if parsing fails. + */ +export const parseTimeWindowToMs = (timeWindow: string): number => { + const match = timeWindow.match(/^(\d+)([hdms])$/); + if (!match) { + return 60 * 60 * 1000; + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 'd': + return value * 24 * 60 * 60 * 1000; + case 'h': + return value * 60 * 60 * 1000; + case 'm': + return value * 60 * 1000; + case 's': + return value * 1000; + default: + return 60 * 60 * 1000; + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.test.ts new file mode 100644 index 0000000000000..5599041936a22 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.test.ts @@ -0,0 +1,195 @@ +/* + * 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 { mergeEntities, indexEntitiesForAlert, addTraversalEdges } from './traversal_utils'; +import type { EdgeAccumulator } from './types'; + +describe('traversal_utils', () => { + describe('mergeEntities', () => { + it('adds new values to known and returns them as frontier', () => { + const known = new Map>(); + const added = new Map([['host.name', new Set(['h1', 'h2'])]]); + + const frontier = mergeEntities({ known, added, maxEntitiesPerField: 10 }); + + expect(known.get('host.name')).toEqual(new Set(['h1', 'h2'])); + expect(frontier.get('host.name')).toEqual(new Set(['h1', 'h2'])); + }); + + it('does not return already-known values in the frontier', () => { + const known = new Map([['host.name', new Set(['h1'])]]); + const added = new Map([['host.name', new Set(['h1', 'h2'])]]); + + const frontier = mergeEntities({ known, added, maxEntitiesPerField: 10 }); + + expect(known.get('host.name')).toEqual(new Set(['h1', 'h2'])); + expect(frontier.get('host.name')).toEqual(new Set(['h2'])); + }); + + it('respects maxEntitiesPerField limit', () => { + const known = new Map([['host.name', new Set(['h1'])]]); + const added = new Map([['host.name', new Set(['h2', 'h3', 'h4'])]]); + + const frontier = mergeEntities({ known, added, maxEntitiesPerField: 2 }); + + expect(known.get('host.name')?.size).toBe(2); + expect(frontier.get('host.name')?.size).toBe(1); + }); + + it('returns empty frontier when nothing is new', () => { + const known = new Map([['host.name', new Set(['h1'])]]); + const added = new Map([['host.name', new Set(['h1'])]]); + + const frontier = mergeEntities({ known, added, maxEntitiesPerField: 10 }); + + expect(frontier.size).toBe(0); + }); + + it('skips empty value sets', () => { + const known = new Map>(); + const added = new Map([['host.name', new Set()]]); + + const frontier = mergeEntities({ known, added, maxEntitiesPerField: 10 }); + + expect(frontier.size).toBe(0); + }); + }); + + describe('indexEntitiesForAlert', () => { + it('creates entries keyed by field+value', () => { + const entityToAlertIds = new Map>(); + const entities = new Map([['host.name', new Set(['h1'])]]); + + indexEntitiesForAlert({ entityToAlertIds, alertId: 'a1', entities }); + + const key = 'host.name\u0000h1'; + expect(entityToAlertIds.get(key)).toEqual(new Set(['a1'])); + }); + + it('accumulates multiple alert IDs for the same entity value', () => { + const entityToAlertIds = new Map>(); + const entities = new Map([['host.name', new Set(['h1'])]]); + + indexEntitiesForAlert({ entityToAlertIds, alertId: 'a1', entities }); + indexEntitiesForAlert({ entityToAlertIds, alertId: 'a2', entities }); + + const key = 'host.name\u0000h1'; + expect(entityToAlertIds.get(key)).toEqual(new Set(['a1', 'a2'])); + }); + + it('indexes multiple fields and values', () => { + const entityToAlertIds = new Map>(); + const entities = new Map([ + ['host.name', new Set(['h1', 'h2'])], + ['user.name', new Set(['u1'])], + ]); + + indexEntitiesForAlert({ entityToAlertIds, alertId: 'a1', entities }); + + expect(entityToAlertIds.size).toBe(3); + expect(entityToAlertIds.get('host.name\u0000h1')).toEqual(new Set(['a1'])); + expect(entityToAlertIds.get('host.name\u0000h2')).toEqual(new Set(['a1'])); + expect(entityToAlertIds.get('user.name\u0000u1')).toEqual(new Set(['a1'])); + }); + }); + + describe('addTraversalEdges', () => { + it('creates a new edge when none exists', () => { + const edgesByKey: EdgeAccumulator = new Map(); + const parentLinks = new Map([ + [ + 'parent-1', + { + labels: new Set(['host']), + labelScores: new Map([['host', 2]]), + score: 2, + }, + ], + ]); + + addTraversalEdges({ edgesByKey, childId: 'child-1', parentLinks }); + + expect(edgesByKey.size).toBe(1); + const edge = Array.from(edgesByKey.values())[0]; + expect(edge.from).toBe('parent-1'); + expect(edge.to).toBe('child-1'); + expect(edge.score).toBe(2); + expect(edge.labelScores.get('host')).toBe(2); + }); + + it('merges scores when edge already exists (takes max per label)', () => { + const edgesByKey: EdgeAccumulator = new Map(); + + // First traversal finds host match with score 2 + addTraversalEdges({ + edgesByKey, + childId: 'child-1', + parentLinks: new Map([ + [ + 'parent-1', + { + labels: new Set(['host']), + labelScores: new Map([['host', 2]]), + score: 2, + }, + ], + ]), + }); + + // Second traversal finds host match with score 3 and user match with score 1 + addTraversalEdges({ + edgesByKey, + childId: 'child-1', + parentLinks: new Map([ + [ + 'parent-1', + { + labels: new Set(['host', 'user']), + labelScores: new Map([ + ['host', 3], + ['user', 1], + ]), + score: 4, + }, + ], + ]), + }); + + expect(edgesByKey.size).toBe(1); + const edge = Array.from(edgesByKey.values())[0]; + expect(edge.labelScores.get('host')).toBe(3); // max(2, 3) + expect(edge.labelScores.get('user')).toBe(1); + expect(edge.score).toBe(4); // 3 + 1 + }); + + it('creates edges for multiple parents', () => { + const edgesByKey: EdgeAccumulator = new Map(); + const parentLinks = new Map([ + [ + 'parent-1', + { + labels: new Set(['host']), + labelScores: new Map([['host', 2]]), + score: 2, + }, + ], + [ + 'parent-2', + { + labels: new Set(['user']), + labelScores: new Map([['user', 3]]), + score: 3, + }, + ], + ]); + + addTraversalEdges({ edgesByKey, childId: 'child-1', parentLinks }); + + expect(edgesByKey.size).toBe(2); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.ts new file mode 100644 index 0000000000000..3e5b66833d4bf --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/traversal_utils.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EdgeAccumulator } from './types'; +import type { ParentLinkResult } from './scoring'; + +/** + * Merges newly discovered entities into the known set, respecting per-field limits. + * + * Returns only the truly new values (frontier) for the next expansion round. + * The `known` map is mutated in place for efficiency. + */ +export const mergeEntities = (params: { + known: Map>; + added: Map>; + maxEntitiesPerField: number; +}): Map> => { + const { known, added, maxEntitiesPerField } = params; + const frontier = new Map>(); + + for (const [field, values] of added.entries()) { + if (values.size) { + const existing = known.get(field) ?? new Set(); + const newValues = new Set(); + + for (const v of values) { + if (existing.size >= maxEntitiesPerField) break; + if (!existing.has(v)) { + existing.add(v); + newValues.add(v); + } + } + + known.set(field, existing); + if (newValues.size) frontier.set(field, newValues); + } + } + + return frontier; +}; + +/** + * Indexes an alert's entity values into the entity-to-alert-IDs lookup map. + * + * This enables efficient parent-link computation during traversal: + * given a `field + value` pair, we can quickly find all alerts that share it. + */ +export const indexEntitiesForAlert = (params: { + entityToAlertIds: Map>; + alertId: string; + entities: Map>; +}): void => { + const { entityToAlertIds, alertId, entities } = params; + + for (const [field, values] of entities.entries()) { + for (const v of values) { + const key = `${field}\u0000${v}`; + const set = entityToAlertIds.get(key) ?? new Set(); + set.add(alertId); + entityToAlertIds.set(key, set); + } + } +}; + +/** + * Records traversal edges from a child alert to its matched parent alerts. + * + * If an edge between two alerts already exists, label scores are merged + * (taking the max score per label). + */ +export const addTraversalEdges = (params: { + edgesByKey: EdgeAccumulator; + childId: string; + parentLinks: Map; +}): void => { + const { edgesByKey, childId, parentLinks } = params; + + for (const [parentId, match] of parentLinks.entries()) { + const edgeKey = `${parentId}\u0000${childId}`; + const edge = edgesByKey.get(edgeKey) ?? { + from: parentId, + to: childId, + labelScores: new Map(), + score: 0, + }; + for (const [label, score] of match.labelScores.entries()) { + const prev = edge.labelScores.get(label) ?? 0; + edge.labelScores.set(label, Math.max(prev, score)); + } + edge.score = Array.from(edge.labelScores.values()).reduce((sum, s) => sum + s, 0); + edgesByKey.set(edgeKey, edge); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/types.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/types.ts new file mode 100644 index 0000000000000..525d978990a17 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/build_alert_entity_graph_step/types.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import type { DetectionAlert800 } from '../../../../common/api/detection_engine/model/alerts'; + +// ── Alert metadata ────────────────────────────────────────────────────────── + +export interface AlertMeta { + alert_id: string; + alert_index: string; + timestamp?: string; + rule_name?: string; + severity?: string; + ts_ms?: number; +} + +// ── Graph output ──────────────────────────────────────────────────────────── + +export interface RelatedAlertsGraphOutput { + nodes: Array<{ id: string }>; + edges: Array<{ + from: string; + to: string; + /** + * Total score for this edge, computed as the sum of `label_scores`. + */ + score: number; + /** + * Per-label scores that contributed to `score`. The keys implicitly represent the matched labels + * (top-level field segment, e.g. `user.name` -> `user`). + */ + label_scores: Record; + }>; + alerts: Array<{ + alert_id: string; + alert_index: string; + timestamp?: string; + rule_name?: string; + severity?: string; + }>; + stats?: { + depth_reached: number; + nodes: number; + edges: number; + queries: number; + time_range: { gte: string; lte: string }; + }; +} + +// ── Elasticsearch types ───────────────────────────────────────────────────── + +export interface EsHit { + _id?: string; + _index: string; + _source?: TSource; + sort?: estypes.SortResults; +} + +export interface EsSearchResponse { + hits: { hits: Array> }; +} + +/** + * Minimal search client interface. + * + * Compatible with the real `ElasticsearchClient` — the generic `` + * parameter lets callers specify the `_source` shape, and `sort` uses + * ES's own `SortResults` type so there is no tuple-length mismatch. + */ +export interface EsSearchClient { + search: ( + request: Record + ) => Promise>; +} + +// ── Edge accumulator ──────────────────────────────────────────────────────── + +export type EdgeAccumulator = Map< + string, + { + from: string; + to: string; + labelScores: Map; + score: number; + } +>; + +// ── Scoring configuration ─────────────────────────────────────────────────── + +export interface ScoringConfig { + /** + * Minimum score required to link an alert to at least one eligible parent. + * Score is computed as the sum of per-label scores. + */ + minEntityScore: number; + /** + * Per-field score overrides. Any field not present falls back to `defaultScorePerField`. + */ + entityFieldScores: Map; + defaultScorePerField: number; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/index.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/index.ts new file mode 100644 index 0000000000000..0f53ce718a7a5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/index.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 { registerWorkflowSteps } from './register_workflow_steps'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts new file mode 100644 index 0000000000000..978ad0ff0dc2d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts @@ -0,0 +1,33 @@ +/* + * 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 { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; +import type { CoreStart } from '@kbn/core/server'; +import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; +import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; +import { + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT, +} from '../../../common/constants'; + +/** + * Registers all security workflow steps with the workflowsExtensions plugin + */ +export const registerWorkflowSteps = async ( + workflowsExtensions: WorkflowsExtensionsServerPluginSetup, + coreStart: CoreStart +): Promise => { + const registerAlertValidationStepsEnabled = await coreStart.featureFlags.getBooleanValue( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ); + + if (registerAlertValidationStepsEnabled) { + workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition); + workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition); + } +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/index.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/index.ts new file mode 100644 index 0000000000000..685435e1e0c74 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; +export { buildNarrative } from './narrative_registry'; +export type { NarrativeStrategy } from './narrative_strategy'; +export type { AlertSource } from './narrative_utils'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.test.ts new file mode 100644 index 0000000000000..06ff096776a52 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { buildNarrative } from './narrative_registry'; + +describe('buildNarrative routing', () => { + it('routes DNS events to the DNS narrative', () => { + const text = buildNarrative({ + event: { category: ['network'] }, + dns: { question: { name: ['malware.com'], type: ['A'] } }, + host: { name: ['host-1'] }, + kibana: { alert: { severity: ['high'], rule: { name: ['DNS Rule'] } } }, + }); + + expect(text).toContain('DNS query for malware.com'); + }); + + it('routes cloud events to the cloud narrative', () => { + const text = buildNarrative({ + cloud: { provider: ['aws'], region: ['us-east-1'] }, + event: { action: ['ConsoleLogin'] }, + aws: { cloudtrail: { user_identity: { arn: ['arn:aws:iam::root'] } } }, + kibana: { alert: { rule: { name: ['AWS Login'] } } }, + }); + + expect(text).toContain('aws event ConsoleLogin'); + }); + + it('routes threat match events to the threat match narrative', () => { + const text = buildNarrative({ + threat: { indicator: { matched: { atomic: ['1.2.3.4'], type: ['ip'] } } }, + kibana: { alert: { rule: { type: ['threat_match'], name: ['IOC Match'] } } }, + }); + + expect(text).toContain('Threat indicator match'); + }); + + it('routes ML events to the machine learning narrative', () => { + const text = buildNarrative({ + event: { action: ['rare_process'] }, + host: { name: ['host-1'] }, + kibana: { alert: { rule: { type: ['machine_learning'], name: ['ML Rule'] } } }, + }); + + expect(text).toContain('Machine learning anomaly detected'); + }); + + it('routes authentication events to the auth narrative', () => { + const text = buildNarrative({ + event: { category: ['authentication'], action: ['user_logon'], outcome: ['success'] }, + user: { name: ['admin'] }, + host: { name: ['dc-01'] }, + kibana: { alert: { rule: { name: ['Auth Rule'] } } }, + }); + + expect(text).toContain('Authentication logon success'); + }); + + it('routes registry events to the registry narrative', () => { + const text = buildNarrative({ + event: { action: ['modification'] }, + registry: { key: ['HKLM\\Software\\Test'] }, + host: { name: ['host-1'] }, + kibana: { alert: { rule: { name: ['Registry Rule'] } } }, + }); + + expect(text).toContain('Registry modification'); + }); + + it('routes network events to the network narrative', () => { + const text = buildNarrative({ + event: { category: ['network'] }, + source: { ip: ['10.0.0.1'] }, + destination: { ip: ['10.0.0.2'], port: [443] }, + kibana: { alert: { rule: { name: ['Network Rule'] } } }, + }); + + expect(text).toContain('Network connection'); + }); + + it('routes file-only events to the file narrative', () => { + const text = buildNarrative({ + event: { category: ['file'], action: ['creation'] }, + file: { name: ['malware.exe'], path: ['/tmp/malware.exe'] }, + host: { name: ['host-1'] }, + kibana: { alert: { rule: { name: ['File Rule'] } } }, + }); + + expect(text).toContain('File creation'); + }); + + it('falls back to process narrative for process events', () => { + const text = buildNarrative({ + event: { category: ['process'], action: ['start'] }, + process: { name: ['bash'], pid: [1234] }, + user: { name: ['root'] }, + host: { name: ['server-1'] }, + kibana: { alert: { severity: ['low'], rule: { name: ['Process Rule'] } } }, + }); + + expect(text).toContain('process event'); + expect(text).toContain('started process bash'); + }); + + it('threat_match takes priority over network category', () => { + const text = buildNarrative({ + event: { category: ['network'] }, + source: { ip: ['1.2.3.4'] }, + threat: { indicator: { matched: { atomic: ['1.2.3.4'], type: ['ip'] } } }, + kibana: { alert: { rule: { type: ['threat_match'], name: ['IOC Match'] } } }, + }); + + expect(text).toContain('Threat indicator match'); + expect(text).not.toContain('Network connection'); + }); + + it('DNS takes priority over generic network', () => { + const text = buildNarrative({ + event: { category: ['network'] }, + dns: { question: { name: ['evil.com'] } }, + source: { ip: ['10.0.0.1'] }, + kibana: { alert: { rule: { name: ['DNS Alert'] } } }, + }); + + expect(text).toContain('DNS query'); + expect(text).not.toContain('Network connection'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.ts new file mode 100644 index 0000000000000..9ff47b81f842e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_registry.ts @@ -0,0 +1,49 @@ +/* + * 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 { NarrativeStrategy } from './narrative_strategy'; +import type { AlertSource } from './narrative_utils'; +import { + threatMatchStrategy, + machineLearningStrategy, + dnsStrategy, + cloudStrategy, + authenticationStrategy, + registryStrategy, + networkStrategy, + fileStrategy, + processStrategy, +} from './strategies'; + +/** + * All registered narrative strategies, sorted by descending priority. + * The first strategy whose `match` returns true produces the narrative. + */ +const strategies: NarrativeStrategy[] = [ + threatMatchStrategy, + machineLearningStrategy, + dnsStrategy, + cloudStrategy, + authenticationStrategy, + registryStrategy, + networkStrategy, + fileStrategy, + processStrategy, +].sort((a, b) => b.priority - a.priority); + +/** + * Selects the highest-priority matching strategy and builds the narrative. + * Always returns a value because `processStrategy` matches everything. + */ +export const buildNarrative = (source: AlertSource): string => { + const strategy = strategies.find((s) => s.match(source)); + if (!strategy) { + throw new Error('No strategy found for alert source'); + } + // processStrategy.match always returns true, so this is guaranteed + return strategy.build(source); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_strategy.ts new file mode 100644 index 0000000000000..3281ddf9f3eb2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_strategy.ts @@ -0,0 +1,33 @@ +/* + * 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 { AlertSource } from './narrative_utils'; + +/** + * A narrative strategy converts an alert _source document into a + * human-readable English string describing what happened. + * + * Strategies are evaluated in priority order (highest first). + * The first strategy whose `match` returns true wins. + */ +export interface NarrativeStrategy { + /** Unique identifier for debugging / logging. */ + readonly id: string; + + /** + * Higher-priority strategies are evaluated first. + * Use this to ensure specific strategies (e.g. threat_match) + * take precedence over generic ones (e.g. network). + */ + readonly priority: number; + + /** Returns true when this strategy can handle the given alert source. */ + match(source: AlertSource): boolean; + + /** Produces the narrative string for the matched alert. */ + build(source: AlertSource): string; +} diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_utils.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_utils.ts new file mode 100644 index 0000000000000..c89fc3824b05b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/narrative_utils.ts @@ -0,0 +1,103 @@ +/* + * 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 { get } from 'lodash/fp'; + +/** + * The raw `_source` of an alert document from Elasticsearch. + * + * Alert documents contain a mix of Kibana alert metadata (`kibana.alert.*`) + * and arbitrary ECS fields (`process.*`, `dns.*`, `cloud.*`, etc.) that vary + * by rule type and data source. The existing `DetectionAlert` types only cover + * the Kibana-managed fields and still include an open index signature, so they + * don't provide meaningful safety for the ECS fields we access via dot-path + * `get()` helpers. We keep this intentionally loose to reflect the reality of + * what Elasticsearch returns. + */ +export type AlertSource = Record; + +export const toStringArray = (value: unknown): string[] | undefined => { + if (value == null) return undefined; + + if (Array.isArray(value)) { + const out = value + .map((x) => (x == null ? '' : String(x))) + .map((x) => x.trim()) + .filter((x) => x.length > 0); + return out.length ? out : undefined; + } + + const asString = String(value).trim(); + return asString.length ? [asString] : undefined; +}; + +export const getValues = (source: AlertSource, field: string): string[] | undefined => + toStringArray(get(field, source)); + +export const joinValues = (values: string[] | undefined): string | undefined => + values != null && values.length ? values.join(', ') : undefined; + +export const getSingleValue = (source: AlertSource, field: string): string | undefined => + joinValues(getValues(source, field)); + +export const getNumberValue = (source: AlertSource, field: string): number | undefined => { + const v = get(field, source); + if (v == null) return undefined; + if (Array.isArray(v)) { + const first = v[0]; + const asNumber = typeof first === 'number' ? first : Number(first); + return Number.isFinite(asNumber) ? asNumber : undefined; + } + const asNumber = typeof v === 'number' ? v : Number(v); + return Number.isFinite(asNumber) ? asNumber : undefined; +}; + +export const normalizeSpaces = (text: string): string => + text + .replace(/,\s*/g, ', ') + .replace(/\s+([,.:;])/g, '$1') + .replace(/\s+/g, ' ') + .trim(); + +export const categoryIs = (source: AlertSource, value: string): boolean => + getValues(source, 'event.category')?.some((c) => c.toLowerCase() === value) ?? false; + +export const datasetIs = (source: AlertSource, value: string): boolean => + getSingleValue(source, 'event.dataset')?.toLowerCase() === value; + +export const ruleTypeIs = (source: AlertSource, value: string): boolean => + getSingleValue(source, 'kibana.alert.rule.type')?.toLowerCase() === value; + +export const appendAlertSuffix = (text: string, source: AlertSource): string => { + const severity = getSingleValue(source, 'kibana.alert.severity'); + const ruleName = getSingleValue(source, 'kibana.alert.rule.name'); + let result = text; + + if (severity != null) { + result += ` created ${severity} alert`; + if (ruleName != null) { + result += ` ${ruleName}.`; + } else { + result += '.'; + } + } else if (ruleName != null) { + result += ` ${ruleName}.`; + } + + return result; +}; + +export const appendUserHostContext = (text: string, source: AlertSource): string => { + const userName = getSingleValue(source, 'user.name'); + const hostName = getSingleValue(source, 'host.name'); + let result = text; + + if (userName != null) result += ` by ${userName}`; + if (hostName != null) result += ` on ${hostName}`; + + return result; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts new file mode 100644 index 0000000000000..c7f590a3a6524 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step.ts @@ -0,0 +1,152 @@ +/* + * 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 { createServerStepDefinition } from '@kbn/workflows-extensions/server'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; + +import { renderAlertNarrativeStepCommonDefinition } from '../../../../common/workflows/step_types/render_alert_narrative_step/render_alert_narrative_step_common'; +import type { AlertSource } from './narrative_utils'; +import { buildNarrative } from './narrative_registry'; + +const SOURCE_INCLUDES = [ + // Core event fields + 'event.category', + 'event.action', + 'event.outcome', + 'event.dataset', + 'event.module', + + // Process fields + 'process.name', + 'process.pid', + 'process.args', + 'process.title', + 'process.working_directory', + 'process.exit_code', + 'process.ppid', + 'process.executable', + 'process.hash.sha256', + 'process.parent.name', + 'process.parent.pid', + + // File fields + 'file.name', + 'file.path', + 'file.hash.sha256', + 'file.extension', + 'file.size', + + // Network fields + 'source.ip', + 'source.port', + 'destination.ip', + 'destination.port', + 'network.protocol', + 'network.transport', + 'network.direction', + 'network.bytes', + + // DNS fields + 'dns.question.name', + 'dns.question.type', + 'dns.resolved_ip', + 'dns.response_code', + + // Authentication fields + 'source.as.organization.name', + + // Registry fields (Windows) + 'registry.key', + 'registry.path', + 'registry.data.strings', + 'registry.value', + + // Cloud fields + 'cloud.provider', + 'cloud.account.id', + 'cloud.region', + 'cloud.service.name', + 'aws.cloudtrail.user_identity.arn', + 'aws.cloudtrail.user_identity.type', + 'aws.cloudtrail.event_type', + 'aws.cloudtrail.error_code', + 'aws.cloudtrail.request_parameters', + 'azure.auditlogs.properties.initiated_by.user.user_principal_name', + 'azure.activitylogs.identity.claims_initiated_by_user.name', + 'gcp.audit.authentication_info.principal_email', + 'gcp.audit.method_name', + 'gcp.audit.resource_name', + + // Threat indicator fields + 'threat.indicator.matched.atomic', + 'threat.indicator.matched.type', + 'threat.indicator.matched.field', + 'threat.indicator.provider', + 'threat.feed.name', + + // Identity fields + 'user.name', + 'user.domain', + 'user.id', + 'host.name', + 'host.id', + 'host.os.name', + + // Alert metadata + 'kibana.alert.severity', + 'kibana.alert.rule.name', + 'kibana.alert.rule.type', + + 'message', +] as const; + +const getAlertSource = async ({ + esClient, + alertIndex, + alertId, +}: { + esClient: ElasticsearchClient; + alertIndex: string; + alertId: string; +}): Promise => { + const response = await esClient.get({ + index: alertIndex, + id: alertId, + _source_includes: [...SOURCE_INCLUDES], + }); + + return response._source ?? {}; +}; + +export const renderAlertNarrativeStepDefinition = createServerStepDefinition({ + ...renderAlertNarrativeStepCommonDefinition, + handler: async (context) => { + try { + const { alertId, alertIndex } = context.input; + const esClient = context.contextManager.getScopedEsClient(); + + const source = await getAlertSource({ esClient, alertId, alertIndex }); + const timelineString = buildNarrative(source); + + return { + output: { + alert_id: alertId, + alert_index: alertIndex, + timeline_string: timelineString, + message: `Generated a Timeline-like string for alert ${alertId}.`, + }, + }; + } catch (error) { + context.logger.error('Failed to generate alert timeline string', error); + return { + error: new Error( + error instanceof Error ? error.message : 'Failed to generate alert timeline string' + ), + }; + } + }, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.test.ts new file mode 100644 index 0000000000000..75ff346403697 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { authenticationStrategy, buildAuthenticationNarrative } from './authentication_strategy'; + +describe('authenticationStrategy', () => { + describe('match', () => { + it('returns true for authentication category', () => { + expect(authenticationStrategy.match({ event: { category: ['authentication'] } })).toBe(true); + }); + + it('returns true for endpoint security dataset', () => { + expect( + authenticationStrategy.match({ event: { dataset: ['endpoint.events.security'] } }) + ).toBe(true); + }); + + it('returns false for process events', () => { + expect(authenticationStrategy.match({ event: { category: ['process'] } })).toBe(false); + }); + }); + + describe('buildAuthenticationNarrative', () => { + it('builds a logon success narrative', () => { + const text = buildAuthenticationNarrative({ + event: { category: ['authentication'], action: ['user_logon'], outcome: ['success'] }, + user: { name: ['admin'] }, + host: { name: ['dc-01'] }, + source: { ip: ['192.168.1.100'], as: { organization: { name: ['Corp Network'] } } }, + process: { name: ['sshd'] }, + kibana: { + alert: { severity: ['medium'], rule: { name: ['Unusual Login Activity'] } }, + }, + }); + + expect(text).toBe( + 'Authentication logon success by admin on dc-01 from 192.168.1.100 (Corp Network) via sshd created medium alert Unusual Login Activity.' + ); + }); + + it('builds a logoff narrative', () => { + expect( + buildAuthenticationNarrative({ + event: { category: ['authentication'], action: ['user_logoff'] }, + user: { name: ['bob'] }, + host: { name: ['host-1'] }, + }) + ).toBe('Authentication logoff by bob on host-1'); + }); + + it('builds a generic auth event', () => { + const text = buildAuthenticationNarrative({ + event: { + category: ['authentication'], + action: ['kerberos_ticket_request'], + outcome: ['failure'], + }, + user: { name: ['svc-account'] }, + host: { name: ['dc-02'] }, + kibana: { alert: { severity: ['high'], rule: { name: ['Kerberoasting'] } } }, + }); + + expect(text).toBe( + 'Authentication event kerberos_ticket_request (failure) by svc-account on dc-02 created high alert Kerberoasting.' + ); + }); + + it('handles minimal auth data', () => { + expect(buildAuthenticationNarrative({ event: { category: ['authentication'] } })).toBe( + 'Authentication event' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.ts new file mode 100644 index 0000000000000..2b9476b184a36 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/authentication_strategy.ts @@ -0,0 +1,56 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + normalizeSpaces, + categoryIs, + datasetIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildAuthenticationNarrative = (source: AlertSource): string => { + const eventAction = getSingleValue(source, 'event.action'); + const outcome = getSingleValue(source, 'event.outcome'); + const sourceIp = getSingleValue(source, 'source.ip'); + const sourceOrg = getSingleValue(source, 'source.as.organization.name'); + const processName = getSingleValue(source, 'process.name'); + + const action = (eventAction ?? '').toLowerCase(); + let text: string; + + if (action.includes('logon') || action.includes('log_on') || action.includes('login')) { + text = `Authentication logon ${outcome ?? 'attempt'}`; + } else if (action.includes('logoff') || action.includes('log_off') || action.includes('logout')) { + text = 'Authentication logoff'; + } else { + text = `Authentication event${eventAction != null ? ` ${eventAction}` : ''}`; + if (outcome != null) text += ` (${outcome})`; + } + + text = appendUserHostContext(text, source); + + if (sourceIp != null) { + text += ` from ${sourceIp}`; + if (sourceOrg != null) text += ` (${sourceOrg})`; + } + if (processName != null) text += ` via ${processName}`; + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const authenticationStrategy: NarrativeStrategy = { + id: 'authentication', + priority: 50, + match: (source) => + categoryIs(source, 'authentication') || datasetIs(source, 'endpoint.events.security'), + build: buildAuthenticationNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.test.ts new file mode 100644 index 0000000000000..d7ed66d7b871d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { cloudStrategy, buildCloudNarrative } from './cloud_strategy'; + +describe('cloudStrategy', () => { + describe('match', () => { + it('returns true for AWS CloudTrail events', () => { + expect( + cloudStrategy.match({ + aws: { cloudtrail: { user_identity: { arn: ['arn:aws:iam::root'] } } }, + }) + ).toBe(true); + }); + + it('returns true when cloud.provider is present', () => { + expect(cloudStrategy.match({ cloud: { provider: ['aws'] } })).toBe(true); + }); + + it('returns true for Azure events', () => { + expect( + cloudStrategy.match({ + azure: { + auditlogs: { + properties: { + initiated_by: { user: { user_principal_name: ['admin@corp.com'] } }, + }, + }, + }, + }) + ).toBe(true); + }); + + it('returns true for GCP events', () => { + expect( + cloudStrategy.match({ + gcp: { audit: { authentication_info: { principal_email: ['user@corp.gcp'] } } }, + }) + ).toBe(true); + }); + + it('returns false for non-cloud events', () => { + expect(cloudStrategy.match({ event: { category: ['process'] } })).toBe(false); + }); + }); + + describe('buildCloudNarrative', () => { + it('builds an AWS CloudTrail narrative', () => { + const text = buildCloudNarrative({ + cloud: { provider: ['aws'], region: ['us-east-1'], account: { id: ['123456789012'] } }, + event: { action: ['ConsoleLogin'], outcome: ['success'] }, + aws: { + cloudtrail: { + user_identity: { arn: ['arn:aws:iam::root'], type: ['Root'] }, + event_type: ['AwsConsoleSignIn'], + }, + }, + kibana: { + alert: { severity: ['critical'], rule: { name: ['AWS Root Console Login'] } }, + }, + }); + + expect(text).toBe( + 'aws event ConsoleLogin by arn:aws:iam::root (Root), event type AwsConsoleSignIn, account 123456789012, region us-east-1 with result success created critical alert AWS Root Console Login.' + ); + }); + + it('builds a GCP audit narrative', () => { + const text = buildCloudNarrative({ + cloud: { provider: ['gcp'], region: ['us-central1'] }, + event: { action: ['SetIamPolicy'] }, + gcp: { + audit: { + authentication_info: { principal_email: ['admin@corp.gcp'] }, + method_name: ['SetIamPolicy'], + resource_name: ['projects/my-project'], + }, + }, + kibana: { alert: { severity: ['medium'], rule: { name: ['GCP IAM Change'] } } }, + }); + + expect(text).toBe( + 'gcp event SetIamPolicy by admin@corp.gcp, method SetIamPolicy on resource projects/my-project, region us-central1 created medium alert GCP IAM Change.' + ); + }); + + it('builds an Azure narrative', () => { + const text = buildCloudNarrative({ + cloud: { provider: ['azure'] }, + event: { action: ['UserLoggedIn'] }, + azure: { + auditlogs: { + properties: { + initiated_by: { user: { user_principal_name: ['admin@corp.com'] } }, + }, + }, + }, + kibana: { alert: { severity: ['low'], rule: { name: ['Azure Sign-In'] } } }, + }); + + expect(text).toBe( + 'azure event UserLoggedIn by admin@corp.com created low alert Azure Sign-In.' + ); + }); + + it('handles minimal cloud data', () => { + expect(buildCloudNarrative({ cloud: { provider: ['aws'] } })).toBe('aws event'); + }); + + it('builds a narrative with AWS error code', () => { + const text = buildCloudNarrative({ + cloud: { provider: ['aws'] }, + event: { action: ['RunInstances'], outcome: ['failure'] }, + aws: { + cloudtrail: { + user_identity: { arn: ['arn:aws:iam::user/dev'] }, + error_code: ['UnauthorizedAccess'], + }, + }, + kibana: { + alert: { severity: ['high'], rule: { name: ['AWS Unauthorized API Call'] } }, + }, + }); + + expect(text).toBe( + 'aws event RunInstances by arn:aws:iam::user/dev with result failure (error: UnauthorizedAccess) created high alert AWS Unauthorized API Call.' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.ts new file mode 100644 index 0000000000000..5a59980d6518d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/cloud_strategy.ts @@ -0,0 +1,72 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { getSingleValue, normalizeSpaces, appendAlertSuffix } from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildCloudNarrative = (source: AlertSource): string => { + const provider = getSingleValue(source, 'cloud.provider'); + const region = getSingleValue(source, 'cloud.region'); + const accountId = getSingleValue(source, 'cloud.account.id'); + const serviceName = getSingleValue(source, 'cloud.service.name'); + const eventAction = getSingleValue(source, 'event.action'); + + const awsArn = getSingleValue(source, 'aws.cloudtrail.user_identity.arn'); + const awsIdentityType = getSingleValue(source, 'aws.cloudtrail.user_identity.type'); + const awsEventType = getSingleValue(source, 'aws.cloudtrail.event_type'); + const awsErrorCode = getSingleValue(source, 'aws.cloudtrail.error_code'); + + const azurePrincipal = getSingleValue( + source, + 'azure.auditlogs.properties.initiated_by.user.user_principal_name' + ); + const azureActivityUser = getSingleValue( + source, + 'azure.activitylogs.identity.claims_initiated_by_user.name' + ); + + const gcpEmail = getSingleValue(source, 'gcp.audit.authentication_info.principal_email'); + const gcpMethod = getSingleValue(source, 'gcp.audit.method_name'); + const gcpResource = getSingleValue(source, 'gcp.audit.resource_name'); + + const outcome = getSingleValue(source, 'event.outcome'); + + let text = `${provider ?? 'cloud'} event`; + + if (eventAction != null) text += ` ${eventAction}`; + + const principal = awsArn ?? azurePrincipal ?? azureActivityUser ?? gcpEmail; + if (principal != null) text += ` by ${principal}`; + if (awsIdentityType != null) text += ` (${awsIdentityType})`; + + if (awsEventType != null) text += `, event type ${awsEventType}`; + if (gcpMethod != null) text += `, method ${gcpMethod}`; + if (gcpResource != null) text += ` on resource ${gcpResource}`; + if (serviceName != null) text += `, service ${serviceName}`; + if (accountId != null) text += `, account ${accountId}`; + if (region != null) text += `, region ${region}`; + + if (outcome != null) text += ` with result ${outcome}`; + if (awsErrorCode != null) text += ` (error: ${awsErrorCode})`; + + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const cloudStrategy: NarrativeStrategy = { + id: 'cloud', + priority: 60, + match: (source) => + getSingleValue(source, 'cloud.provider') != null || + getSingleValue(source, 'aws.cloudtrail.user_identity.arn') != null || + getSingleValue(source, 'azure.auditlogs.properties.initiated_by.user.user_principal_name') != + null || + getSingleValue(source, 'gcp.audit.authentication_info.principal_email') != null, + build: buildCloudNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.test.ts new file mode 100644 index 0000000000000..0b72f9db14972 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { dnsStrategy, buildDnsNarrative } from './dns_strategy'; + +describe('dnsStrategy', () => { + describe('match', () => { + it('returns true when dns.question.name is present', () => { + expect(dnsStrategy.match({ dns: { question: { name: ['evil.com'] } } })).toBe(true); + }); + + it('returns false when dns.question.name is absent', () => { + expect(dnsStrategy.match({ event: { category: ['network'] } })).toBe(false); + }); + }); + + describe('buildDnsNarrative', () => { + it('builds a full DNS narrative with all fields', () => { + const text = buildDnsNarrative({ + dns: { + question: { name: ['evil.com'], type: ['A'] }, + resolved_ip: ['1.2.3.4', '5.6.7.8'], + response_code: ['NOERROR'], + }, + process: { name: ['chrome'] }, + user: { name: ['alice'] }, + host: { name: ['host-1'] }, + kibana: { alert: { severity: ['high'], rule: { name: ['Suspicious DNS Query'] } } }, + }); + + expect(text).toBe( + 'DNS query for evil.com (A) from process chrome by alice on host-1 resolved to 1.2.3.4, 5.6.7.8 with response NOERROR created high alert Suspicious DNS Query.' + ); + }); + + it('handles minimal DNS data', () => { + expect(buildDnsNarrative({ dns: { question: { name: ['test.com'] } } })).toBe( + 'DNS query for test.com' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.ts new file mode 100644 index 0000000000000..a843828b9ce07 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/dns_strategy.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + getValues, + joinValues, + normalizeSpaces, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildDnsNarrative = (source: AlertSource): string => { + const questionName = getSingleValue(source, 'dns.question.name'); + const questionType = getSingleValue(source, 'dns.question.type'); + const resolvedIp = joinValues(getValues(source, 'dns.resolved_ip')); + const responseCode = getSingleValue(source, 'dns.response_code'); + const processName = getSingleValue(source, 'process.name'); + + let text = 'DNS query'; + if (questionName != null) text += ` for ${questionName}`; + if (questionType != null) text += ` (${questionType})`; + if (processName != null) text += ` from process ${processName}`; + text = appendUserHostContext(text, source); + if (resolvedIp != null) text += ` resolved to ${resolvedIp}`; + if (responseCode != null) text += ` with response ${responseCode}`; + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const dnsStrategy: NarrativeStrategy = { + id: 'dns', + priority: 70, + match: (source) => getSingleValue(source, 'dns.question.name') != null, + build: buildDnsNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.test.ts new file mode 100644 index 0000000000000..11b0a7da41d5d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { fileStrategy, buildFileNarrative } from './file_strategy'; + +describe('fileStrategy', () => { + describe('match', () => { + it('returns true for file events without process context', () => { + expect( + fileStrategy.match({ event: { category: ['file'] }, file: { name: ['test.txt'] } }) + ).toBe(true); + }); + + it('returns false when a process is present', () => { + expect( + fileStrategy.match({ + event: { category: ['file'] }, + file: { name: ['test.txt'] }, + process: { name: ['vim'] }, + }) + ).toBe(false); + }); + }); + + describe('buildFileNarrative', () => { + it('builds a full file narrative', () => { + const text = buildFileNarrative({ + event: { category: ['file'], action: ['creation'] }, + file: { + name: ['evil.exe'], + path: ['C:\\Users\\admin\\Downloads\\evil.exe'], + hash: { sha256: ['abc123def456'] }, + extension: ['exe'], + size: [2048], + }, + user: { name: ['admin'] }, + host: { name: ['workstation-1'] }, + kibana: { + alert: { severity: ['critical'], rule: { name: ['Malware File Created'] } }, + }, + }); + + expect(text).toBe( + 'File creation C:\\Users\\admin\\Downloads\\evil.exe (exe), 2048 bytes sha256:abc123def456 by admin on workstation-1 created critical alert Malware File Created.' + ); + }); + + it('uses file.name when file.path is absent', () => { + expect( + buildFileNarrative({ + event: { category: ['file'], action: ['deletion'] }, + file: { name: ['config.yaml'] }, + host: { name: ['server-1'] }, + }) + ).toBe('File deletion config.yaml on server-1'); + }); + + it('handles minimal file data', () => { + expect( + buildFileNarrative({ event: { category: ['file'] }, file: { name: ['unknown.dat'] } }) + ).toBe('File event unknown.dat'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.ts new file mode 100644 index 0000000000000..bf294272dbd07 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/file_strategy.ts @@ -0,0 +1,62 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + getNumberValue, + normalizeSpaces, + categoryIs, + datasetIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildFileNarrative = (source: AlertSource): string => { + const eventAction = getSingleValue(source, 'event.action'); + const fileName = getSingleValue(source, 'file.name'); + const filePath = getSingleValue(source, 'file.path'); + const fileHash = getSingleValue(source, 'file.hash.sha256'); + const fileExtension = getSingleValue(source, 'file.extension'); + const fileSize = getNumberValue(source, 'file.size'); + + let text = `File ${eventAction ?? 'event'}`; + + if (filePath != null) { + text += ` ${filePath}`; + } else if (fileName != null) { + text += ` ${fileName}`; + } + + if (fileExtension != null) text += ` (${fileExtension})`; + if (fileSize != null) text += `, ${fileSize} bytes`; + if (fileHash != null) text += ` sha256:${fileHash}`; + + text = appendUserHostContext(text, source); + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const fileStrategy: NarrativeStrategy = { + id: 'file', + priority: 10, + match: (source) => { + const hasFile = + getSingleValue(source, 'file.path') != null || getSingleValue(source, 'file.name') != null; + const hasProcess = getSingleValue(source, 'process.name') != null; + return ( + hasFile && + !hasProcess && + (categoryIs(source, 'file') || + datasetIs(source, 'endpoint.events.file') || + datasetIs(source, 'file')) + ); + }, + build: buildFileNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/index.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/index.ts new file mode 100644 index 0000000000000..7fd79d02fbbc9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { dnsStrategy, buildDnsNarrative } from './dns_strategy'; +export { cloudStrategy, buildCloudNarrative } from './cloud_strategy'; +export { threatMatchStrategy, buildThreatMatchNarrative } from './threat_match_strategy'; +export { + machineLearningStrategy, + buildMachineLearningNarrative, +} from './machine_learning_strategy'; +export { authenticationStrategy, buildAuthenticationNarrative } from './authentication_strategy'; +export { registryStrategy, buildRegistryNarrative } from './registry_strategy'; +export { networkStrategy, buildNetworkNarrative } from './network_strategy'; +export { fileStrategy, buildFileNarrative } from './file_strategy'; +export { + processStrategy, + buildAlertTimelineString, + buildProcessTimelineString, +} from './process_strategy'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.test.ts new file mode 100644 index 0000000000000..21b92feeaf0b1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { + machineLearningStrategy, + buildMachineLearningNarrative, +} from './machine_learning_strategy'; + +describe('machineLearningStrategy', () => { + describe('match', () => { + it('returns true when rule type is machine_learning', () => { + expect( + machineLearningStrategy.match({ + kibana: { alert: { rule: { type: ['machine_learning'] } } }, + }) + ).toBe(true); + }); + + it('returns false for other rule types', () => { + expect( + machineLearningStrategy.match({ kibana: { alert: { rule: { type: ['query'] } } } }) + ).toBe(false); + }); + }); + + describe('buildMachineLearningNarrative', () => { + it('builds an ML narrative with action and context', () => { + const text = buildMachineLearningNarrative({ + event: { action: ['rare_process_by_host'] }, + user: { name: ['alice'] }, + host: { name: ['workstation-42'] }, + source: { ip: ['10.0.0.5'] }, + kibana: { + alert: { + severity: ['medium'], + rule: { + name: ['Anomalous Process For a Windows Population'], + type: ['machine_learning'], + }, + }, + }, + }); + + expect(text).toBe( + 'Machine learning anomaly detected: rare_process_by_host by alice on workstation-42 source 10.0.0.5 created medium alert Anomalous Process For a Windows Population.' + ); + }); + + it('handles minimal ML data', () => { + expect( + buildMachineLearningNarrative({ + kibana: { alert: { rule: { type: ['machine_learning'] } } }, + }) + ).toBe('Machine learning anomaly detected'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.ts new file mode 100644 index 0000000000000..c2b5f851642bd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/machine_learning_strategy.ts @@ -0,0 +1,44 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + normalizeSpaces, + ruleTypeIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildMachineLearningNarrative = (source: AlertSource): string => { + const eventAction = getSingleValue(source, 'event.action'); + + let text = 'Machine learning anomaly detected'; + if (eventAction != null) text += `: ${eventAction}`; + + text = appendUserHostContext(text, source); + + const sourceIp = getSingleValue(source, 'source.ip'); + const destinationIp = getSingleValue(source, 'destination.ip'); + if (sourceIp != null) text += ` source ${sourceIp}`; + if (destinationIp != null) text += ` destination ${destinationIp}`; + + const message = getSingleValue(source, 'message'); + if (message != null) text += ` ${message}`; + + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const machineLearningStrategy: NarrativeStrategy = { + id: 'machine_learning', + priority: 90, + match: (source) => ruleTypeIs(source, 'machine_learning'), + build: buildMachineLearningNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.test.ts new file mode 100644 index 0000000000000..4bc072f23bb21 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { networkStrategy, buildNetworkNarrative } from './network_strategy'; + +describe('networkStrategy', () => { + describe('match', () => { + it('returns true for network category without DNS fields', () => { + expect( + networkStrategy.match({ + event: { category: ['network'] }, + source: { ip: ['10.0.0.1'] }, + }) + ).toBe(true); + }); + + it('returns false when DNS fields are present', () => { + expect( + networkStrategy.match({ + event: { category: ['network'] }, + dns: { question: { name: ['example.com'] } }, + }) + ).toBe(false); + }); + }); + + describe('buildNetworkNarrative', () => { + it('builds a full network narrative', () => { + const text = buildNetworkNarrative({ + event: { category: ['network'], action: ['connection_attempted'] }, + source: { ip: ['10.0.0.5'], port: [49152] }, + destination: { ip: ['185.220.101.1'], port: [443] }, + network: { + protocol: ['https'], + transport: ['tcp'], + direction: ['outbound'], + bytes: [42000], + }, + process: { name: ['firefox'] }, + user: { name: ['alice'] }, + host: { name: ['laptop-1'] }, + kibana: { + alert: { severity: ['high'], rule: { name: ['Connection to Known C2'] } }, + }, + }); + + expect(text).toBe( + 'Network outbound connection (connection_attempted) from 10.0.0.5:49152 to 185.220.101.1:443 via tcp/https (42000 bytes) process firefox by alice on laptop-1 created high alert Connection to Known C2.' + ); + }); + + it('handles minimal network data', () => { + expect( + buildNetworkNarrative({ + event: { category: ['network'] }, + source: { ip: ['10.0.0.1'] }, + destination: { ip: ['10.0.0.2'] }, + }) + ).toBe('Network connection from 10.0.0.1 to 10.0.0.2'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.ts new file mode 100644 index 0000000000000..646d7f1db653e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/network_strategy.ts @@ -0,0 +1,63 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + getNumberValue, + normalizeSpaces, + categoryIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildNetworkNarrative = (source: AlertSource): string => { + const eventAction = getSingleValue(source, 'event.action'); + const sourceIp = getSingleValue(source, 'source.ip'); + const sourcePort = getSingleValue(source, 'source.port'); + const destinationIp = getSingleValue(source, 'destination.ip'); + const destinationPort = getSingleValue(source, 'destination.port'); + const protocol = getSingleValue(source, 'network.protocol'); + const transport = getSingleValue(source, 'network.transport'); + const direction = getSingleValue(source, 'network.direction'); + const bytes = getNumberValue(source, 'network.bytes'); + const processName = getSingleValue(source, 'process.name'); + + let text = 'Network'; + if (direction != null) text += ` ${direction}`; + text += ` connection`; + if (eventAction != null) text += ` (${eventAction})`; + + if (sourceIp != null) { + text += ` from ${sourceIp}`; + if (sourcePort != null) text += `:${sourcePort}`; + } + if (destinationIp != null) { + text += ` to ${destinationIp}`; + if (destinationPort != null) text += `:${destinationPort}`; + } + + if (protocol != null || transport != null) { + text += ` via ${[transport, protocol].filter(Boolean).join('/')}`; + } + + if (bytes != null) text += ` (${bytes} bytes)`; + if (processName != null) text += ` process ${processName}`; + text = appendUserHostContext(text, source); + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const networkStrategy: NarrativeStrategy = { + id: 'network', + priority: 20, + match: (source) => + categoryIs(source, 'network') && getSingleValue(source, 'dns.question.name') == null, + build: buildNetworkNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.test.ts new file mode 100644 index 0000000000000..80b239ab68978 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { + processStrategy, + buildAlertTimelineString, + buildProcessTimelineString, +} from './process_strategy'; + +describe('processStrategy', () => { + describe('match', () => { + it('always returns true (fallback strategy)', () => { + expect(processStrategy.match({})).toBe(true); + expect(processStrategy.match({ event: { category: ['process'] } })).toBe(true); + }); + }); + + describe('buildAlertTimelineString', () => { + it('generates a Timeline-like string similar to the alert row renderer', () => { + const text = buildAlertTimelineString({ + event: { category: ['process'] }, + process: { name: ['bash'], parent: { name: ['bash'] } }, + user: { name: ['patrykkopycinski'] }, + host: { name: ['patryk-defend-367602-1'] }, + kibana: { + alert: { + severity: ['high'], + rule: { name: ['Potential Reverse Shell Activity via Terminal'] }, + }, + }, + }); + + expect(text).toBe( + 'process event with process bash, parent process bash, by patrykkopycinski on patryk-defend-367602-1 created high alert Potential Reverse Shell Activity via Terminal.' + ); + }); + + it('renders source ip:port and destination ip:port when both are present', () => { + const text = buildAlertTimelineString({ + event: { category: ['network'] }, + process: { name: ['curl'] }, + source: { ip: ['10.0.0.1'], port: [8080] }, + destination: { ip: ['192.168.1.1'], port: [443] }, + kibana: { alert: { severity: ['medium'], rule: { name: ['rule-1'] } } }, + }); + + expect(text).toBe( + 'network event with process curl, source 10.0.0.1:8080, destination 192.168.1.1:443, created medium alert rule-1.' + ); + }); + + it('omits port when the corresponding ip is absent', () => { + const text = buildAlertTimelineString({ + event: { category: ['process'] }, + process: { name: ['bash'] }, + source: { port: [8080] }, + destination: { port: [443] }, + kibana: { alert: { severity: ['high'], rule: { name: ['rule-2'] } } }, + }); + + expect(text).toBe('process event with process bash, created high alert rule-2.'); + }); + + it('renders ip without port when port is absent', () => { + const text = buildAlertTimelineString({ + event: { category: ['network'] }, + source: { ip: ['10.0.0.1'] }, + destination: { ip: ['192.168.1.1'] }, + kibana: { alert: { severity: ['low'], rule: { name: ['rule-3'] } } }, + }); + + expect(text).toBe( + 'network event with source 10.0.0.1, destination 192.168.1.1, created low alert rule-3.' + ); + }); + + it('does not render "with" when none of the with-fields exist', () => { + const text = buildAlertTimelineString({ + event: { category: ['process'] }, + user: { name: ['alice'] }, + host: { name: ['host-1'] }, + kibana: { alert: { severity: ['low'], rule: { name: ['rule-1'] } } }, + }); + + expect(text).toBe('process event by alice on host-1 created low alert rule-1.'); + }); + }); + + describe('buildProcessTimelineString', () => { + it('generates a Timeline-like string similar to the system/endpoint process renderer', () => { + const text = buildProcessTimelineString({ + event: { action: ['fork'], outcome: ['unknown'] }, + user: { name: ['patrykkopycinski'] }, + host: { name: ['patryk-defend-367602-1'] }, + process: { + working_directory: ['/home/patrykkopycinski'], + name: ['bash'], + pid: [122765], + args: ['bash', '-c', 'echo', 'done'], + exit_code: [1], + parent: { name: ['bash'], pid: [122759] }, + ppid: [122759], + hash: { + sha256: ['59474588a312b6b6e73e5a42a59bf71e62b55416b6c9d5e4a6e1c630c2a9ecd4'], + }, + }, + }); + + expect(text).toBe( + 'patrykkopycinski@patryk-defend-367602-1 in /home/patrykkopycinski forked process bash (122765) bash -c echo done with exit code 1 via parent process bash (122759) (122759) with result unknown 59474588a312b6b6e73e5a42a59bf71e62b55416b6c9d5e4a6e1c630c2a9ecd4' + ); + }); + }); + + describe('build (combined)', () => { + it('returns alert + process segments when both are available', () => { + const text = processStrategy.build({ + event: { category: ['process'], action: ['fork'], outcome: ['unknown'] }, + user: { name: ['patrykkopycinski'] }, + host: { name: ['patryk-defend-367602-1'] }, + process: { name: ['bash'], parent: { name: ['bash'] }, pid: [122765] }, + kibana: { + alert: { + severity: ['high'], + rule: { name: ['Potential Reverse Shell Activity via Terminal'] }, + }, + }, + }); + + expect(text).toBe( + 'process event with process bash, parent process bash, by patrykkopycinski on patryk-defend-367602-1 created high alert Potential Reverse Shell Activity via Terminal. ' + + 'patrykkopycinski@patryk-defend-367602-1 forked process bash (122765) via parent process bash with result unknown' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.ts new file mode 100644 index 0000000000000..2f738b692aa85 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/process_strategy.ts @@ -0,0 +1,232 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + getValues, + joinValues, + getNumberValue, + toStringArray, + normalizeSpaces, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +const shouldShowWith = (source: AlertSource): boolean => + [ + 'destination.ip', + 'destination.port', + 'file.name', + 'process.name', + 'process.parent.name', + 'source.ip', + 'source.port', + ].some((field) => (getValues(source, field)?.length ?? 0) > 0); + +export const buildAlertTimelineString = (source: AlertSource): string => { + const eventCategory = joinValues(getValues(source, 'event.category')); + const processName = joinValues(getValues(source, 'process.name')); + const processParentName = joinValues(getValues(source, 'process.parent.name')); + const fileName = joinValues(getValues(source, 'file.name')); + const sourceIp = joinValues(getValues(source, 'source.ip')); + const sourcePort = joinValues(getValues(source, 'source.port')); + const destinationIp = joinValues(getValues(source, 'destination.ip')); + const destinationPort = joinValues(getValues(source, 'destination.port')); + const userName = joinValues(getValues(source, 'user.name')); + const hostName = joinValues(getValues(source, 'host.name')); + const severity = joinValues(getValues(source, 'kibana.alert.severity')); + const ruleName = joinValues(getValues(source, 'kibana.alert.rule.name')); + + let text = `${eventCategory ?? 'alert'} event`; + + if (shouldShowWith(source)) { + text += ' with'; + } + + if (processName != null) text += ` process ${processName},`; + if (processParentName != null) text += ` parent process ${processParentName},`; + if (fileName != null) text += ` file ${fileName},`; + + if (sourceIp != null) { + text += ` source ${sourceIp}`; + if (sourcePort != null) text += `:${sourcePort}`; + text += ','; + } + + if (destinationIp != null) { + text += ` destination ${destinationIp}`; + if (destinationPort != null) text += `:${destinationPort}`; + text += ','; + } + + if (userName != null) text += ` by ${userName}`; + if (hostName != null) text += ` on ${hostName}`; + + if (severity != null) { + text += ` created ${severity} alert`; + if (ruleName != null) { + text += ` ${ruleName}.`; + } else { + text += '.'; + } + } else if (ruleName != null) { + text += ` ${ruleName}.`; + } + + return text.replace(/,\s*/g, ', ').replace(/\s+/g, ' ').trim(); +}; + +const getProcessActionText = (eventAction: string | undefined): string => { + const action = (eventAction ?? '').toLowerCase(); + switch (action) { + case 'fork': + return 'forked process'; + case 'exec': + return 'executed process'; + case 'start': + case 'process_started': + case 'creation_event': + case 'creation': + return 'started process'; + case 'end': + case 'termination_event': + case 'process_stopped': + case 'deletion_event': + return 'terminated process'; + default: + return 'process'; + } +}; + +const getArgs = (source: AlertSource): string[] | undefined => { + const raw = getValues(source, 'process.args'); + const values = toStringArray(raw); + return values?.length ? values : undefined; +}; + +export const buildProcessTimelineString = (source: AlertSource): string => { + const userName = getSingleValue(source, 'user.name'); + const userDomain = getSingleValue(source, 'user.domain'); + const hostName = getSingleValue(source, 'host.name'); + const workingDirectory = getSingleValue(source, 'process.working_directory'); + + const eventAction = getSingleValue(source, 'event.action'); + const actionText = getProcessActionText(eventAction); + + const processName = + getSingleValue(source, 'process.name') ?? getSingleValue(source, 'process.executable'); + const processPid = getNumberValue(source, 'process.pid'); + const args = getArgs(source); + const processTitle = getSingleValue(source, 'process.title'); + + const processExitCode = getNumberValue(source, 'process.exit_code'); + + const parentName = getSingleValue(source, 'process.parent.name'); + const parentPid = getNumberValue(source, 'process.parent.pid'); + const ppid = getNumberValue(source, 'process.ppid'); + + const outcome = getSingleValue(source, 'event.outcome'); + const processHashSha256 = getSingleValue(source, 'process.hash.sha256'); + + const message = getSingleValue(source, 'message'); + + const hasAnyProcessDetails = + userName != null || + userDomain != null || + hostName != null || + workingDirectory != null || + processName != null || + processPid != null || + (args?.length ?? 0) > 0 || + processTitle != null || + processExitCode != null || + parentName != null || + parentPid != null || + ppid != null || + outcome != null || + processHashSha256 != null; + + if (!hasAnyProcessDetails) { + return ''; + } + + let text = ''; + + if (userName != null) text += userName; + if (userDomain != null) text += `${userName != null ? '\\' : ''}${userDomain}`; + if (hostName != null && userName != null) text += `@${hostName}`; + else if (hostName != null) text += hostName; + + if (workingDirectory != null) { + if (text.length) text += ' '; + text += `in ${workingDirectory}`; + } + + if (text.length) text += ' '; + text += actionText; + + if (processName != null) { + text += ` ${processName}`; + if (processPid != null) { + text += ` (${processPid})`; + } + } else if (processPid != null) { + text += ` (${processPid})`; + } + + if (args?.length) { + text += ` ${args.join(' ')}`; + } + if (processTitle != null) { + text += ` ${processTitle}`; + } + + if (processExitCode != null) { + text += ` with exit code ${processExitCode}`; + } + + if (parentName != null || parentPid != null || ppid != null) { + text += ' via parent process'; + if (parentName != null) text += ` ${parentName}`; + if (parentPid != null) text += ` (${parentPid})`; + if (ppid != null) text += ` (${ppid})`; + } + + if (outcome != null) { + text += ` with result ${outcome}`; + } + + if (processHashSha256 != null) { + text += ` ${processHashSha256}`; + } + + if (message != null) { + text += ` ${message}`; + } + + return normalizeSpaces(text); +}; + +/** + * The process/endpoint strategy is the default fallback. + * It always matches but has the lowest priority. + */ +export const processStrategy: NarrativeStrategy = { + id: 'process', + priority: 0, + match: () => true, + build: (source) => { + const alertPart = normalizeSpaces(buildAlertTimelineString(source)); + const processPart = buildProcessTimelineString(source); + + if (processPart.length && alertPart.length) { + return normalizeSpaces(`${alertPart} ${processPart}`); + } + + return processPart.length ? processPart : alertPart; + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.test.ts new file mode 100644 index 0000000000000..6bc68417486d2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { registryStrategy, buildRegistryNarrative } from './registry_strategy'; + +describe('registryStrategy', () => { + describe('match', () => { + it('returns true when registry.key is present', () => { + expect(registryStrategy.match({ registry: { key: ['HKLM\\Software\\Test'] } })).toBe(true); + }); + + it('returns true for endpoint.events.registry dataset', () => { + expect(registryStrategy.match({ event: { dataset: ['endpoint.events.registry'] } })).toBe( + true + ); + }); + + it('returns false without registry fields', () => { + expect(registryStrategy.match({ event: { category: ['process'] } })).toBe(false); + }); + }); + + describe('buildRegistryNarrative', () => { + it('builds a full registry narrative', () => { + const text = buildRegistryNarrative({ + event: { action: ['modification'] }, + registry: { + path: ['HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\malware'], + key: ['HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'], + value: ['malware'], + data: { strings: ['C:\\Windows\\Temp\\evil.exe'] }, + }, + process: { name: ['powershell.exe'] }, + user: { name: ['admin'] }, + host: { name: ['workstation-1'] }, + kibana: { + alert: { severity: ['high'], rule: { name: ['Registry Run Key Persistence'] } }, + }, + }); + + expect(text).toBe( + 'Registry modification on HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\malware value malware data C:\\Windows\\Temp\\evil.exe by process powershell.exe by admin on workstation-1 created high alert Registry Run Key Persistence.' + ); + }); + + it('handles minimal registry data', () => { + expect(buildRegistryNarrative({ registry: { key: ['HKLM\\Software\\Test'] } })).toBe( + 'Registry event on HKLM\\Software\\Test' + ); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.ts new file mode 100644 index 0000000000000..e8589a8440fb6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/registry_strategy.ts @@ -0,0 +1,52 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + getValues, + joinValues, + normalizeSpaces, + datasetIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildRegistryNarrative = (source: AlertSource): string => { + const eventAction = getSingleValue(source, 'event.action'); + const registryKey = getSingleValue(source, 'registry.key'); + const registryPath = getSingleValue(source, 'registry.path'); + const registryValue = getSingleValue(source, 'registry.value'); + const registryData = joinValues(getValues(source, 'registry.data.strings')); + const processName = getSingleValue(source, 'process.name'); + + let text = `Registry ${eventAction ?? 'event'}`; + if (registryPath != null) { + text += ` on ${registryPath}`; + } else if (registryKey != null) { + text += ` on ${registryKey}`; + } + if (registryValue != null) text += ` value ${registryValue}`; + if (registryData != null) text += ` data ${registryData}`; + if (processName != null) text += ` by process ${processName}`; + + text = appendUserHostContext(text, source); + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const registryStrategy: NarrativeStrategy = { + id: 'registry', + priority: 40, + match: (source) => + getSingleValue(source, 'registry.key') != null || + getSingleValue(source, 'registry.path') != null || + datasetIs(source, 'endpoint.events.registry'), + build: buildRegistryNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.test.ts new file mode 100644 index 0000000000000..7ad9a2f5a1a65 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { threatMatchStrategy, buildThreatMatchNarrative } from './threat_match_strategy'; + +describe('threatMatchStrategy', () => { + describe('match', () => { + it('returns true when rule type is threat_match', () => { + expect( + threatMatchStrategy.match({ kibana: { alert: { rule: { type: ['threat_match'] } } } }) + ).toBe(true); + }); + + it('returns true when threat.indicator.matched.atomic exists', () => { + expect( + threatMatchStrategy.match({ + threat: { indicator: { matched: { atomic: ['1.2.3.4'] } } }, + }) + ).toBe(true); + }); + + it('returns false for other rule types', () => { + expect(threatMatchStrategy.match({ kibana: { alert: { rule: { type: ['query'] } } } })).toBe( + false + ); + }); + }); + + describe('buildThreatMatchNarrative', () => { + it('builds a full threat match narrative', () => { + const text = buildThreatMatchNarrative({ + threat: { + indicator: { + matched: { atomic: ['1.2.3.4'], type: ['ip'], field: ['source.ip'] }, + provider: ['AlienVault'], + }, + feed: { name: ['OTX'] }, + }, + user: { name: ['bob'] }, + host: { name: ['server-1'] }, + kibana: { alert: { severity: ['critical'], rule: { name: ['Known C2 IP Match'] } } }, + }); + + expect(text).toBe( + 'Threat indicator match (ip): 1.2.3.4 matched on field source.ip from OTX by bob on server-1 created critical alert Known C2 IP Match.' + ); + }); + + it('handles minimal threat match data', () => { + expect( + buildThreatMatchNarrative({ + kibana: { alert: { rule: { type: ['threat_match'], name: ['IOC Match'] } } }, + }) + ).toBe('Threat indicator match IOC Match.'); + }); + + it('uses provider when feed name is absent', () => { + expect( + buildThreatMatchNarrative({ + threat: { indicator: { matched: { atomic: ['hash123'] }, provider: ['VirusTotal'] } }, + }) + ).toBe('Threat indicator match: hash123 from VirusTotal'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.ts new file mode 100644 index 0000000000000..382fd5d57e2ca --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/render_alert_narrative_step/strategies/threat_match_strategy.ts @@ -0,0 +1,47 @@ +/* + * 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 { NarrativeStrategy } from '../narrative_strategy'; +import { + getSingleValue, + normalizeSpaces, + ruleTypeIs, + appendUserHostContext, + appendAlertSuffix, +} from '../narrative_utils'; +import type { AlertSource } from '../narrative_utils'; + +export const buildThreatMatchNarrative = (source: AlertSource): string => { + const matchedAtomic = getSingleValue(source, 'threat.indicator.matched.atomic'); + const matchedType = getSingleValue(source, 'threat.indicator.matched.type'); + const matchedField = getSingleValue(source, 'threat.indicator.matched.field'); + const indicatorProvider = getSingleValue(source, 'threat.indicator.provider'); + const feedName = getSingleValue(source, 'threat.feed.name'); + + let text = 'Threat indicator match'; + + if (matchedType != null) text += ` (${matchedType})`; + if (matchedAtomic != null) text += `: ${matchedAtomic}`; + if (matchedField != null) text += ` matched on field ${matchedField}`; + if (indicatorProvider != null || feedName != null) { + text += ` from ${feedName ?? indicatorProvider}`; + } + + text = appendUserHostContext(text, source); + text = appendAlertSuffix(text, source); + + return normalizeSpaces(text); +}; + +export const threatMatchStrategy: NarrativeStrategy = { + id: 'threat_match', + priority: 100, + match: (source) => + ruleTypeIs(source, 'threat_match') || + getSingleValue(source, 'threat.indicator.matched.atomic') != null, + build: buildThreatMatchNarrative, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/tsconfig.json b/x-pack/solutions/security/plugins/security_solution/tsconfig.json index da9a0c867056e..87c2c21cad424 100644 --- a/x-pack/solutions/security/plugins/security_solution/tsconfig.json +++ b/x-pack/solutions/security/plugins/security_solution/tsconfig.json @@ -287,11 +287,13 @@ "@kbn/controls-constants", "@kbn/anonymization-plugin", "@kbn/anonymization-common", + "@kbn/workflows-extensions", "@kbn/shared-ux-ai-components", "@kbn/controls-schemas", "@kbn/search-inference-endpoints", "@kbn/evals-plugin", "@kbn/shared-ux-column-presets", "@kbn/core-overlays-browser", + "@kbn/workflows-management-plugin", ] }