Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,9 @@ src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/watch_d
src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/watch_detection.ts @elastic/security-solution
src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/watch_detection.yaml @elastic/security-solution

# Actions directories
src/platform/packages/shared/kbn-workflows/managed/definitions/alertzero/actions/defend @elastic/security-defend-workflows

# Alert analysis workflow
src/platform/packages/shared/kbn-workflows/managed/definitions/alert_analysis @elastic/security-threat-hunting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,29 @@ import {
*/

/** Local shape: the parsed YAML is untyped and only these fields are asserted on. */
interface ParsedStep {
type?: string;
steps?: ParsedStep[];
else?: ParsedStep[];
}

interface ParsedActionWorkflow {
tags?: string[];
consts?: { actionMetadata?: unknown };
triggers?: Array<{
type?: string;
inputs?: { properties?: Record<string, { type?: string }>; required?: string[] };
}>;
steps?: Array<{ type?: string }>;
steps?: ParsedStep[];
}

const flattenSteps = (steps: ParsedStep[]): ParsedStep[] =>
steps.flatMap((step) => [
step,
...flattenSteps(step.steps ?? []),
...flattenSteps(step.else ?? []),
]);

const actionWorkflowCandidates = managedWorkflowDefinitions
.filter((definition): definition is typeof definition & { yaml: string } => 'yaml' in definition)
.map((definition) => ({
Expand Down Expand Up @@ -77,7 +90,9 @@ describe('action workflow contract', () => {
});

it('emits an explicit output, since workflow.execute cannot type a child result', () => {
expect(workflow.steps?.map(({ type }) => type)).toContain('workflow.output');
const allSteps = flattenSteps(workflow.steps ?? []);

expect(allSteps.map(({ type }) => type)).toContain('workflow.output');
});
}
);
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we create all the defend action workfows inside one directory (I used managed/definitions/alertzero/actions/defend/* in my PR) and so we can assign the defend-workflows team ownership to the whole directory instead of each workflow individually?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 87f1fe7 and added the pre-flight privileges probe in 5302e96

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import ACTION_ISOLATE_HOST_YAML from './action_isolate_host.yaml';
import type { ManagedWorkflowDefinition } from '../../../../types';
import {
ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID,
ALERTZERO_RULE_WORKFLOW_MANAGEMENT,
} from '../../constants';

export const ALERTZERO_ACTION_ISOLATE_HOST_WORKFLOW_ID = 'system-alertzero-action-isolate-host';

export const ALERTZERO_ACTION_ISOLATE_HOST_WORKFLOW = {
billable: false,
id: ALERTZERO_ACTION_ISOLATE_HOST_WORKFLOW_ID,
management: ALERTZERO_RULE_WORKFLOW_MANAGEMENT,
pluginId: ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID,
version: 1,
yaml: ACTION_ISOLATE_HOST_YAML,
} as const satisfies ManagedWorkflowDefinition;
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
version: "1"
name: Isolate host
description: >
AlertZero action: isolates one or more Elastic Defend endpoints from the
network. Executed by the investigation proposal gate once an analyst
approves, so the response action is attributed to the approver.
enabled: true
tags:
# Generic tag every action workflow carries, so the catalog can be
# discovered by tag rather than a hardcoded list.
- action
- alertzero
consts:
# Self-describing catalog metadata, read back by the catalog when a
# proposal referencing this workflow is created or rendered. Metadata has
# to live under `consts`: unknown top-level keys are stripped by the schema.
actionMetadata:
name: Isolate host
description: Isolates Elastic Defend endpoints from the network. Only Elastic Defend retains connectivity.
category: contain
impact: high
reversible: true
approvalPolicy: always-gate
actionLabel: Host isolation
# Sentinel id used only to probe Actions-log privilege before isolating.
# A 404 means the privilege is present; anything else must fail closed.
privilege_probe_action_id: "00000000-0000-0000-0000-000000000000"
triggers:
- type: manual
inputs:
properties:
actionInput:
type: object
description: >
Isolate fields. Every action workflow takes a single `actionInput`
object so the generic gate workflow never needs to know an
action's parameter names.
properties:
endpoint_ids:
type: array
minItems: 1
maxItems: 250
items:
type: string
minLength: 1
maxLength: 256
description: Elastic Defend agent IDs (agent.id) of the endpoints to isolate.
comment:
type: string
maxLength: 30000
description: Optional comment explaining why these hosts are being isolated.
required:
- endpoint_ids
required:
- actionInput
additionalProperties: false
outputs:
- name: action_id
type: string
- name: status
type: string
- name: was_successful
type: boolean
- name: message
type: string
steps:
# GET details requires Actions log (Platinum+); isolate only needs Host
# isolation. Probe the same route the poll uses so a missing privilege fails
# before the host is touched. kibana.request throws `HTTP <status>: …`.
- name: probe_action_details
type: kibana.request
on-failure:
continue: true
with:
method: GET
path: "/s/{{ workflow.spaceId }}/api/endpoint/action/{{ consts.privilege_probe_action_id }}"
headers:
kbn-xsrf: "true"
elastic-api-version: "2023-10-31"

# Separate step: Liquid cannot read a variable written by the same data.set.
- name: capture_probe_status
type: data.set
with:
probe_http_status: "{{ steps.probe_action_details.error.message | slice: 0, 8 }}"

- name: fail_if_cannot_poll
type: if
condition: '${{ steps.probe_action_details.error != blank and variables.probe_http_status != "HTTP 404" }}'
steps:
- name: missing_actions_log_privilege
type: workflow.fail
with:
message: >
Cannot wait for isolate completion: GET /api/endpoint/action/{id}
failed ({{ steps.probe_action_details.error.message }}). The
approver needs the Actions log privilege in addition to Host
isolation.

# POST queues the command; 200 means accepted, not that the host is isolated.
# kibana.request is sent verbatim, so the path is prefixed with the executing space.
# No retries: the API is non-idempotent — each POST creates a new action ID.
- name: dispatch
type: kibana.request
with:
method: POST
path: "/s/{{ workflow.spaceId }}/api/endpoint/action/isolate"
headers:
kbn-xsrf: "true"
elastic-api-version: "2023-10-31"
Content-Type: application/json
body:
endpoint_ids: "${{ inputs.actionInput.endpoint_ids }}"
comment: "{{ inputs.actionInput.comment }}"
agent_type: endpoint

# Poll until the endpoint reports completion
# 10s interval, 60 attempts, 10 minutes. Do-while: the first GET always runs.
- name: poll
type: while
condition: "${{ steps.poll_status.output.data.isCompleted != true }}"
max-iterations:
limit: 60
on-limit: fail
timeout: 10m
steps:
- name: poll_status
type: kibana.request
on-failure:
retry:
max-attempts: 3
delay: 5s
with:
method: GET
path: "/s/{{ workflow.spaceId }}/api/endpoint/action/{{ steps.dispatch.output.data.id }}"
headers:
kbn-xsrf: "true"
elastic-api-version: "2023-10-31"
- name: wait_if_pending
type: if
condition: "${{ steps.poll_status.output.data.isCompleted != true }}"
steps:
- name: poll_delay
type: wait
with:
duration: 10s

# `workflow.execute` declares its output as unknown, so the parent falls back
# to a heuristic read of the last step unless the child emits explicitly.
Comment thread
ymao1 marked this conversation as resolved.
# Fail the workflow when the endpoint-side action did not succeed so the
# proposal gate's on-failure path fires instead of record_success.
- name: emit_result
type: if
condition: "${{ steps.poll_status.output.data.wasSuccessful == true }}"
steps:
- name: emit_success
type: workflow.output
status: completed
with:
action_id: "{{ steps.poll_status.output.data.id }}"
status: "{{ steps.poll_status.output.data.status }}"
was_successful: true
message: "{{ consts.actionLabel }} completed successfully. Action ID: {{ steps.poll_status.output.data.id }}"
else:
- name: emit_failure
type: workflow.fail
with:
message: "{{ consts.actionLabel }} failed. Action ID: {{ steps.poll_status.output.data.id }}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import ACTION_KILL_PROCESS_YAML from './action_kill_process.yaml';
import type { ManagedWorkflowDefinition } from '../../../../types';
import {
ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID,
ALERTZERO_RULE_WORKFLOW_MANAGEMENT,
} from '../../constants';

export const ALERTZERO_ACTION_KILL_PROCESS_WORKFLOW_ID = 'system-alertzero-action-kill-process';

export const ALERTZERO_ACTION_KILL_PROCESS_WORKFLOW = {
billable: false,
id: ALERTZERO_ACTION_KILL_PROCESS_WORKFLOW_ID,
management: ALERTZERO_RULE_WORKFLOW_MANAGEMENT,
pluginId: ALERTZERO_MANAGED_WORKFLOW_PLUGIN_ID,
version: 1,
yaml: ACTION_KILL_PROCESS_YAML,
} as const satisfies ManagedWorkflowDefinition;
Loading
Loading