x-pack/solutions/security/plugins/discoveries/README.md
Attack Discovery 2.0 decouples the generation pipeline from the monolithic elastic_assistant endpoint and runs each phase (alert retrieval → generation → validation → persistence) as a Kibana Workflows step. The Alerting Framework owns scheduling, alert persistence, and action execution (with full throttling/frequency support); the Workflows engine owns only the generation pipeline.
This plugin lets users:
This README is the architecture reference for AD 2.0. Read top-to-bottom for the full picture, or jump to any section via the TOC below.
security.attack-discovery.run StepThe whole feature is gated behind securitySolution.attackDiscoveryWorkflowsEnabled (default OFF). With the flag OFF every PR preserves existing behavior exactly — the public elastic_assistant API surface is unchanged. Internal routes are still registered but reject requests with 404 Not Found (assertWorkflowsEnabled); workflow step handlers are still registered but wrapped by withWorkflowsEnabledGuard, so directly invoking a managed workflow step throws Attack Discovery workflows are not enabled instead of doing work; Agent Builder skills are not registered; and generation (the managed-workflow integrity check and pre-execution validation) never runs because executeGenerationWorkflow short-circuits when the flag is OFF.
The feature-flag helpers are canonical in @kbn/discoveries/impl/lib/helpers/is_workflows_enabled (isWorkflowsEnabled + ATTACK_DISCOVERY_WORKFLOWS_ENABLED_FEATURE_FLAG); the server route helper assertWorkflowsEnabled re-exports the constant.
Enable in kibana.dev.yml:
feature_flags.overrides:
securitySolution.attackDiscoveryWorkflowsEnabled: true
See the YAML block above.
| Surface | Path |
|---|---|
| This plugin | x-pack/solutions/security/plugins/discoveries/ |
| Shared server logic (LangGraph, event logging, telemetry definitions) | @kbn/discoveries |
| OpenAPI schemas + generated types | @kbn/discoveries-schemas |
| System workflow definitions (the live source-of-truth — seven inline YAML strings) | @kbn/workflows/managed/definitions/discoveries/index.ts |
| Plugin-side managed-workflow install + integrity check | server/managed_workflows/ |
| Workflow step common definitions | common/step_types/ |
| Workflow step server handlers | server/workflows/steps/ |
| Step registration | server/workflows/register_workflow_steps.ts |
UI hook (frontend entry to _generate) | use_attack_discovery |
The Security - Attack discovery - Run example workflow is the recommended way to desk-test the pipeline end-to-end.
yarn es snapshot --license trial, then yarn start).http://localhost:5601/app/workflows/system-attack-discovery-run-example{}
connector_id to override the configured default:
{ "connector_id": "<your-connector-id>" }
To list available connector IDs:
curl -s -u elastic:changeme 'http://localhost:5601/api/actions/connectors' | jq '.[] | {id, name}'
More invocation patterns are documented in Using the security.attack-discovery.run Step.
Attack Discovery 2.0 has three entry paths, all of which converge on the same generation function (executeGenerationWorkflow):
POST /internal/attack_discovery/_generate.workflowExecutor rule executor invokes executeGenerationWorkflow directly. (New to AD 2.0 and easy to misread — see What Alerting Framework workflowExecutor means.)security.attack-discovery.run as a step. The step handler calls executeGenerationWorkflow internally.executeGenerationWorkflow (in @kbn/discoveries) is the single shared entry point. It runs pre-execution validation and a managed-workflow integrity check, then delegates to runManualOrchestration, which chains the phases with timeout budgets.
The generation phase begins with an always-on ground-truthing gate — a separate ai.agent workflow that runs before the untouched generate workflow. The gate runs for every trigger except agent_builder (the conversational skill has already ground-truthed its own data — see shouldRunGate); that skip is also the recursion break. The gate is fail-closed (a gate failure fails the run), runs a bounded multi-skill corroboration that stays decision-only, decides which candidate alerts to keep (original bytes pass through by _id), may add the _ids of net-new alerts it retrieved itself (Skill toggle, ids only), and threads a conversation_id to a fire-and-forget report phase after validation (which renders the report and drafts missed-detection rule proposals behind the verbatim create the rule approval gate, never auto-persisting rules). See Always-on generation-phase gate.
flowchart TB
UI["Attack Discovery UI
(Run button)"]
SCHED["Alerting Framework
workflowExecutor"]
USER["User-authored workflow
(security.attack-discovery.run step)"]
RUNTOOL["Skill run tool
(agent_builder)"]
HTTP["POST /internal/attack_discovery/_generate"]
EGW["executeGenerationWorkflow
(pre-exec validation + integrity check)"]
RMO["runManualOrchestration
(timeout budget per phase)"]
R["Retrieval phase: retrieval_step
(deterministic toggles only —
esql / query-builder / custom workflows;
may be empty)"]
SKIP{"trigger ==
agent_builder?"}
GATE["SKILL RUN 1 — Gate (separate ai.agent workflow,
create-conversation; fail-closed):
ground-truth candidates + optional skill retrieval
→ remove_alert_ids + added_alert_ids + conversation_id"]
G["Generation phase: generation_step
(security.attack-discovery.generate, LangGraph)"]
V["Validation phase: validation_step
(security.attack-discovery.defaultValidation
+ security.attack-discovery.persistDiscoveries)"]
REPORT["SKILL RUN 2 — Report phase (fire-and-forget):
resume conversation_id → render report
+ draft missed-detection rules (create the rule gate)"]
EBT["EBT telemetry"]
EVL["Event log
(.kibana-event-log-*)"]
UI --> HTTP --> EGW
SCHED --> EGW
USER --> EGW
RUNTOOL --> EGW
EGW --> RMO --> R --> SKIP
SKIP -->|"no (manual / schedule / workflow)"| GATE --> G
SKIP -->|"yes (skill already ground-truthed)"| G
G --> V --> REPORT
EGW --> EBT
RMO --> EVL
┌────────────────────────────────────────────────────────────────────┐
│ @kbn/discoveries Package (server-only) │
│ - LangGraph execution logic (graphs, orchestration) │
│ - executeGenerationWorkflow + runManualOrchestration │
│ - Event logging utilities (shared with elastic_assistant) │
│ - Hallucination detection, anonymization, schedule transforms │
│ - Telemetry event definitions (EBT) │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ @kbn/discoveries-schemas Package (shared-common) │
│ - OpenAPI schemas (.schema.yaml) │
│ - Generated TypeScript types and Zod (v3) validators (.gen.ts) │
└────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ discoveries Plugin │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Internal APIs (FF-gated): │ │
│ │ - POST /internal/attack_discovery/_generate │ │
│ │ - Pipeline data + tracking routes │ │
│ │ - Default ES|QL query route │ │
│ │ - Schedule CRUD (create/find/get/update/delete/enable/disable)│ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Workflow Step handlers (5): │ │
│ │ - security.attack-discovery.defaultAlertRetrieval │ │
│ │ - security.attack-discovery.generate (with event logging) │ │
│ │ - security.attack-discovery.defaultValidation │ │
│ │ - security.attack-discovery.persistDiscoveries │ │
│ │ - security.attack-discovery.run (full pipeline in one step) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Managed workflows wiring: │ │
│ │ - installStatic: installs the 7 system-… workflows globally │ │
│ │ - checkManagedWorkflowIntegrity: pre-execution introspection │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
The discoveries plugin owns the step handlers; the workflow definitions that consume those steps are platform-managed and live in @kbn/workflows. See System workflow definitions.
The current Attack Discovery 1.0 pipeline is a monolithic public-API route (POST /api/attack_discovery/_generate) that retrieves alerts, invokes the LLM, validates results, and writes alerts inline. AD 2.0 decomposes that into Kibana Workflow steps so that:
The security.attack-discovery.persistDiscoveries step is the single persistence authority across all three paths. It echoes the discoveries it was handed into a discoveries_to_persist output. Ad-hoc and the run step own their own I/O via the step (the step writes to the ad-hoc index). Scheduled rules invoke the step only for that handover — the step does no I/O for scheduled runs; it hands the discoveries up via discoveries_to_persist, and the scheduled executor reports exactly that handover to the Alerting Framework (which owns scheduled persistence). If a validation workflow never invokes the persist step — or hands it an empty array — nothing is persisted (a noop, logged at WARN). Details: Persist-step handover and the requirement-to-test map.
Attack Discovery ships seven system-managed workflows (installed by installStatic — see AD_WORKFLOW_IDS): three core pipeline workflows, two example templates, and two Agent Builder skill workflows. All seven are declared as inline YAML strings in a single TS file (kbn-workflows/managed/definitions/discoveries/index.ts) — that file is the single source-of-truth for their contents at runtime. The plugin validates those exact .yaml strings (Liquid templating + the no-concurrency guarantee on the generation workflow) in server/managed_workflows/validate_managed_workflow_definitions.test.ts.
| System workflow ID | Purpose | Exported constant (same file) |
|---|---|---|
system-attack-discovery-alert-retrieval | Default DSL/ES|QL alert retrieval phase | ATTACK_DISCOVERY_ALERT_RETRIEVAL_WORKFLOW |
system-attack-discovery-generation | LangGraph generation phase | ATTACK_DISCOVERY_GENERATION_WORKFLOW |
system-attack-discovery-validate | Default validation (hallucination detection) + persistence | ATTACK_DISCOVERY_VALIDATE_WORKFLOW |
system-attack-discovery-run-example (example) | Ready-made security.attack-discovery.run template | ATTACK_DISCOVERY_RUN_EXAMPLE_WORKFLOW |
system-attack-discovery-custom-validation-example (example) | Custom validation workflow template | ATTACK_DISCOVERY_CUSTOM_VALIDATION_EXAMPLE_WORKFLOW |
system-attack-discovery-skill-alert-retrieval (skill) | Alert retrieval used by the Agent Builder skill path | ATTACK_DISCOVERY_SKILL_ALERT_RETRIEVAL_WORKFLOW |
system-attack-discovery-skill-report (skill) | Report generation used by the Agent Builder skill path | ATTACK_DISCOVERY_SKILL_REPORT_WORKFLOW |
The core generation pipeline itself runs the three required workflows (alert retrieval, generation, validate); the example and skill workflows are additional managed workflows installed alongside them.
checkManagedWorkflowIntegrityverifies the three required plus the two example workflows.
All seven carry the same management metadata:
management: {
enablement: 'enforced', // cannot be disabled by users
lifecycle: 'static', // no user edits
versionStrategy: 'auto', // platform upgrades to latest version on restart
}
Install is invoked by the plugin via installStatic, which loops over AD_WORKFLOW_IDS and asks workflowsExtensions.initManagedWorkflowsClient('discoveries') to install each one into the global workflow space. Reconciliation, version upgrades, and orphan cleanup are owned by the platform's managed-workflow framework — not by this plugin.
Navigation note. Managed workflows are hidden from the Workflows UI list view by default. Navigate directly by URL, e.g.:
http://localhost:5601/app/workflows/system-attack-discovery-generationhttp://localhost:5601/app/workflows/system-attack-discovery-run-exampleThe AD-side checkManagedWorkflowIntegrity introspects platform state on every generation request and reports diagnostic outcomes — it does not perform restoration. A hash mismatch means the platform will reconcile on the next restart.
graph TD
A[checkManagedWorkflowIntegrity] --> B[Check each required workflow]
B --> C[workflowsManagementApi.getWorkflow]
C -->|null| D[unrepairable: not found]
C -->|not managed| E[unrepairable: not managed]
C -->|disabled| F[unrepairable: disabled]
C -->|hash mismatch| G[repaired: platform will reconcile on next restart]
C -->|hash matches| H[intact]
D --> I[status: repair_failed → abort generation]
E --> I
F --> I
G --> J[status: repaired → workflow_modified telemetry]
H --> K[status: all_intact]
| Outcome | Meaning | Pipeline |
|---|---|---|
all_intact | All required managed workflows are present, enabled, and current | Continues |
repaired | One or more workflows have drifted; workflow_modified telemetry emitted; platform will reconcile on next restart | Continues |
repair_failed | One or more required managed workflows are missing, unmanaged, or disabled | Aborted — generation-failed event written with error reason |
The plugin registers five workflow step handlers (see server/workflows/register_workflow_steps.ts). Per-step contracts (input/output schemas, anonymization flow, failure modes, "adding a new step" checklist) are in the workflow steps README.
| Step Type ID | Purpose | Inputs (summary) | Outputs (summary) |
|---|---|---|---|
security.attack-discovery.defaultAlertRetrieval | Retrieves and anonymizes alerts (DSL or ES|QL) | alertsIndexPattern, anonymizationFields, apiConfig, filter, size, start/end | alerts, anonymizedAlerts, replacements, apiConfig, connectorName, alertsContextCount |
security.attack-discovery.generate | Generates attack discoveries from anonymized alerts via LangGraph | alerts (string[]), apiConfig, replacements, size | attack_discoveries, execution_uuid, replacements |
security.attack-discovery.defaultValidation | Hallucination detection + deduplication | attackDiscoveries, anonymizedAlerts, apiConfig, connectorName, generationUuid, alertsContextCount, replacements | validated_discoveries, filtered_count, filter_reason |
security.attack-discovery.persistDiscoveries | Persists validated discoveries to the AD data store | (as above) | persisted_discoveries, duplicates_dropped_count, discoveries_to_persist (echo of input — the handover) |
security.attack-discovery.run | Runs the full pipeline (retrieve → generate → validate → persist) as a single step | connector_id (optional — defaults to genAiSettings:defaultAIConnector → inference fallback), alert_retrieval_mode, mode, alerts (optional), size, start/end, filter, esql_query | attack_discoveries, execution_uuid, alerts_context_count, discovery_count |
Common (cross-runtime) step definitions live in common/step_types/; server-side handlers live in server/workflows/steps/.
All step schemas are defined inline using @kbn/zod/v4 per the Workflows platform requirement. Auto-generated v3 schemas (used for REST route validation) must never be cast to v4 — v4 enums carry a .values property that v3 lacks, so a runtime cast surfaces as TypeError: Cannot read properties of undefined (reading 'values') in the Workflows UI.
flowchart LR
ES["Elasticsearch
raw alerts"] --> RET["defaultAlertRetrieval
step"]
RET -->|anonymized string[]| GEN["generate step
(LangGraph)"]
RET -->|replacements map| RM[(replacements
map)]
GEN --> VAL["defaultValidation"]
VAL --> PER["persistDiscoveries"]
RM -.->|de-anonymize
on display only| UI["Attack Discovery UI"]
GEN -.->|excluded| RUN["security.attack-discovery.run
output"]
The anonymization boundary sits at the alert retrieval step. Everything upstream (raw Elasticsearch alerts) is real data; everything downstream operates on anonymized strings. The replacements map is the only bridge between the two worlds — and it is deliberately excluded by the output schema of security.attack-discovery.run so user-authored workflows cannot inadvertently log or forward the de-anonymization key to external systems.
The generate step's input contract is alerts: string[] (anonymized strings), not structured alert objects — making it impossible to accidentally pass raw alert objects to the LLM.
The defaultAlertRetrieval step ensures the _id field is always present in the anonymization configuration. Downstream steps use real alert IDs for hallucination detection — IDs are allowed but not anonymized.
All three modes converge on executeGenerationWorkflow and share the same step pipeline. Differences are only in how the call is initiated and how results are returned.
The user clicks Run in the Attack Discovery UI. The useAttackDiscovery hook calls POST /internal/attack_discovery/_generate, which fires the pipeline asynchronously and returns an execution_uuid. Results appear in the UI as they complete via the generations polling API. See Internal APIs.
workflowExecutor)An Alerting Framework rule fires on a configured cadence (e.g., every hour). The workflowExecutor registered with the Alerting Framework (server/lib/schedules/workflow_executor/) invokes the same executeGenerationWorkflow function as the ad-hoc path. Full throttling and frequency controls are enforced by the Alerting Framework. Schedule CRUD is exposed through the internal Schedule APIs, and tag-based isolation keeps internal-API schedules separate from legacy public-API schedules. See Scheduling.
security.attack-discovery.run Step (User-Authored Workflows)A user-authored workflow includes security.attack-discovery.run as a step. This is the composability path: the step can receive pre-retrieved alerts from upstream steps, customize retrieval mode, and return discoveries to downstream steps. The full pipeline (retrieve → generate → validate → persist) runs inside the step in either sync mode (returns discoveries inline) or async mode (returns execution_uuid immediately).
See Using the security.attack-discovery.run Step for a full guide.
Attack Discovery generation is bounded by layered timeouts (see ADR-008). Timeouts propagate inside-out: a slow LLM call trips the connector timeout, which fails the workflow step, which runManualOrchestration catches against the total pipeline budget. The only hard wall-clock kill of an in-flight run is the scheduled rule-task timeout; the ad-hoc route, the run step, and the run tool are fire-and-forget / soft-handoff, so the background pipeline keeps running up to the pipeline budget.
| Method | Timeout | Value | Constant / source | Behavior |
|---|---|---|---|---|
| Scheduled (Alerting Framework) | Rule task timeout | 15m | ruleTaskTimeout — register_schedule/definition.ts | Hard kill: the Alerting Framework cancels the task; shouldStopExecution() flips true and the run is reported failed. |
Ad hoc (POST /internal/attack_discovery/_generate) | Route handler idleSocket | 10m | DEFAULT_ROUTE_HANDLER_TIMEOUT_MS — routes/constants.ts | Effectively moot: the route is fire-and-forget and returns execution_uuid immediately, so this does not bound the generation. |
Run tool (security.attack-discovery.run) & run step, sync mode | Soft deadline | 90s | ATTACK_DISCOVERY_RUN_SOFT_DEADLINE_MS — run_step/constants.ts | Not a kill: on deadline the call returns execution_uuid and the pipeline keeps running in the background for slow-path resume. |
| Agent Builder workflow-tool wrapper | Wait-for-completion ceiling | 120s | WAIT_FOR_COMPLETION_TIMEOUT_SEC — @kbn/agent-builder-common | External AB ceiling the 90s soft deadline sits safely under. |
| Timeout | Value | Constant / source | Notes |
|---|---|---|---|
| Total pipeline budget | 30m | DEFAULT_PIPELINE_TIMEOUT_MS — run_manual_orchestration/index.ts | Outermost orchestration boundary, shared across gate + generation + validation (the fire-and-forget persist runs outside the budget). Raised from 15m → 30m (ADR-008). |
| Per-phase consumer poll | remaining budget | maxWaitMs: getRemainingBudgetMs() — pollForWorkflowCompletion | Each phase poll is sized to whatever pipeline budget remains, so it never gives up while the inner workflow step is still legitimately running. |
| Poll readiness wait | 5s | DEFAULT_READINESS_TIMEOUT_MS — poll_for_workflow_completion/index.ts | Initial wait for the execution doc to become queryable before polling begins. |
Per-step timeout values from the managed definitions (kbn-workflows/managed/definitions/discoveries/index.ts):
| Step | Timeout |
|---|---|
security.attack-discovery.defaultAlertRetrieval | 5m |
Always-on gate (ai.agent) | 10m |
security.attack-discovery.generate | 10m |
security.attack-discovery.defaultValidation | 5m |
security.attack-discovery.persistDiscoveries | 5m |
security.attack-discovery.run (composite) | 10m |
| Timeout | Value | Constant / source | Notes |
|---|---|---|---|
| Connector timeout per LLM call | 10m | DEFAULT_CONNECTOR_TIMEOUT_MS — server/index.ts | Innermost boundary; bounds each generate-graph LLM call. Configurable via xpack.discoveries.connectorTimeout. |
Attack Discovery 2.0 follows a least-privilege authorization model. It requires both the Attack Discovery feature and the Workflows Management feature — the Attack Discovery all privilege does not grant workflows privileges, so operators must grant the two features separately.
Rather than requiring the same broad grant on every route, each route declares only the privileges it actually needs:
workflowsManagement:execute is required only on routes that trigger a workflow run (_generate and schedule create / update / enable).workflowsManagement:read is required only on routes that read workflow data (the execution-monitoring routes).The same privilege ids shown in the table below are what users see in two places:
API [POST /internal/attack_discovery/_generate] is unauthorized for user, this action is granted by the Kibana privileges [workflowsManagement:read, workflowsManagement:execute].| Route (method + path) | Required Kibana privileges (ids) | Notes |
|---|---|---|
POST /internal/attack_discovery/_generate | securitySolution-attackDiscoveryAll, alerts-read, workflowsManagement:read, workflowsManagement:execute | Triggers a workflow run. assertAuthorizedToExecuteWorkflows remains in-handler as defense-in-depth for the fire-and-forget async pipeline. |
POST /internal/attack_discovery/schedules (create) | securitySolution-attackDiscoveryAll, alerts-read, securitySolution-updateAttackDiscoverySchedule, workflowsManagement:read, workflowsManagement:execute | Schedule mutation that runs a workflow. |
PUT /internal/attack_discovery/schedules/{id} (update) | securitySolution-attackDiscoveryAll, alerts-read, securitySolution-updateAttackDiscoverySchedule, workflowsManagement:read, workflowsManagement:execute | Schedule mutation that runs a workflow. |
POST /internal/attack_discovery/schedules/{id}/_enable | securitySolution-attackDiscoveryAll, alerts-read, securitySolution-updateAttackDiscoverySchedule, workflowsManagement:read, workflowsManagement:execute | Schedule mutation that runs a workflow. |
POST /internal/attack_discovery/schedules/{id}/_disable | securitySolution-attackDiscoveryAll, alerts-read, securitySolution-updateAttackDiscoverySchedule | Stops runs; no workflow interaction, so no workflows privilege. |
DELETE /internal/attack_discovery/schedules/{id} | securitySolution-attackDiscoveryAll, alerts-read, securitySolution-updateAttackDiscoverySchedule | No workflow interaction. |
GET /internal/attack_discovery/schedules/_find | securitySolution-attackDiscoveryAll, alerts-read | Read-only; no workflow interaction. |
GET /internal/attack_discovery/schedules/{id} | securitySolution-attackDiscoveryAll, alerts-read | Read-only; no workflow interaction. |
GET /internal/attack_discovery/workflow/{workflow_id}/execution/{execution_id} (pipeline) | securitySolution-attackDiscoveryAll, alerts-read, workflowsManagement:read | Reads workflow execution data. |
GET /internal/attack_discovery/executions/{execution_id}/tracking | securitySolution-attackDiscoveryAll, alerts-read, workflowsManagement:read | Reads workflow execution data. |
GET /internal/attack_discovery/attack_discovery/queries/esql/default | securitySolution-attackDiscoveryAll, alerts-read | No workflow interaction. |
Serverless roles already grant feature_workflowsManagement.all to every Attack-Discovery-capable role, so generate / monitor / schedule all work under the least-privilege policy. The viewer role has feature_workflowsManagement.read only, so it can view and monitor discoveries but is correctly blocked (403) from generate and schedule-write routes — and receives the graceful "Insufficient privileges" callout instead of an error toast.
On-prem (stateful) custom roles must grant both the Attack Discovery feature and the Workflows Management feature to use AD 2.0.
All internal routes are FF-gated (assertWorkflowsEnabled) and use asCurrentUser only — never asInternalUser. Privilege escalation is impossible because every ES query inherits the authenticated request's permissions.
Kicks off the orchestrated pipeline (retrieve → generate → validate → persist) asynchronously and returns an execution UUID for tracking. Returns 404 when securitySolution.attackDiscoveryWorkflowsEnabled is OFF. The handler also calls assertAlertsIndexPatternInSpace (server/lib/assert_alerts_index_pattern_in_space/) — the alerts_index_pattern must equal the caller's own space-specific alerts index (.alerts-security.alerts-<spaceId>, derived by getAlertsIndexForSpace); another space's index or a cross-space -* wildcard is rejected with 400.
Request:
{
alerts_index_pattern: string,
api_config: ApiConfig,
filter?: Record<string, unknown>,
start?: string,
end?: string,
replacements?: Replacements,
size?: number,
// Composite config: three independent retrieval toggles compose the alert set;
// at least one must be enabled. The always-on gate runs regardless of skill_enabled.
workflow_config?: {
skill_enabled?: boolean, // Toggle 1 (default: true)
default_retrieval_enabled?: boolean, // Toggle 2 (default: false)
alert_retrieval_mode?: 'custom_query' | 'esql', // default-retrieval query mode (default: custom_query)
esql_query?: string, // required when default_retrieval_enabled + alert_retrieval_mode === 'esql'
alert_retrieval_workflows_enabled?: boolean, // Toggle 3 (default: false)
alert_retrieval_workflow_ids?: string[], // default: []
validation_workflow_id?: string // default: 'default'
}
}
Response:
{ execution_uuid: string }
Returns the space-aware default ES|QL query for alert retrieval — the same query pre-populated in the Attack Discovery settings flyout when ES|QL retrieval mode is selected. The query includes a KEEP clause scoped to the anonymization fields active in the current space.
Response:
{ query: string }
Returns the workflow execution tracking data for a given execution_id — the IDs of the alert retrieval, generation, and validation workflow runs logged by the orchestrator. Used by the UI to link from an execution UUID to specific workflow run IDs for deep-linking into the Workflows app. Returns 404 when the execution has not yet been indexed into the event log.
Path parameters: execution_id: string
Response:
{
alert_retrieval: Array<{ workflow_id: string; workflow_run_id: string }> | null,
// Generation-phase gate (skill) runs, surfaced under the Generation phase.
gate: Array<{ workflow_id: string; workflow_run_id: string }> | null,
generation: { workflow_id: string; workflow_run_id: string } | null,
validation: { workflow_id: string; workflow_run_id: string } | null
}
Real-time visibility while running. The generation run ID is written to the
event log (in the generate-step-started event) as soon as the generation
workflow is scheduled — i.e. before generation finishes — so the flyout can show
the Generation workflow row the moment generation starts, not only after it
completes. The flyout polls this route every 2s and keeps polling until both
generation and validation are present, or a 10-minute safety window elapses
(getTrackingRefetchInterval). The safety window must outlast a full run: the
gate (skill) phase alone can take ~45s before the generation run ID is written, so
a shorter cap would freeze the poller mid-run and the Generation row would not
appear until the run completed.
Returns the full pipeline data for a generation run: alert retrieval results, combined alerts, generation output, validated discoveries, and per-workflow execution tracking. This is the primary data source for the Execution Details flyout in the Attack Discovery UI.
The optional generation_workflow_run_id query parameter is a client-supplied fallback for early polling before the event-log entry for the generation phase has been indexed — the client provides the run ID it received from POST /internal/attack_discovery/_generate and the server uses it to fetch generation data directly.
Path parameters:
workflow_id: string // The orchestrator workflow ID
execution_id: string // The execution UUID from _generate
Query parameters:
generation_workflow_run_id?: string // Client fallback run ID for early polling
Response:
{
alert_retrieval: Array<{
alerts: string[],
alerts_context_count: number | null,
// Count of `custom_workflow` alerts that lack a recoverable backing `_id`
// (C2). When > 0 the UI shows a warning, since such alerts are dropped
// before generation. Only set for the `custom_workflow` strategy.
alerts_missing_id_count?: number,
extraction_strategy: string,
workflow_id: string,
workflow_run_id: string
}> | null, // includes generation-phase gate runs (see below)
combined_alerts: { alerts: string[]; alerts_context_count: number } | null,
diagnostics_context?: DiagnosticsContext,
generation: PipelineGenerationData | null,
validated_discoveries: AttackDiscoveryApiAlert[] | null,
workflow_executions_tracking: {
alert_retrieval: Array<{ workflow_id: string; workflow_run_id: string }> | null,
// Generation-phase gate (skill) runs, surfaced under the Generation phase.
gate: Array<{ workflow_id: string; workflow_run_id: string }> | null,
generation: { workflow_id: string; workflow_run_id: string } | null,
validation: { workflow_id: string; workflow_run_id: string } | null
}
}
Generation-phase gate runs. The gate bucket holds a single entry: the
always-on gate (skill) decision run. Any net-new alert re-fetch the gate triggers
(retrieveAnonymizedAlertsByIds) is an internal hydration detail of the skill
invocation and is folded into that single entry (it is not surfaced as a
separate execution) — so a skill-only ad-hoc run shows exactly one gate entry
plus the generate workflow under the Generation phase (and one validation
entry), with zero entries under Alert retrieval. The gate run is also merged
into alert_retrieval (keyed by workflow_run_id) so its badge/inspect resolve
under the Generation phase in the UI — but it is excluded from combined_alerts,
which stays scoped to the Alert retrieval phase. The gate emits a removal set
(remove_alert_ids), so the kept count is candidates − removed — not derivable
from the gate decision alone. The interim badge count therefore reports only the
net-new ids the gate retrieved (added_alert_ids) until the generate step input
resolves the authoritative count (below).
Inspecting the alerts passed to generation. The gate emits ids only, so on its
own the gate entry carries no raw alerts and its inspect would be disabled. The
authoritative set of alerts generation actually analyzed is the
generate_discoveries step's input.alerts, so get_pipeline_data attaches those
to the gate (skill) entry. This guarantees the gate inspect always reflects the
real events passed to generation: the kept candidate alerts forwarded as-is
(original bytes, including any alerts produced by the alert-retrieval workflows that
survived the gate) plus any net-new alerts the gate added. Alerts the gate dropped
are intentionally absent (they were not passed to generation) and remain
inspectable under their own Alert retrieval entries when retrieval ran. Once the
generate step input is available, the badge count and the inspect both reflect that
real count; before then, the interim (added_alert_ids-only) count stands and
inspect is disabled.
The full schedule API surface is documented under Scheduling → Schedule-Related Internal APIs.
security.attack-discovery.run StepThe security.attack-discovery.run step is the recommended entry point for triggering Attack Discovery from a user-authored workflow. All inputs are optional — every field has a sensible default, including the LLM connector.
The Security - Attack discovery - Run example workflow (system-attack-discovery-run-example, declared inline in kbn-workflows/managed/definitions/discoveries/index.ts) is a ready-made workflow that exposes all inputs and is ideal for desk-testing or as a starting template.
connector_id is optional. When it is omitted, the step resolves the connector server-side in this order:
genAiSettings:defaultAIConnector — the configured default AI connector (read via request-scoped uiSettings). The NO_DEFAULT_CONNECTOR sentinel and empty values are treated as unset.inference.getDefaultConnector — the platform default inference connector, used as a fallback when no default AI connector is configured.If neither source yields a connector, the step fails with a clear error asking the caller to configure a default or provide connector_id. Workflow-engine surfaces (the run step and the example workflow) have no agent execution context, so they always use this server-side resolution. Pass an explicit connector_id to override the configured default. The examples below include connector_id to show the override; drop it to use the configured default.
Retrieve the 100 most recent security alerts and generate discoveries using all defaults (including the configured default AI connector):
{}
connector_id defaults to the configured default AI connector (genAiSettings:defaultAIConnector → inference fallback)alert_retrieval_mode defaults to custom_querysize defaults to 100mode defaults to syncattack_discoveries inlinecustom_query — DSL query with overrides (sync)Scope retrieval to a specific time range and alert severity:
{
"connector_id": "<your-connector-id>",
"alert_retrieval_mode": "custom_query",
"size": 25,
"start": "now-72h",
"end": "now",
"filter": {
"term": { "kibana.alert.severity": "critical" }
}
}
esql — ES|QL query (sync){
"connector_id": "<your-connector-id>",
"alert_retrieval_mode": "esql",
"esql_query": "FROM .alerts-security.alerts-default METADATA _id | WHERE kibana.alert.severity == \"critical\" | LIMIT 50"
}
Merge ES|QL results with output from a custom alert retrieval workflow (parallel execution):
{
"connector_id": "<your-connector-id>",
"alert_retrieval_mode": "esql",
"esql_query": "FROM .alerts-security.alerts-default METADATA _id | WHERE kibana.alert.severity == \"high\" | LIMIT 30",
"alert_retrieval_workflow_ids": ["<your-retrieval-workflow-id>"]
}
Results from both sources are merged before generation.
provided — Pre-retrieved alerts (auto-detected)Pass alerts directly via the alerts input. The step auto-detects that alerts are provided and sets alert_retrieval_mode to provided, skipping all retrieval:
{
"connector_id": "<your-connector-id>",
"alerts": [
"Alert 1: Unusual process execution on host web-prod-01. Process: cmd.exe spawned by iis.exe.",
"Alert 2: Lateral movement detected. User admin logged in from 10.0.0.5 to 10.0.0.23 via PsExec.",
"Alert 3: Privilege escalation attempt. User admin added to Domain Admins group."
]
}
This is the primary composability pattern: an upstream workflow step populates alerts; the security.attack-discovery.run step generates discoveries without re-querying Elasticsearch.
In a workflow YAML:
- name: run_attack_discovery
type: security.attack-discovery.run
with:
alerts: ${{ steps.my_retrieval_step.output.alerts }}
connector_id: ${{ inputs.connector_id }}
custom_only — Custom retrieval workflows onlySkips the built-in retrieval and uses only results from alert_retrieval_workflow_ids.
Fire the pipeline without waiting. Returns execution_uuid immediately; discoveries are written to Elasticsearch in the background:
{
"connector_id": "<your-connector-id>",
"mode": "async"
}
execution_uuid (no attack_discoveries field)GET /api/attack_discovery/generations{
"connector_id": "<your-connector-id>",
"mode": "async",
"alert_retrieval_mode": "custom_query",
"size": 50,
"start": "now-48h",
"end": "now"
}
security.attack-discovery.run outputThe replacements map is excluded by the step's output schema — not just by the handler. A workflow that invokes run receives discoveries but cannot access the de-anonymization key. This prevents user-authored workflows from inadvertently logging or forwarding the replacements to external systems.
| Problem | Solution |
|---|---|
Workflow not found at /app/workflows/system-attack-discovery-run-example | Restart Kibana to trigger platform reconciliation of managed workflows |
connector_id not found | Run the connector list curl command in Quick start |
provided mode not auto-detected | Confirm alerts is a non-empty array of strings; explicit alert_retrieval_mode overrides auto-detection |
| Async results not appearing | Wait 30–60 seconds; check the Attack Discovery UI; search logs for the execution_uuid |
attack-discovery-generator is one of three Agent Builder skills registered by this plugin (alongside alert-retrieval-builder and workflow-troubleshooting). It is the analyst-facing front door to AD 2.0: rather than asking the user to compose a workflow or call _generate directly, the skill lets the agent gather and corroborate evidence with whatever tools it has, then delegates the generation pipeline to security.attack-discovery.run.
Definition: server/agent_builder/skills/attack_discovery_generator/attack_discovery_generator_skill.ts. Registration: server/agent_builder/skills/register_skills.ts.
The skill plays two roles: (1) the conversational front door described in this section (Modes A/B, the agent_builder trigger), and (2) the always-on generation-phase gate that the orchestration invokes for every other trigger — see Always-on generation-phase gate.
Across every execution path the skill provides the same three capabilities; what changes per path is which mode delivers them.
Cross-Skill Corroboration loads the four core corroboration skills (threat-hunting, entity-analytics, alert-analysis, graph-creation) with mandatory language, and Key Principles / Analysis Process enforce default-to-split independent evaluation, entity-correlation hygiene (a shared username/host/IP is necessary but not sufficient), and severity-weighted timeline construction. The Step 0 hard gate keeps the pipeline's returned discoveries as the only set the skill may report.Upfront Pipeline Pattern has the skill source the alerts it analyzes — call get_default_esql_query, run it via execute_esql, corroborate, then hand the curated set to the pipeline. Mode A's retrieval-mode preference is provided > esql > custom_only > custom_query, and bare (parameter-less) security.attack-discovery.run invocations are forbidden.{{ field uuid }} tokens the UI and persistence layer consume). The Missed Detection Closure pass then drafts ES|QL rules for any coverage gaps and pauses for the verbatim create the rule approval before invoking detection-rule-edit.| Mode | Invoked by | Role |
|---|---|---|
| A — Generate | Agent Builder conversation (agent_builder trigger) | Corroborate evidence, then delegate to security.attack-discovery.run |
| B — Status-only | Agent Builder conversation, or the fire-and-forget report workflow | Look up a prior run by execution_uuid and render the report |
| C — Ground-truth gate | The generation-phase gate, for every non-agent_builder trigger | Curate the candidate alert set and return a decision (remove_alert_ids / added_alert_ids / additional_context) — never a report, never security.attack-discovery.run |
All four entry points converge on executeGenerationWorkflow → runManualOrchestration; the trigger string is the only switch that decides whether the gate (Mode C) runs (shouldRunGate returns true for everything except agent_builder).
flowchart TD
subgraph entries [Four invocation methods]
UI["1. Ad hoc / manual
POST _generate
trigger=manual"]
SCHED["2. Scheduled
AD 2.0 schedule executor
trigger=schedule"]
AB["3. Agent Builder skill
trigger=agent_builder"]
STEP["4. Workflow run step
security.attack-discovery.run
trigger=workflow"]
end
EGW["executeGenerationWorkflow()"]
RMO["runManualOrchestration()"]
RET["Retrieval step
(deterministic ES|QL / DSL / custom workflows)"]
GATECHECK{"shouldRunGate(trigger)?"}
GATE["Gate = Mode C
runGatePhase -> invokeGateWorkflow
ai.agent skill mention"]
GEN["Generation step
invokeGenerationWorkflow()"]
VAL["Validation step
invokeValidationWorkflow() (persists)"]
RPTCHECK{"gate persisted conversation_id?"}
RPT["invokeSkillReportWorkflow()
Mode B report + missed-detection closure
into gate conversation"]
ABREPORT["Skill renders report inline
(Mode A / Mode B)"]
UI --> EGW
SCHED --> EGW
STEP --> EGW
AB -->|"Phase 1: corroborate (Mode A)"| AB
AB -->|"Phase 2: run tool"| EGW
EGW --> RMO --> RET --> GATECHECK
GATECHECK -->|"manual / schedule / workflow"| GATE --> GEN
GATECHECK -->|"agent_builder (skipped)"| GEN
GEN --> VAL --> RPTCHECK
RPTCHECK -->|"yes: paths 1, 2, 4"| RPT
RPTCHECK -->|"path 3"| ABREPORT
POST /internal/attack_discovery/_generate, trigger: 'manual') — retrieval → gate (Mode C) reviews the candidates and (with the Skill toggle on) retrieves additional alerts → generate → validate → persist. The rich report is delivered afterward by the fire-and-forget Mode B report workflow when the gate persisted a conversation_id; that report also runs the Missed Detection Closure pass — drafting candidate ES|QL rules for any coverage gaps and pausing at the verbatim create the rule approval gate (never auto-persisting rules).trigger: 'schedule') — identical gated pipeline; discoveries are also reported back to the Alerting Framework, and the Mode B report (including the Missed Detection Closure pass) is fire-and-forget into the gate's conversation. That conversation is the canonical surface for reviewing the report and its missed-detection rule proposals — the schedule execution flyout's ConversationLink is the entry point. Conversations are owned by the schedule's API-key owner and ACL-filtered by username, so visibility is single-owner; cross-user sharing is not currently supported (out of scope).trigger: 'agent_builder') — the gate is skipped; the conversational skill itself performs Mode A corroboration/retrieval and renders the report inline (and Mode B via get_status), including the Missed Detection Closure pass — the same closure the gated paths now run during their fire-and-forget Mode B report.security.attack-discovery.run step (trigger: 'workflow') — the same gated pipeline as manual.Capability → pipeline embodiment for the gated paths: review alert data → Mode C remove_alert_ids decision (informed by the gate's bounded multi-skill corroboration; keep = candidates − removed); retrieve own alert data → Mode C added_alert_ids when the Skill toggle is on; report on results → persisted discoveries in the AD UI plus the Mode B skill report, which renders the rich report and drafts missed-detection rule proposals behind the create the rule gate.
Every non-agent_builder generation (manual, schedule, workflow) runs the skill as an always-on ground-truthing gate during the generation phase. The gate is a separate workflow executed before the untouched generate workflow — the generation workflow YAML and the generate step's LangGraph logic are not modified. Orchestration: run_gate_phase/index.ts, invoked from run_manual_orchestration/index.ts.
_generate manual, scheduled, run step workflow, the agent_builder run tool) flow through the same executeGenerationWorkflow → runManualOrchestration path. The gate is invoked from that single TS path and skipped only for agent_builder (shouldRunGate) — the conversational skill has already ground-truthed its own data before delegating to the pipeline, so re-running the gate would double-invoke the skill. That trigger check is also the recursion break: if the gate's ai.agent ever called security.attack-discovery.run, the re-entry carries trigger === 'agent_builder' and skips the gate instead of recursing.ai.agent — one turn that ground-truths the candidates and, when the Skill toggle is on, retrieves its own additional alerts — and (2) the fire-and-forget report phase ai.agent (a resume of the gate's conversation_id) that renders the Attack Discovery Report after validation and then runs the Missed Detection Closure pass — a best-effort raw-log corroboration of the persisted chains that emits a ## ⚠️ Missed Detection heading per coverage gap, drafts a candidate ES|QL detection rule for each, and pauses at the verbatim create the rule approval (it never auto-persists a rule). The generate step is a separate LLM call, not a skill run. The report phase runs for all gated runs (including scheduled) and is fire-and-forget, so a report failure never affects the generation outcome.threat-hunting (raw-telemetry pivots), entity-analytics (host/user risk, asset criticality), and alert-analysis (alert drill-down) — best-effort against the candidates it is keeping, folding the findings into additional_context. Two hard guardrails keep this inside the gate's 10m timeout and token budget: (a) the output stays decision-only / ids-only (corroboration may only inform remove_alert_ids / added_alert_ids / a short additional_context summary — never a report or raw data), and (b) a budget cap (scope corroboration to the kept candidates, summarize findings, never dump raw telemetry, and skip a skill rather than blow the turn). The deeper corroboration trades additional gate latency/token use for stronger ground-truthing; the budget cap mitigates but does not eliminate that cost.manual / schedule / workflow generation._ids to drop (remove_alert_ids) + the _ids of any net-new alerts it retrieved itself (added_alert_ids) + additional_context (Constraint B — the gate never echoes the candidate bytes it received). Both id sets follow the same ids-only contract — the gate never emits raw alert strings. Keep is derived deterministically as candidates − remove_alert_ids, so an omitted/empty/truncated removal set keeps every candidate (recall-first) and a hallucinated remove id drops nothing. The orchestration forwards the original candidate alert strings for every candidate NOT listed in remove_alert_ids unchanged — it does not re-fetch or distill kept candidates. The retrieve-by-ids path (retrieveAnonymizedAlertsByIds) re-fetches + anonymizes only the gate's net-new added_alert_ids (Skill toggle on), which have no anonymized upstream form; that re-fetch is an internal hydration detail of the skill invocation and is folded into the single gate entry (never surfaced as a separate execution). The gate run is recorded as one entry under the event-log gate bucket (not alertRetrieval) so the monitoring UI surfaces it as a single sub-step under the Generation phase, with an interim badge count of added_alert_ids until the generate step input resolves the authoritative candidates − removed + added count._id contract + richest-wins dedup. Before the gate, candidates lacking a recoverable backing _id are rejected loudly (validate_candidate_alert_ids), and duplicate _ids from multiple sources collapse to the richest copy (dedupe_candidates_by_id).ai.agent (10m) plus generate (10m) plus validate (5m) share the 30m pipeline budget — see ADR-008.The skill registers a single Agent Builder skill. In its conversational role it supports two modes (Mode C, the always-on gate, is covered in Always-on generation-phase gate):
execute_esql, generate_esql, search, get_document_by_id, get_index_mapping, get_workflow_execution_status) plus the inline get_default_esql_query and security.attack-discovery.get_status tools, but other tools active in the session are also fair game.security.attack-discovery.run per ADR-012. The pipeline handles anonymization, LangGraph generation, hallucination detection, validation, and persistence to the Attack Discovery alerts index. Sync mode races a ~90s soft deadline against the 120s Agent Builder workflow-tool ceiling — fast generations return discoveries inline; slower generations return only an execution_uuid and the agent hands off cleanly with an in-progress acknowledgement.execution_uuid (or asks about a previously-started generation), the agent calls security.attack-discovery.get_status and emits the insights JSON if the run has succeeded, reports progress with the active phase if still running, or reports the failure cleanly. No new generation is started.defaultValidation + persistDiscoveries chain used by every other execution path (so they appear in the AD UI and via GET /api/attack_discovery/generations), regardless of which mode emitted them in the agent reply.flowchart TB
USER["Agent Builder user"]
AGENT["Agent + skill content"]
CORR["Corroboration phase
(execute_esql, search, threat intel, ...)"]
RUN["security.attack-discovery.run
(sync mode + ~90s soft deadline)"]
ORCH["Orchestrator pipeline
(retrieve → generate → validate → persist)"]
STATUS["security.attack-discovery.get_status"]
AD["Attack Discovery alerts index"]
REPLY_FAST["Agent final reply
(insights[] JSON)"]
REPLY_SLOW["Agent final reply
(in-progress + execution_uuid)"]
USER --> AGENT
AGENT --> CORR
CORR -->|"refined alerts / ES|QL / DSL filter"| RUN
RUN --> ORCH
ORCH --> AD
ORCH -->|"fast: attack_discoveries + execution_uuid"| AGENT
ORCH -->|"slow: execution_uuid only
(pipeline runs in background)"| AGENT
AGENT --> REPLY_FAST
AGENT --> REPLY_SLOW
USER -.->|"resume: 'check status of <uuid>'"| AGENT
AGENT -.-> STATUS
STATUS -.->|"succeeded → discoveries"| REPLY_FAST
STATUS -.->|"running / failed / not_found"| REPLY_SLOW
The skill teaches the agent to pick the security.attack-discovery.run mode that matches the evidence it just gathered, without re-doing retrieval inside the agent:
provided mode (alerts: string[]). This is the preferred path: the agent controls exactly which evidence goes into the pipeline.esql mode (esql_query). Combined with alert_retrieval_workflow_ids when the user has custom retrieval workflows to merge in parallel.custom_only mode with alert_retrieval_workflow_ids.custom_query mode with explicit size, start, and end values. The skill explicitly forbids omitting these and relying on server defaults.⛔ The skill explicitly forbids bare connector-ID-only invocations ({ "connector_id": "..." }) because they rely on server-side defaults that do not reflect the investigation context.
Sync mode is the default and the only mode the skill actively instructs the agent to use, per ADR-012. The run step's executor races the pipeline against ATTACK_DISCOVERY_RUN_SOFT_DEADLINE_MS (90s) so the wrapping Agent Builder workflow tool — which itself caps at 120s — always gets a clean response well inside its window. When the soft deadline wins, only execution_uuid is returned; the pipeline keeps running in the background and the agent resumes via security.attack-discovery.get_status when the user asks for status.
The Agent Builder tool resolves the LLM connector from the agent's selected model, not from a server-side platform default. When connector_id is omitted, the tool awaits context.modelProvider.getDefaultModel() and uses the resulting connector.connectorId — the full-fidelity connector the agent execution already selected (per-request override → genAiSettings default → fallback), so no Agent Builder context-shape change is needed. Passing connector_id overrides the agent's selected connector. If neither yields a connector, the tool returns the existing "no LLM connector available" error result. This differs from the workflow-engine run step, which has no agent execution context and instead resolves genAiSettings:defaultAIConnector (with an inference fallback) server-side — see Connector resolution under the run-step guide.
The skill's corroboration tools operate on raw data with the user's RBAC. Anything passed into security.attack-discovery.run is anonymized inside the pipeline before the LLM sees it. The skill content makes this explicit so the agent does not pass real (un-anonymized) values into provided mode — that input is contractually string[] of anonymized alert text. See Anonymization Boundary.
The skill prompt branches on which outcome is in hand:
status: succeeded): the agent acknowledges the run completed (referencing the execution_uuid so the operator can find the execution in the Workflows app and the AD UI), emits the insights JSON envelope inline, provides a per-chain narrative, and reports "no chains met the validation standard" rather than fabricating chains when the pipeline returned none.
{ "insights": [ { "title": "...", "alertIds": [...], "detailsMarkdown": "...", "summaryMarkdown": "...", "entitySummaryMarkdown": "...", "mitreAttackTactics": [...] } ] }
status: running): the agent does not emit the insights JSON. It writes a short status response with the execution_uuid, the active pipeline phase (when known), and a pointer to /app/security/attack_discovery. It offers to check status again on the next user prompt.status: failed or not_found): the agent does not emit the insights JSON. It reports the error message and phase (failed) or that the execution_uuid was not found.attackDiscoveryPrompts constants instead of duplicatingThe skill imports MITRE_ATTACK_TACTICS, SYNTAX, GOOD_SYNTAX_EXAMPLES, BAD_SYNTAX_EXAMPLES, the ATTACK_DISCOVERY_GENERATION_* field-description strings, and ATTACK_DISCOVERY_DEFAULT / ATTACK_DISCOVERY_REFINE directly from server/lib/prompt/local_prompt_object/attack_discovery_prompts.ts. Three reasons it must, and one it doesn't:
| Concern | Why reuse is load-bearing |
|---|---|
Inline insights[] output contract | The user-supplied skill spec demands the agent emit JSON in the schema shape inside its final reply. That envelope is the AD discovery schema with the top-level key renamed to insights. The agent cannot conform without seeing the schema. |
| Field-syntax preservation | The pipeline-produced details_markdown contains {{ host.name web-prod-01 }} placeholders. Without the syntax block in the skill content, models will "helpfully" expand them to web-prod-01 and break downstream UI rendering. The constants are a guard against well-intentioned mangling, not generation. |
| Agent-side regeneration on the refinement path | The skill's stricter rules (default-to-split, entity correlation hygiene, validation standard) can lead the agent to filter, split, or merge discoveries that the pipeline returned. Once it does that, it emits a chain the pipeline didn't produce — meaning the agent must conform to the schema, the MITRE enum, and the field syntax on its own. |
Not load-bearing: per-field description strings (ATTACK_DISCOVERY_GENERATION_DETAILS_MARKDOWN etc.) | These were authored for the LangGraph generate prompt. The agent doesn't need them to forward what run returned. Embedding them in the skill content adds tokens without behavioral benefit, but keeps a single source of truth across both the LangGraph and the skill. The decision to keep them is alignment-driven, not necessity. |
The two reference-content blocks (ATTACK_DISCOVERY_DEFAULT and ATTACK_DISCOVERY_REFINE) are included as referenced content (not embedded directly in content) so the agent can consult them on demand for cross-reference, while the skill's stricter rules in content take precedence where the two differ.
The agent does not call the LLM connector directly to produce discoveries. It always routes generation through security.attack-discovery.run. This preserves four guarantees that the orchestrator owns:
replacements excluded by schema — see ADR-010 and ADR-011.executionUuid plumbing as every other run.If the skill called the LLM directly, none of these guarantees would hold and we would have a second, parallel generation path to maintain.
The skill content does not name specific threat-intel, hunting, or entity-context tool ids. It instructs the agent to enumerate available tools and choose those relevant to evidence-gathering. Two reasons:
The getRegistryTools list (six platform-core tools) is the minimum the skill guarantees will be available; the agent can use anything else it can see.
get_default_esql_query inline toolRather than create a new inline tool, the skill calls getDefaultEsqlQueryTool() — the same tool used by alert-retrieval-builder. This keeps anonymization-field-aware default ES|QL behavior consistent across both skills and avoids a second copy of the space-specific KEEP-clause logic.
The skill is registered in register_skills.ts unconditionally within that module — the FF gate sits one level up in the plugin's setup, which only performs Agent Builder registration when the flag is ON. When the FF is OFF the plugin skips Agent Builder skill registration entirely, so the skill is never registered and Agent Builder users do not see it. (Routes and workflow step handlers are still registered when the FF is OFF — routes return 404 and step handlers throw via withWorkflowsEnabledGuard.)
The gate ai.agent step emits decisions only (Constraint B): a removal set of candidate _ids to drop (remove_alert_ids), the _ids of any net-new alerts the skill retrieved itself (added_alert_ids — Skill toggle on, up to size), and a short additional_context summary. It never echoes alert bytes — neither the candidates it received nor the net-new alerts it found. The orchestration keeps every candidate NOT listed in remove_alert_ids and forwards its original strings by _id, and re-fetches the net-new alerts server-side by _id (retrieveAnonymizedAlertsByIds).
Because the gate response carries only id arrays plus a short summary, it stays well under the actions framework's response-size cap (xpack.actions.maxResponseContentLength, applied at the axios layer — actions/server/lib/axios_utils.ts). This ids-only contract is what removes any dependency on that setting: full alert content never rides the connector response, so AD 2.0 requires no change to xpack.actions.maxResponseContentLength. (An earlier design echoed the full net-new alert strings through this response and could exceed the 1mb default; the ids-only contract replaced it precisely to avoid that.)
Skill-only Jest run:
node scripts/jest --coverage x-pack/solutions/security/plugins/discoveries/server/agent_builder/skills
Desk test (FF ON):
execute_esql against .alerts-security.alerts-default) before invoking security.attack-discovery.run.security.attack-discovery.run in sync mode and receives attack_discoveries inline.insights[] JSON envelope and a narrative.grep "Orchestration summary" /tmp/kibana.log to confirm the same executionUuid was logged.The security.attack-discovery.generate workflow step (and the orchestrator's per-phase boundaries) emit events to the Elasticsearch event log for generation tracking. These events enable:
GET /api/attack_discovery/generationsEvent log entries carry only metadata: execution_uuid, phase, outcome, duration, sanitized error reason. Specifically, no event field carries: alert content, query content, user identifiers (beyond user.name), connector credentials.
Caveat — providedAlerts legacy path. writeAttackDiscoveryEvent currently includes providedAlerts: string[] (anonymized alert strings) in event.reference for the provided retrieval mode. This was moved verbatim from elastic_assistant when the package was extracted and is tracked for tightening. Until that change ships, treat the event log as carrying anonymized alert text in that branch.
generation-started — emitted when generation beginsgeneration-succeeded — emitted on successful completion with metricsgeneration-failed — emitted on error with failure reasonalert-retrieval-*, generate-step-*, validation-*{
'@timestamp': string,
event: {
action: 'generation-started' | 'generation-succeeded' | 'generation-failed' | ...,
dataset: string, // Connector ID
duration?: number, // Duration in nanoseconds
end?: string,
outcome?: 'success' | 'failure',
provider: 'securitySolution.attackDiscovery',
reason?: string, // Sanitized failure reason; truncated to MAX_LENGTH
reference?: string, // JSON-encoded execution metadata
start?: string
},
kibana: {
alert: {
rule: {
consumer: 'siem',
execution: {
metrics?: { alert_counts: { active?: number, new?: number } },
status?: string,
uuid: string // Execution UUID (ties events together)
}
}
},
space_ids: [string]
},
message: string,
tags: ['securitySolution', 'attackDiscovery'],
user: { name: string }
}
Event logging utilities are shared between discoveries and elastic_assistant plugins via the @kbn/discoveries package:
writeAttackDiscoveryEvent — writes events to the event loggetDurationNanoseconds — calculates duration in nanosecondsATTACK_DISCOVERY_EVENT_LOG_ACTION_*This eliminates code duplication and ensures consistent event structure across both the public API and workflow-based generation.
In addition to the event log (which records per-run state), three EBT events report fleet-wide metrics. The full event catalog and KQL examples are in the telemetry README. Privacy constraints, in summary:
snake_case. Pre-existing camelCase fields on shared attack_discovery_success/error events are retained as-is.optional: true; legacy events that omit them still validate when the FF is OFF.Attack Discovery produces four categories of observable artifacts. Together they let you trace any single execution end-to-end:
| Artifact | Where | Default level | Purpose |
|---|---|---|---|
| Server logs | Kibana log output | INFO | Execution summary, startup health, pre-execution validation |
| Event log entries | .kibana-event-log-* index | — | Generation tracking via GET /api/attack_discovery/generations |
| Workflow execution details | Workflows app UI | — | Per-step status, inputs/outputs, timing |
| EBT telemetry | Elastic analytics pipeline | — | Fleet-wide success/error/misconfiguration/step-failure metrics |
executionUuidEvery generation run is assigned a unique executionUuid (UUIDv4). The traced logger prefixes all log messages for that run with [execution: {uuid}], making it easy to filter logs for a single execution:
[2026-03-09T10:30:00.000Z][INFO ][plugins.discoveries] [execution: abc-123-def] Orchestration summary [succeeded] in 12345ms | alerts: 50, discoveries: 3
To filter for a specific execution:
grep "execution: abc-123-def" /tmp/kibana.log
The same executionUuid appears in:
[execution: {uuid}] prefix)kibana.alert.rule.execution.uuid)execution_uuid on attack_discovery_step_failure)POST /internal/attack_discovery/_generateAfter every orchestration run (success or failure), a single INFO-level summary is logged. This summary mirrors the Workflow Execution Details UI and is available with default logging settings:
[execution: abc-123-def] Orchestration summary [succeeded] in 12345ms | alerts: 50, discoveries: 3
retrieval: succeeded (4500ms) [system-attack-discovery-alert-retrieval] /app/workflows/system-attack-discovery-alert-retrieval?tab=executions&executionId=ret-run-id
generation: succeeded (6000ms) [system-attack-discovery-generation] /app/workflows/system-attack-discovery-generation?tab=executions&executionId=gen-run-id
validation: succeeded (1800ms) [system-attack-discovery-validate] /app/workflows/system-attack-discovery-validate?tab=executions&executionId=val-run-id
Each line includes step status, duration, the system workflow that was executed, and a clickable path to the Workflows app execution details page. On failure, the failed step includes the error message.
Before each orchestration step, a DEBUG-level health check logs the preconditions. These have zero cost when debug logging is off (lazy evaluation via logger.debug(() => ...)).
Enable debug logging in kibana.dev.yml:
logging:
loggers:
- name: plugins.discoveries
level: debug
Security note:
debugforplugins.discoveriesalso makesActionsClientLlmlog the full LLM request (prompt + anonymized alerts) and the full response / upstream error body on every call. Treat this as sensitive data: enable only for short-lived, controlled troubleshooting and revert once logs are captured. The same applies toplugins.elasticAssistant: debugon the legacy path.
| Step | Preconditions checked |
|---|---|
| retrieval | alertsIndexPattern, anonymizationFieldCount, connectorId, customWorkflowIds, defaultAlertRetrievalWorkflowId, retrievalMode |
| generation | alertCount, connectorId, generationWorkflowId |
| validation | defaultValidationWorkflowId, discoveryCount, persist, validationWorkflowId |
executeGenerationWorkflow runs four pre-execution checks concurrently (Promise.all) before the pipeline starts:
graph TD
A[validatePreExecution] --> B[Promise.all]
B --> C[checkWorkflowsManagementApi]
B --> D[checkDefaultWorkflowIds]
B --> E[checkAlertsIndex]
B --> F[checkConnectorAccessibility]
C --> G{critical?}
D --> G
E --> H{warning?}
F --> H
G -->|yes| I[abort: valid=false]
H -->|yes| J[log warn, continue]
| Check | Severity | Message |
|---|---|---|
| WorkflowsManagement API | Critical | WorkflowsManagement API is not available; cannot execute workflows |
| Default workflow IDs | Critical | Default workflows could not be resolved; cannot execute workflows |
| Alerts index existence | Warning | Alerts index '{pattern}' does not exist |
| Connector accessibility | Warning | Connector '{id}' is not accessible: {error} |
Critical issues (WorkflowsManagement API unavailable, default workflow IDs unresolvable) abort the pipeline. Warnings (alerts index missing, connector unreachable) are logged but execution proceeds — and they emit attack_discovery_misconfiguration EBT events for fleet-wide visibility. See telemetry README.
If a UI surface or route appears missing or returns 404, the feature flag may be off:
# 1. Hit the route and confirm 404 vs 200
curl -s -u elastic:changeme -H 'kbn-xsrf: true' \
-X POST 'http://localhost:5601/internal/attack_discovery/_generate' \
-H 'Content-Type: application/json' -d '{}'
# 404 → FF is OFF; 4xx with validation → FF is ON
# 2. Check server logs for the startup health check
grep -a 'Startup health check' /tmp/kibana.log | head -10
The event log lives in .kibana-event-log-*. To find all events for a specific execution:
event.provider : "securitySolution.attackDiscovery" and kibana.alert.rule.execution.uuid : "abc-123-def"
To find recent failures across all executions:
event.provider : "securitySolution.attackDiscovery" and event.outcome : "failure"
| Symptom | Likely cause | Where to look |
|---|---|---|
UI shows 404 on _generate | FF is OFF | Verify securitySolution.attackDiscoveryWorkflowsEnabled in kibana.dev.yml; check startup health check in server logs |
| Orchestrator times out (30 min budget exceeded) | Stuck LLM call or slow gate/retrieval | Workflows app → execution details → identify which phase exceeded its sub-budget (gate ai.agent, generate, or validate); check connector accessibility |
Generation phase fails before the generate step | The always-on gate ai.agent failed (fail-closed) | The gate runs before the LLM generate step; inspect the gate workflow execution (ai.agent step) — a gate error/timeout fails the whole run. Verify the gate connector and create-conversation work headless (scheduled/run-step contexts) |
Run fails with prior run was interrupted | Kibana restarted, crashed, or shut down mid-execution (in dev, the file-watcher restarting the server) | Error category interrupted (distinct from timeout/concurrent_conflict) — not a config/model/RBAC issue. Simply re-run. If interruptions recur in production, investigate Kibana restarts (deploys, OOM kills, node rollovers) |
| Run succeeds but no Attack Discovery Report appears in the conversation | The fire-and-forget system-attack-discovery-skill-report run failed or was interrupted — it is independent of the three pipeline phases and not shown in the execution-details flyout | Check the system-attack-discovery-skill-report workflow execution status for the run's execution_uuid / conversationId; a report failure never fails generation. Re-running generation re-schedules the report |
Pipeline aborts with repair_failed | A required managed workflow is missing, unmanaged, or disabled | Server log around checkManagedWorkflowIntegrity; navigate to http://localhost:5601/app/workflows/system-attack-discovery-generation and confirm the workflow exists and is enabled; restart Kibana to trigger platform reconciliation |
| Schedule fires but no discoveries appear | Tag-based isolation drift | Confirm the schedule was created via the internal API (carries the attack-discovery-schedule tag); compare with find / get route output |
| EBT events missing from analytics | Either FF is OFF or core.analytics is unavailable | Verify FF; check core.analytics.registerEventType calls in discoveries/server/plugin.ts |
When the plugin starts, it logs the result of a startup health check:
Startup health check passed: workflow steps registered, WorkflowsManagement API availableStartup health check found issues: {issue1}; {issue2}Possible issues:
Workflow steps were not registeredWorkflowsManagement API is not availableBefore the pipeline starts, the system verifies the integrity of the AD managed workflows by introspecting platform state. The AD workflows are registered as global system-… managed workflows; the platform (not the AD plugin) owns reconciliation, version-based upgrade (versionStrategy: 'auto'), and orphan cleanup.
The AD-side checkManagedWorkflowIntegrity function checks each workflow's presence, managed status, enabled state, and definition hash. It reports diagnostic outcomes but does not perform restoration — a hash mismatch means the platform will reconcile on the next restart.
Error visibility:
| Scenario | Log level | Telemetry |
|---|---|---|
| All intact | DEBUG | None |
| Definition hash mismatch (platform will reconcile on restart) | DEBUG | workflow_modified per workflow |
| Workflow missing / unmanaged / disabled | ERROR | None (execution aborted before telemetry) |
Key implementation files:
server/managed_workflows/check_managed_workflow_integrity.tskbn-workflows/managed/definitions/discoveries/index.ts@kbn/discoveries verify_workflow_integrity/The Attack Discovery settings flyout performs async runtime checks when workflow settings change:
Issues are displayed in the validation callout:
Scenario: A user reports that Attack Discovery shows "0 new attacks discovered."
Check the execution summary in the Kibana server log (INFO level, no config changes needed):
grep "Orchestration summary" /tmp/kibana.log | tail -5
Look for the most recent execution. The summary shows which step failed and how long each step took.
Follow the workflow link from the execution summary to view detailed inputs/outputs in the Workflows app.
Check for pre-execution warnings (also INFO/WARN level):
grep "Pre-execution validation" /tmp/kibana.log | tail -5
Common issues: alerts index doesn't exist, connector not accessible.
Enable DEBUG logging for deeper investigation (see DEBUG-level health checks). Health checks before each step reveal the exact preconditions.
Check EBT telemetry for fleet-wide patterns — see the telemetry README for attack_discovery_misconfiguration and attack_discovery_step_failure events.
Scheduling is always Alerting-Framework-backed regardless of the feature flag state. The Alerting Framework owns scheduling, alert persistence, and action execution (with full throttling/frequency support); the Workflows engine owns only the generation pipeline. The native scheduling features of Workflows will eventually replace the public Attack Discovery create schedule API.
workflowConfig field additively.workflow_executor — the Alerting Framework rule executor (in server/lib/schedules/workflow_executor/); delegates to executeGenerationWorkflow instead of inline generation. Runs in the authenticated user's context (asCurrentUser), not internal user.create_schedule_data_client — factory that configures AttackDiscoveryScheduleDataClient from @kbn/attack-discovery-schedules-common. Uses applyTags: [ATTACK_DISCOVERY_SCHEDULE_TAG] on writes but sets no read filterTags — the internal API surfaces both its own tagged schedules and legacy (untagged) schedules.Internal-API and public-API schedule visibility is asymmetric, not bidirectional:
| Caller | Sees | Does not see |
|---|---|---|
| Public API user (legacy) | Schedules created via the public API | Workflow-tagged schedules (excludeTags filters them out) |
| Internal API user (workflows on) | All schedules — its own workflow-tagged schedules and legacy (untagged) schedules | — |
The internal API deliberately reads unfiltered so the workflow-scheduling UI can show and migrate pre-existing legacy schedules. Only the legacy public API filters (via excludeTags); it depends on legacy schedules never carrying the attack-discovery-schedule tag — that invariant lives in elastic_assistant and is verified by the Scout API tests in test/scout/api/.
Action throttling and frequency settings continue to work because they are owned by the Alerting Framework, not by the Workflows engine. New action settings on workflow-tagged schedules behave identically to settings on legacy schedules.
The following internal routes expose schedule CRUD operations for the workflow-backed scheduling path. All routes are FF-gated (assertWorkflowsEnabled, returning 404 when OFF) and use asCurrentUser only — privilege escalation via asInternalUser is never used. Writes tag new schedules with attack-discovery-schedule, but reads are not tag-filtered (the internal API surfaces all schedules). Create and update additionally call assertAlertsIndexPatternInSpace to reject a params.alerts_index_pattern that isn't the caller's own space-specific alerts index (.alerts-security.alerts-<spaceId>) with a 400.
Privileges:
GET): [ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ]POST/PUT/DELETE): [ATTACK_DISCOVERY_API_ACTION_UPDATE_ATTACK_DISCOVERY_SCHEDULE, ATTACK_DISCOVERY_API_ACTION_ALL, ALERTS_API_READ]Creates a new workflow-tagged attack discovery schedule. The schedule is registered with the Alerting Framework and tagged attack-discovery-schedule for isolation from legacy public-API schedules.
Request:
{
name: string,
enabled?: boolean,
params: {
alerts_index_pattern: string,
api_config: {
connector_id: string,
action_type_id: string,
default_system_prompt_id?: string,
provider?: string,
model?: string,
name?: string
},
size: number,
start?: string,
end?: string,
filters?: unknown[],
query?: { query: string | object; language: string },
combined_filter?: object,
// Composite config: three independent retrieval toggles compose the alert set;
// at least one must be enabled. The always-on gate runs regardless of skill_enabled.
workflow_config?: {
skill_enabled?: boolean, // Toggle 1 (default: true)
default_retrieval_enabled?: boolean, // Toggle 2 (default: false)
alert_retrieval_mode?: 'custom_query' | 'esql', // default-retrieval query mode (default: custom_query)
esql_query?: string, // required when default_retrieval_enabled + alert_retrieval_mode === 'esql'
alert_retrieval_workflows_enabled?: boolean, // Toggle 3 (default: false)
alert_retrieval_workflow_ids?: string[], // default: []
validation_workflow_id?: string // default: 'default'
}
},
schedule: { interval: string },
actions?: ScheduleAction[]
}
Response: AttackDiscoverySchedule — the full schedule object including id, created_at, updated_at, enabled, last_execution, etc.
Returns a paginated list of workflow-tagged attack discovery schedules visible to the current user.
Query parameters:
page?: number // Page number (default: 1)
per_page?: number // Results per page (default: 10)
sort_field?: string
sort_direction?: 'asc' | 'desc'
Response:
{
data: AttackDiscoverySchedule[],
page: number,
per_page: number,
total: number
}
Returns a single workflow-tagged schedule by ID.
Path parameters: id: string
Response: AttackDiscoverySchedule
Replaces the mutable fields of an existing workflow-tagged schedule. The params, schedule, actions, and name fields are all updated atomically; the existing workflow_config inside params is used as the baseline and merged with the incoming update.
Path parameters: id: string
Request:
{
name: string,
params: AttackDiscoveryScheduleParams, // same shape as POST body params field
schedule: { interval: string },
actions: ScheduleAction[]
}
Response: AttackDiscoverySchedule
Permanently deletes a workflow-tagged schedule from the Alerting Framework.
Path parameters: id: string
Response: { id: string }
Enables a workflow-tagged schedule so the Alerting Framework begins firing it on its configured cadence.
Path parameters: id: string
Response: { id: string }
Disables a workflow-tagged schedule without deleting it. The schedule remains in the Alerting Framework but will not fire until re-enabled.
Path parameters: id: string
Response: { id: string }
The Scheduling section above describes the moving parts (schedule SO, workflow_executor, data client, tag-based isolation). This section answers the questions that come up when the securitySolution.attackDiscoveryWorkflowsEnabled feature flag (FF) is toggled on a running system: what is shared with AD 1.0, what AD 2.0 adds, how the Alerting Framework chooses an executor, and the exact visibility/execution guarantees (with the Jest tests that lock them in).
One sentence to anchor everything below: scheduling is always Alerting-Framework-backed; the FF and the workflow path change only how a schedule generates discoveries and which CRUD surface manages it — never the rule type, the saved object, or who owns cadence/persistence/throttling.
Alerting Framework workflowExecutor meansThis term appears early in the Overview and is easy to misread by anyone familiar with AD 1.0 schedules. It does not mean "the Workflows engine runs the schedule." Three facts disambiguate it:
attack-discovery (ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID), registered by elastic_assistant (register_schedule/definition.ts). AD 2.0 did not add a second rule type or saved-object type.attackDiscoveryScheduleExecutor (register_schedule/executor.ts). When the rule fires, this function decides — at runtime, from the rule's own params — which generation path to take.workflowExecutor is the AD 2.0 branch of that executor, not a Workflows-engine primitive. It is a factory the discoveries plugin hands to elastic_assistant during setup (discoveries/server/plugin.ts → registerAttackDiscoveryWorkflowExecutor). The factory runs workflowExecutor, which calls the same executeGenerationWorkflow shared by the UI and the run step. Only the generation pipeline runs as workflow steps; the Alerting Framework still owns scheduling, persistence, and action throttling.flowchart TB
RULE["attack-discovery-schedules rule fires
(one rule type, one SO type)"]
EXEC["attackDiscoveryScheduleExecutor
(one executor — elastic_assistant)"]
Q{"params.workflowConfig
present?"}
WF["workflowExecutor (discoveries)
→ executeGenerationWorkflow
(AD 2.0, workflow steps)"]
LEG["generateAttackDiscoveries
(AD 1.0, inline)"]
RULE --> EXEC --> Q
Q -->|yes| WF
Q -->|no| LEG
| Concern | AD 1.0 (untagged / legacy) | AD 2.0 (workflow-tagged) | Where |
|---|---|---|---|
| Alerting rule type & saved object | attack-discovery-schedules | same (no migration; workflowConfig is additive) | register_schedule/definition.ts |
| Owner of cadence, persistence, action throttling/frequency | Alerting Framework | same | Scheduling → Action throttling |
| Rule executor entry point | attackDiscoveryScheduleExecutor | same function, different branch | register_schedule/executor.ts |
| Generation engine | inline generateAttackDiscoveries | executeGenerationWorkflow (workflow steps) | workflow_executor/index.ts |
params.workflowConfig | absent | new, additive — the dispatch key | workflow_executor/index.ts |
attack-discovery-schedule tag | never present | new — applied on every internal-API write | create_schedule_data_client/index.ts |
| CRUD surface | public elastic_assistant API | new FF-gated internal routes | Schedule-related internal APIs |
| Read visibility filter | excludes tagged schedules | no include filter (surfaces all) | create_schedule_data_client/index.ts |
New concepts introduced for AD 2.0 (none of which existed in the AD 1.0 schedules implementation):
workflowConfig rule param — an additive field on the existing rule SO that both (a) selects retrieval/validation workflows and (b) acts as the executor's dispatch key.discoveries registers a workflowExecutor factory with elastic_assistant at setup; elastic_assistant owns the rule type and calls back into discoveries only for workflow-configured schedules.attack-discovery-schedule tag plus asymmetric read filters (legacy excludes it; internal includes everything). See ADR-014.The branch is keyed on the data (params.workflowConfig), not on the feature flag:
flowchart LR
P{"params.workflowConfig != null"}
P -->|yes| F{"workflow executor
factory registered?"}
P -->|no| L["AD 1.0: generateAttackDiscoveries
(inline LLM call)"]
F -->|yes| W["AD 2.0: executeGenerationWorkflow
(workflow steps)"]
F -->|no| ERR["TaskRunError (USER source)
— no retry"]
Two consequences that surprise people:
workflowExecutor task factory is registered during the discoveries plugin's setup() gated by elasticAssistant presence, not the FF (discoveries/server/plugin.ts). But the registered executor checks the flag at run time via isWorkflowsEnabled: when the FF is OFF it no-ops (returns { state: {} }, discoveries/server/plugin.ts ~L324), and executeGenerationWorkflow itself throws Attack Discovery workflows are not enabled when the flag is OFF (execute_generation_workflow.ts ~L536). So a schedule that carries workflowConfig stops doing generation work while the FF is OFF — the rule still exists and keeps its cadence, but each fire is a no-op until the flag is turned back ON.workflowConfig + tag are still a one-way street. Once a schedule is edited under the internal API it gains both the workflowConfig param and the attack-discovery-schedule tag (→ hidden from the legacy view). Turning the FF off pauses workflow execution but does not strip workflowConfig, so the schedule does not silently fall back to AD 1.0 behavior — it resumes workflow execution when the FF is ON again (see C3 below).Both branches end at the Alerting Framework's alertsClient.report, but they differ in what is reported and how the discoveries travel there. The legacy branch reports the raw generation output inline; the workflow branch reports the persist-step handover (discoveries_to_persist) — the transformed/validated discoveries the persist step was handed. The branch point is the same line for both; the dispatch key is workflowConfig (not the FF — see above).
| Stage | Legacy branch (workflowConfig absent) | Workflow branch (workflowConfig present) |
|---|---|---|
| Branch point | register_schedule/executor.ts L76 if (workflowConfig != null) — falls through to the inline path | same line → workflowExecutorFactory(options) (L87) |
| Generation engine | inline generateAttackDiscoveries (executor.ts L132) | executeGenerationWorkflow (workflow steps), driven by workflow_executor/index.ts |
| What is reported | the raw generated attackDiscoveries | the persist-step handover discoveries_to_persist (transformed/validated, not raw) |
| Handover carrier | n/a (in-memory generation output) | persist output discoveries_to_persist (get_persist_discoveries_step_definition.ts L66/L78/L154) → extractDiscoveriesToPersist (invoke_validation_workflow.ts L350) → ValidationResult.discoveriesToPersist → workflow_executor/index.ts L199 |
| Write to Alerting Framework | alertsClient.report (executor.ts L233) | alertsClient.report (workflow_executor/index.ts L242) |
| Empty / absent handover | n/a | noop + WARN (workflow_executor/index.ts L204-210) — nothing reported, no setAlertData, no updateAlertsWithAttackIds |
workflow_executor/index.ts L204-210). A custom validation workflow MUST invoke security.attack-discovery.persistDiscoveries for its discoveries to reach the Alerting Framework.{{ field value }} placeholder tokens, and replacements is keyed to those exact tokens. A custom transform that rewrites details_markdown / summary_markdown can break the token↔replacement match, so de-anonymization on display silently fails. (The scheduled executor still sources replacements from the generation result, not from the transformed handover.)alert_ids must be preserved by custom transforms. Source-alert linkage and updateAlertsWithAttackIds (workflow_executor/index.ts L293) depend on each discovery's alert_ids. A transform that drops or rewrites them breaks the alert→attack back-reference.generateAttackDiscoveryAlertHash hashes discovery content, so a transformed discovery gets a new identity (expected). The default validation workflow leaves surviving discoveries unchanged, so there is no dedup regression on the default path.attack_discoveries) for scheduled runs (get_scheduled_input_discoveries), which equals the handover — so what the UI shows and what the Alerting Framework persists stay in agreement.The table encodes the guarantees a user can rely on as the FF is toggled. "Legacy view" = the public elastic_assistant find API (and the UI when the FF is OFF); "internal view" = the FF-gated internal find API (and the UI when the FF is ON).
| # | Scenario | Expected result | Covered by (Jest) |
|---|---|---|---|
| C1 | A schedule created while the FF was OFF (untagged) is viewed in the legacy view | Visible — and stays visible forever. The legacy data client only ever excludes tagged schedules; it never hides untagged ones. | legacy client wiring: request_context_factory.ts; exclude-filter behavior: data_client/index.test.ts ("...exclude tag filter when filterTags.excludeTags is set"); route: public/get/find.test.ts |
| C2 | That same untagged schedule is viewed in the internal view (FF ON) | Visible. The internal client sets no includeTags filter, so it surfaces both its own tagged rules and untagged legacy rules. | create_schedule_data_client/index.test.ts ("does not set a filterTags include filter so the internal API surfaces both its own and legacy (untagged) schedules"); route: find_schedules.test.ts |
| C3 (visibility) | An untagged schedule is mutated under the internal API (FF ON) | It adopts the attack-discovery-schedule tag (existing tags preserved + merged + de-duplicated) and therefore leaves the legacy view — a one-way street. | tag merge: data_client/index.test.ts ("merges existing tags with applyTags additively", "deduplicates tags..."); route: update_schedule.test.ts |
| C3 (execution) | The same mutation persists workflowConfig (existing config used as baseline) | The executor dispatches on workflowConfig presence, so the schedule takes the workflow path — but the workflow path is FF-gated at run time: while the FF is OFF each fire no-ops ({ state: {} }) and executeGenerationWorkflow throws. With the FF ON it behaves exactly like an FF-ON-created schedule. | baseline merge: transform_update_props_from_api/index.test.ts; dispatch: executor.test.ts ("...when workflowConfig is present but executor factory returns undefined"); factory registration + FF no-op: plugin.test.ts |
| Invariant | Legacy/public-API code path must never write the attack-discovery-schedule tag | If it ever did, C1 would break (legacy schedules would hide themselves). Enforced in elastic_assistant and verified end-to-end. | Scout API tests: test/scout/api/ |
The net effect across C1–C3: a schedule created with the FF off never disappears on its own (C1 + C2), but the moment a user edits it under the FF, it becomes a workflow schedule for good (C3) — visibility and execution move together, so the running schedule always reflects the user's most recent intent.
@kbn/workflows-plugin — Workflow engine (required)@kbn/discoveries — Shared server-side business logic and event logging utilities@kbn/discoveries-schemas — OpenAPI-generated types and Zod (v3) validators for route validation@kbn/attack-discovery-schedules-common — Shared schedule infrastructure (data client, transforms, field map)@kbn/actions-plugin — Connector execution (required)@kbn/alerting-plugin — Schedule rule registration (required)@kbn/event-log-plugin — Event logging for generation tracking (required)@kbn/security-plugin — User authentication (required)@kbn/spaces-plugin — Space ID resolution (optional)@kbn/elastic-assistant-plugin — Optional executor registration for scheduled workflow executionRun the four required Jest jobs:
node scripts/jest --coverage x-pack/solutions/security/packages/kbn-discoveries
node scripts/jest --coverage x-pack/solutions/security/plugins/discoveries
node scripts/jest --coverage x-pack/solutions/security/plugins/elastic_assistant
node scripts/jest --coverage x-pack/solutions/security/plugins/security_solution/public/attack_discovery
Type check (scoped):
node scripts/type_check --project x-pack/solutions/security/plugins/discoveries/tsconfig.json
Scout API tests for internal schedule routes are documented in test/scout/api/README.md.
The persist-step handover model (epic kibana-j4y) encodes requirements R1–R4 plus the empty/no-persist cases as unit tests:
| Requirement / scenario | Behavior | Locked in by (Jest) |
|---|---|---|
| R1 — no persist step ⇒ noop + WARN | A validation workflow that never invokes the persist step persists nothing; the handover defaults to [] and a WARN is logged. | invoke_validation_workflow.test.ts (defaults discoveriesToPersist to an empty array when no persist step ran (R1), logs a warning when no persist step ran (R1)); workflow_executor/index.test.ts (does not call alertsClient.report when the handover is empty/absent, logs a warning when the handover is empty/absent) |
| R2 — only the handed discoveries are persisted (persist input == handover) | The persist step echoes exactly its input attack_discoveries as discoveries_to_persist. | get_persist_discoveries_step_definition.test.ts (echoes the input attack_discoveries as discoveries_to_persist, echoes the input attack_discoveries as discoveries_to_persist for scheduled executions) |
| R3 — ad-hoc & run step persist via the step; run step inline output == handover | The run step's sync output sets attack_discoveries to the handover (discoveriesToPersist), not the raw generation output. | get_run_step_definition.test.ts (returns the persist handover discoveries instead of raw generation output) |
| R4 — scheduled reports the handover (not raw generation) | The scheduled executor reports the transformed handover to alertsClient, never raw generation discoveries absent from the handover. | workflow_executor/index.test.ts (reports the persist handover discoveries instead of raw generation output, does not report raw generation discoveries that are absent from the handover) |
| Empty input — empty array handed to persist ⇒ empty handover / noop | An empty input echoes []; the run step returns []; the scheduled executor reports nothing. | get_persist_discoveries_step_definition.test.ts (echoes the empty input as discoveries_to_persist); get_run_step_definition.test.ts (returns an empty array when the handover is empty/absent); workflow_executor/index.test.ts (does not call alertsClient.report when the handover is empty/absent) |
Passthrough — handover rides ValidationResult onto the orchestration outcome | The validation step surfaces discoveriesToPersist on the outcome consumed by the scheduled executor and the run step. | validation_step/index.test.ts (passes discoveriesToPersist through onto the outcome) |
The records below preserve the historical rationale behind each load-bearing design choice. Each record uses the Context / Decision / Consequence structure.
Context. Attack Discovery 1.0 was a monolithic endpoint: one HTTP handler retrieved alerts, invoked the LLM, validated results, and persisted discoveries — all inline. This made the pipeline opaque to operators and impossible to customize without forking the plugin.
Decision. Decompose the pipeline into Kibana Workflows steps. Register defaultAlertRetrieval, generate, defaultValidation, persistDiscoveries, and run as first-class workflow steps under the security.attack-discovery.* namespace.
Consequence. Four capabilities the monolithic approach could not provide:
security.attack-discovery.generate step can appear in a user-authored workflow alongside custom pre/post-processing, with data threaded via Liquid expressions.executeGenerationWorkflow powers both the interactive _generate endpoint and the alerting-framework scheduler, eliminating separate code paths.Context. Some parts of the pipeline (orchestration, event logging, pre-execution validation, integrity verification) are implementation details; others (run, defaultAlertRetrieval, generate, defaultValidation, persistDiscoveries) need to appear in the Workflows step catalog so users can compose them in YAML.
Decision. Maintain two tiers:
plugin.setup() and appear in the catalog. Their schemas are part of the public contract.Consequence. The step catalog stays minimal and intentional. Internal helpers can change shape freely without breaking user-authored workflows.
run step alongside the four phase stepsContext. The four phase steps (retrieval, generate, validation, persist) require a workflow author to thread intermediate data via Liquid expressions — non-trivial boilerplate.
Decision. Add security.attack-discovery.run as a single step that internally executes the full pipeline and exposes a minimal input surface (every input is optional — connector_id defaults to the configured default AI connector when omitted).
Consequence. Dramatically reduces the surface area a workflow author must understand. Advanced users who need to inject custom logic between phases can still compose the individual steps directly. The run step is the recommended entry point for Agent Builder integrations.
run step takes optional connector_id, not api_configContext. Every connector already knows its own action type. Requiring callers to provide both action_type_id and connector_id is redundant and error-prone. Beyond that, Attack Discovery should not depend on a caller always supplying a connector, nor on any Agent Builder-specific connector primitive — the connector should resolve from the execution context or platform defaults.
Decision. Take an optional connector_id; resolve the action type from the connector at runtime. When connector_id is omitted, resolve a default based on the surface: the workflow-engine run step (no agent context) reads genAiSettings:defaultAIConnector with an inference.getDefaultConnector fallback; the Agent Builder tool resolves the agent's selected model via context.modelProvider.getDefaultModel(). An explicit connector_id overrides either default.
Consequence. Simpler input contract; one less field for callers to get wrong; callers can omit the connector entirely and rely on the configured default. Attack Discovery ships its connector handling with no dependency on an Agent Builder connector-shape change — the only AB-owned file the AD stack touches is allow_lists.ts (skill-ID registration).
run does not internally call workflow.executeContext. An alternative design would have the run step call workflow.execute to invoke an existing generation workflow internally.
Decision. Reject. The run step handler calls executeGenerationWorkflow directly (which in turn calls runManualOrchestration) — composing phase functions in-process instead of nesting workflow executions.
Consequence. Avoids three problems:
run stepContext. Existing _generate endpoint is async because LLM generation can take minutes and HTTP requests should not block that long. Agent Builder tools and workflow steps that compose AD need the result inline.
Decision. Support both modes via a mode enum input on the run step (sync blocks until completion; async returns execution_uuid immediately). The underlying pipeline logic is identical — only the response envelope differs.
Consequence. Single code path for both call patterns. Sync mode unblocks Agent Builder; async mode preserves the existing event-log polling contract for the UI.
_generate endpoint stays asyncContext. Generation routinely takes 2–5 minutes. Kibana's default idle socket timeout is 2 minutes.
Decision. Keep POST /internal/attack_discovery/_generate async — it returns execution_uuid, not discoveries.
Consequence. Five structural reasons:
GET /api/attack_discovery/generations for status; multiple browser tabs and the scheduler can observe the same execution.executeGenerationWorkflow. A sync _generate would need a separate code path.Context. A gated generation now spans four workflow executions — the always-on gate (ai.agent), generation, validation, and the fire-and-forget report — plus a consumer-side poll (pollForWorkflowCompletion) that waits for each workflow execution to finish. The gate and the generate workflow are two separately-bounded ai.agent/LLM phases that both run inside the generation phase. runManualOrchestration enforces a single total pipeline budget (DEFAULT_PIPELINE_TIMEOUT_MS) shared across the gate, generation, and validation (the report phase is fire-and-forget and runs outside the budget).
Decision. Each layer's timeout is strictly less than or equal to the layer above it. The total pipeline budget is the outermost boundary; individual LLM calls are the innermost. Critically, each phase's consumer-side poll is bounded by the remaining pipeline budget (maxWaitMs: getRemainingBudgetMs()) and must be ≥ that phase's inner workflow step timeout — otherwise the poll gives up while the workflow step is still legitimately running. This applies to the always-on gate ai.agent step (10m timeout), generation (10m), and validation (5m). The budget was raised from 15m to 30m so the gate plus generation plus validation comfortably fit on every gated run (the gate is a full extra inference pass that the 15m budget left no room for once the gate approached its own timeout).
gantt
title Attack Discovery Timeout Budget (30 min total)
dateFormat X
axisFormat %M:%S
section Pipeline Layer
Total pipeline budget :a1, 0, 1800
section Workflow Layer
Always-on gate (10m) :a2, 0, 600
Generation (10 min max) :a3, 600, 1200
Validation (5 min max) :a4, 1200, 1500
section LLM Layer
Connector timeout per call :a5, 0, 300
Consequence. Timeouts propagate inside-out: a slow LLM call triggers a connector timeout, which triggers a step failure, which runManualOrchestration catches and reports. Because each phase poll is sized to the remaining budget (rather than a hard-coded 5m), the gate ai.agent step gets the time it needs while generation and validation still share whatever budget remains. Because the gate is fail-closed, a gate timeout fails the whole run loudly rather than passing un-ground-truthed candidates through.
Regression guarded against. Before the remaining-budget threading, a phase poll defaulted to a hard-coded 5m while the
ai.agentstep allowed 10m, so the consumer-side poll threwWorkflow timed out after 300000mseven though the workflow step was still running. The fix threads the remaining pipeline budget into every phase poll.
No retry-on-timeout for the gate ai.agent step. The gate step deliberately omits on-failure: retry. The ai.agent step is conversation-stateful and long-running; the workflow engine enforces its 10m timeout by aborting the underlying agent execution (Converse request was aborted). A retry would restart the agent from scratch — discarding all prior ground-truthing progress — and, on a timeout, simply burn the remaining pipeline budget, guaranteeing failure rather than recovering. Surfacing the timeout immediately is the correct behavior.
Gate connector (Constraint A). The gate ai.agent step routes its model calls through the connector selected for the generation: apiConfig.connector_id is threaded into the gate workflow as the connector_id input and rendered into the step's connector-id config. The gate must run on the same connector as the generation request (or a larger-context one) so it never fails on inputs the generate step would have accepted. When the input is empty/omitted (e.g. a manual workflow run with no connector), connector-id renders to undefined and the step falls back to the Agent Builder default model.
string[] alert contract on generateContext. The generation step needs alerts, but raw alert objects carry PII fields that must never reach the LLM.
Decision. The security.attack-discovery.generate step's input schema is alerts: z.array(z.string()).min(1) — anonymized strings only.
Consequence. Three security properties:
generate, they have already been anonymized by defaultAlertRetrieval.string[] schema cannot carry nested fields that might leak sensitive data.defaultAlertRetrievalContext. Raw alerts carry PII. The LLM and downstream consumers must operate on anonymized data only. There must also be a way to de-anonymize on display.
Decision. The boundary sits at security.attack-discovery.defaultAlertRetrieval. Output is alerts: string[] (anonymized) plus a replacements map (anonymized token → real value). The _id field is always present in the anonymization config so downstream hallucination detection can use real alert IDs.
Consequence. The replacements map is the only bridge between anonymized and real data. It is excluded by the output schema of security.attack-discovery.run so user-authored workflows cannot inadvertently log or forward the de-anonymization key.
replacements map flow per stepContext. The replacements map is sensitive. We need explicit, auditable rules for when each step receives or returns it.
Decision.
| Step | Receives | Returns |
|---|---|---|
security.attack-discovery.defaultAlertRetrieval | Optional initial replacements | Updated replacements (new tokens from anonymization) |
security.attack-discovery.generate | Replacements from retrieval | Updated replacements (LLM may create new mappings) |
security.attack-discovery.defaultValidation | Replacements from generation | Not in output (consumed internally for hallucination check) |
security.attack-discovery.persistDiscoveries | Replacements from generation | Not in output (consumed internally for de-anonymized persistence) |
security.attack-discovery.run | Optional initial replacements | Excluded from output (security boundary) |
Consequence. No path lets a user-authored workflow downstream of run see the replacements map.
run in sync mode with a soft deadlineContext. Agent Builder tools execute as part of a larger agent conversation. The agent needs the result inline to formulate its response. The Agent Builder workflow tool that wraps security.attack-discovery.run waits up to WAIT_FOR_COMPLETION_TIMEOUT_SEC = 120s for the workflow to complete. Real Attack Discovery generations frequently exceed two minutes, but the run step itself has a 10-minute internal timeout. Without intervention, the wrapping AB tool would hit its own timeout and return only a workflow execution ID — useless for an AD-specific resume path. Async-mode polling is not the current Agent Builder pattern (platform.core.get_workflow_execution_status explicitly tells agents not to auto-poll within a turn).
Decision. Agent Builder integrations call security.attack-discovery.run in sync mode. The run step's executor races the generation pipeline against a hard-coded ATTACK_DISCOVERY_RUN_SOFT_DEADLINE_MS = 90s soft deadline (≈30s of headroom under the 120s AB ceiling). If the pipeline finishes first, the step returns the full sync output (attack_discoveries, execution_uuid, alerts_context_count, discovery_count). If the soft deadline wins, the step returns { execution_uuid } only and lets the underlying pipeline keep running in the background. The agent skill exposes a dedicated security.attack-discovery.get_status tool so the user can resume by execution_uuid on a subsequent prompt.
Consequence. The AB workflow tool always receives a clean response well inside its 120s window — it never times out. Fast generations return inline discoveries (today's behavior). Slow generations return a clean execution_uuid handoff; the agent acknowledges the in-progress state, the pipeline persists discoveries automatically when complete, and the user can ask for status to resume. The agent never sees the replacements map (excluded by schema).
Superseded by the system-workflows framework migration. The AD plugin migrated to platform-managed
system-…workflows. The platform now owns reconciliation, version-based upgrade (versionStrategy: 'auto'), and orphan cleanup. The AD-sidecheckManagedWorkflowIntegrityfunction introspects platform state and reports diagnostic outcomes; it does not perform restoration. This ADR is preserved as historical context for why theWorkflowIntegrityResultoutcomes table (all_intact/repaired/repair_failed) exists.
Context. Default workflows are essential for the pipeline to function. If they are deleted or modified, the pipeline silently breaks. We needed a self-healing mechanism that did not block startup.
Decision (original, since superseded). On every generation request, verify the integrity of the required default workflows by SHA-256 hashing the stored YAML and comparing against the bundled YAML hash. If the workflow is missing or the hash differs, restore from bundled YAML. If restore fails, abort with repair_failed.
Consequence. Self-healing without compromising the integrity guarantee. Whitespace-only diffs trigger drift detection (intentional — they may represent tampering). Today's equivalent: checkManagedWorkflowIntegrity reports drift via the same outcome enum and emits workflow_modified telemetry; the platform reconciles on the next restart.
Context. Internal-API and public-API schedules must coexist in the same alert SO type without leaking across the boundary. We chose not to introduce a new SO type because that would require migrations.
Decision. Tag every internal-API-created schedule with attack-discovery-schedule on write. The internal data client does not apply a read filter; the legacy public API applies an excludeTags read filter to hide tagged schedules.
Consequence. Asymmetric visibility: legacy schedule users never see workflow-created schedules (they are excluded), but internal API users see all schedules — both workflow-tagged and legacy (untagged) — so the workflow UI can migrate pre-existing schedules. The invariant depends on legacy code never accidentally writing the tag — that responsibility lives in elastic_assistant and is verified by the Scout API tests in test/scout/api/.
| Term | Definition |
|---|---|
| Four entry points | Ad-hoc (UI), Scheduled (Alerting Framework workflowExecutor), security.attack-discovery.run step (user-authored workflow), and the Agent Builder run tool (trigger: 'agent_builder') — all converge on executeGenerationWorkflow |
| Five workflow steps | security.attack-discovery.defaultAlertRetrieval, security.attack-discovery.generate, security.attack-discovery.defaultValidation, security.attack-discovery.persistDiscoveries, security.attack-discovery.run |
| Seven system workflows | system-attack-discovery-alert-retrieval, system-attack-discovery-generation, system-attack-discovery-validate, system-attack-discovery-run-example, system-attack-discovery-custom-validation-example, system-attack-discovery-skill-alert-retrieval, system-attack-discovery-skill-report — declared inline in kbn-workflows/managed/definitions/discoveries/index.ts |
| Feature flag | securitySolution.attackDiscoveryWorkflowsEnabled (default OFF) |
assertWorkflowsEnabled | FF gate helper; returns 404 from internal routes when the FF is OFF |
@kbn/zod/v4 requirement | Workflow step schemas use @kbn/zod/v4 (NOT v3) per the Workflows platform contract; v3 schemas (REST route validation) must never be cast to v4 |
| Connector resolution | connector_id is optional everywhere. The workflow-engine run step resolves genAiSettings:defaultAIConnector (→ inference.getDefaultConnector fallback) server-side; the Agent Builder tool resolves the agent's selected model via context.modelProvider.getDefaultModel(). An explicit connector_id overrides either default. No Agent Builder connector-shape dependency — the AD stack touches only allow_lists.ts in that package |
| Anonymization boundary | Alert retrieval transforms raw alerts → anonymized string[] + replacements map; replacements de-anonymizes only on display and is excluded from security.attack-discovery.run output |
replacements map | Record<string, string> mapping anonymized tokens (e.g., "SRVHQMWPN001") back to real values (e.g., "dc01.example.com") |
| Tag-based isolation | Internal-API schedules carry the attack-discovery-schedule tag on write; internal reads are unfiltered (surface all schedules), while the legacy/public API excludeTags the tag; legacy/public-API schedules carry no tag |
| Managed workflow integrity check | Pre-execution platform-introspection of the AD managed workflows via checkManagedWorkflowIntegrity; platform reconciles drift on restart; abort on repair_failed (missing/unmanaged/disabled required workflow) |
executionUuid | UUIDv4 unique to each generation run; appears in server log prefix [execution: {uuid}], event-log entries, EBT events, and the _generate API response |
executeGenerationWorkflow | Single entry function shared by all four entry points; throws when the FF is OFF, then runs pre-execution validation + integrity check and delegates to runManualOrchestration |
runManualOrchestration | Chains the three pipeline phases (retrieval → generation → validation+persistence) with timeout budgets and error handling |
| Event log privacy contract | No alert / query / user / connector content; only execution_uuid, phase, outcome, sanitized reason, duration. Caveat: providedAlerts (anonymized strings) flows into event.reference for the legacy provided path |
| EBT privacy contract | snake_case for new fields; no user content / query / alerts / identifiers; legacy camelCase fields retained on shared events |
| Pre-execution validation | Four concurrent checks (WorkflowsManagement API, default workflow IDs, alerts index, connector accessibility); critical failures abort, warnings log + emit attack_discovery_misconfiguration EBT |