diff --git a/examples/security/response/README.md b/examples/security/response/README.md index f025449..612e70c 100644 --- a/examples/security/response/README.md +++ b/examples/security/response/README.md @@ -2,10 +2,13 @@ Incident response, case management, and remediation workflows -## Workflows (4) +## Workflows (7) | Workflow | Description | |----------|-------------| +| [Remediate Startup Folder Persistence](./remediate-persistence-windows-startup-folder.yaml) | ES\|QL correlates user/ProgramData Startup persistence with endpoint alerts in 5m windows; skips if get-file exists in history, then downloads, waits 20s, and deletes | +| [Remediate Run Key Persistence](./remediate-persistence-windows-run-key.yaml) | ES\|QL correlates Run-key registry persistence with endpoint alerts in 5m windows; skips if delete exists in history, then removes the Run value via PowerShell | +| [Remediate Scheduled Task Persistence](./remediate-persistence-windows-scheduled-task.yaml) | ES\|QL correlates scheduled task creation with endpoint alerts in 5m windows; skips if schtasks delete exists in history, then removes the task | | [📁 Case workflow - Prod](./case-workflow-prod.yaml) | The YAML workflow outlines a security operations process that triggers on alerts | | [📁 Traditional Triage](./traditional-triage.yaml) | The "Traditional Triage" workflow automates the response to security alerts, par | | [🔒 AD - Automated Triaging](./ad-automated-triaging.yaml) | The YAML workflow outlines an automated triaging process for security operations | diff --git a/examples/security/response/remediate-persistence-windows-run-key.yaml b/examples/security/response/remediate-persistence-windows-run-key.yaml new file mode 100644 index 0000000..07bec36 --- /dev/null +++ b/examples/security/response/remediate-persistence-windows-run-key.yaml @@ -0,0 +1,146 @@ +# ============================================================================= +# Workflow: Remediate Run Key Persistence +# Category: security/response +# +# Finds Run-key and Policies\Explorer\Run registry persistence via ES|QL on +# logs-endpoint.*, correlating with Malicious Behavior Prevention alerts within +# 5-minute windows on the same agent/host, then for each hit: +# 0. Checks newest execute in response history (pageSize 1) for same path. +# 1. execute — PowerShell Remove-ItemProperty on registry.hive\registry.key. +# +# ES|QL columns: [0] agent.id, [1] host.id, [2] registry.hive, [3] registry.key, +# [4] registry.value, [5] registry.path, [6] registry.data.strings +# +# registry.path ends with the value name; remediation uses registry.hive + key + value. +# +## The workflow is set to run every 15m, you can also attach it as a custom workflow action to the "Endpoint Security (Elastic Defend)" https://www.elastic.co/guide/en/security/8.19/endpoint-security-elastic-defend.html SIEM rule so it triggers everytime a behavior alert is generated. +# ============================================================================= +version: "1" +name: Remediate Run Key Persistence +description: | + Query logs-endpoint.* for Run-key registry persistence correlated with endpoint + alerts within 5-minute windows, skip values already removed per response actions + history, then delete the Run value via PowerShell Remove-ItemProperty. +enabled: false +tags: + - security + - endpoint + - response + - remediation + - persistence + - registry + +triggers: + - type: manual + inputs: + - name: esql_query + type: string + description: ES|QL returning agent.id, host.id, registry.hive, registry.key, registry.value, registry.path, registry.data.strings per row. + required: true + default: | + FROM logs-endpoint.* + | WHERE @timestamp > NOW() - 15 minutes and + (message like "Malicious Behavior Prevention Alert*" OR (event.category == "registry" and (registry.path like """*\\Windows\\CurrentVersion\\Run\\*""" or registry.path like """*CurrentVersion\\Policies\\Explorer\\Run\\*""") and registry.data.strings IS NOT NULL)) + | EVAL is_endpoint_alert = CASE(message like "Malicious Behavior Prevention Alert*", agent.id, NULL), is_run_persistence = CASE(event.category == "registry" and (registry.path like """*\\Windows\\CurrentVersion\\Run\\*""" or registry.path like """*CurrentVersion\\Policies\\Explorer\\Run\\*""") and registry.data.strings IS NOT NULL, agent.id, NULL) + | STATS endpoint_alerts_count = COUNT_DISTINCT(is_endpoint_alert), persistence_count = COUNT_DISTINCT(is_run_persistence), rules = VALUES(message), registry.hive = VALUES(registry.hive), registry.key = VALUES(registry.key), registry.value = VALUES(registry.value), registry.path = VALUES(registry.path), registry.data.strings = VALUES(registry.data.strings) by agent.id, host.id, DATE_TRUNC(5 minutes, @timestamp) + | WHERE endpoint_alerts_count >= 1 and persistence_count >= 1 + | STATS BY agent.id, host.id, registry.hive, registry.key, registry.value, registry.path, registry.data.strings + - name: history_lookback + type: string + description: How far back to search response actions history (Date Math, e.g. now-1d). + default: now-1d + - type: scheduled + with: + every: 15m + +steps: + - name: run_esql + type: elasticsearch.esql.query + with: + query: "{{ inputs.esql_query }}" + + - name: process_hits + type: if + condition: steps.run_esql.output.values.length > 0 + steps: + - name: per_persistence_hit + type: foreach + foreach: "{{ steps.run_esql.output.values }}" + steps: + - name: set_hit_context + type: data.set + with: + registry_path: "{{ foreach.item[5] | downcase }}" + reg_key: >- + {% if foreach.item[2] != '' and foreach.item[2] != null and foreach.item[3] != '' and foreach.item[3] != null %}{{ foreach.item[2] }}\{{ foreach.item[3] }}{% else %}{% assign rp = foreach.item[5] %}{% assign parts = rp | split: '\' %}{% assign key_path = '' %}{% for part in parts %}{% unless forloop.last %}{% if key_path != '' %}{% assign key_path = key_path | append: '\' %}{% endif %}{% assign key_path = key_path | append: part %}{% endunless %}{% endfor %}{{ key_path }}{% endif %} + reg_value: >- + {% if foreach.item[4] != '' and foreach.item[4] != null %}{{ foreach.item[4] }}{% else %}{% assign rp = foreach.item[5] %}{% assign parts = rp | split: '\' %}{{ parts | last }}{% endif %} + + - name: log_hit + type: console + with: + message: "Checking Run-key persistence — agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}, key {{ steps.set_hit_context.output.reg_key }}, value {{ steps.set_hit_context.output.reg_value }} = {{ foreach.item[6] }} (row {{ foreach.index }})." + + - name: check_execute_history + type: kibana.request + with: + method: GET + path: "/api/endpoint/action" + query: + agentIds: "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + commands: execute + startDate: "{{ inputs.history_lookback }}" + endDate: now + pageSize: 1 + headers: + kbn-xsrf: "true" + on-failure: + continue: true + + - name: capture_history + type: data.set + with: + should_skip: "{% if steps.check_execute_history.output.total == 0 %}false{% else %}{% for action in steps.check_execute_history.output.data limit:1 %}{% assign rp = steps.set_hit_context.output.registry_path %}{% assign rk = steps.set_hit_context.output.reg_key | downcase %}{% assign rv = steps.set_hit_context.output.reg_value | downcase %}{% assign hc = action.parameters.command | downcase %}{% assign cm = action.comment | downcase %}{% if hc contains rp %}true{% elsif hc contains rk and hc contains rv %}true{% elsif cm contains rp %}true{% elsif cm contains rv and cm contains 'remediate run key' %}true{% else %}false{% endif %}{% endfor %}{% endif %}" + + - name: already_remediated + type: if + condition: 'steps.capture_history.output.should_skip: "true"' + steps: + - name: log_already_in_history + type: console + with: + message: "Skipping {{ foreach.item[5] }} on agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %} — delete already in response actions history ({{ inputs.history_lookback }})." + else: + - name: delete_registry_value + type: kibana.request + with: + method: POST + path: "/api/endpoint/action/execute" + headers: + kbn-xsrf: "true" + body: + endpoint_ids: + - "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + comment: "Workflow Remediate Run Key Persistence — delete {{ steps.set_hit_context.output.reg_key }} value {{ steps.set_hit_context.output.reg_value }} ({{ foreach.item[6] }})." + parameters: + command: 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Remove-ItemProperty -LiteralPath ''Registry::{{ steps.set_hit_context.output.reg_key }}'' -Name ''{{ steps.set_hit_context.output.reg_value }}'' -Force -ErrorAction Stop"' + timeout: 120 + on-failure: + continue: true + + - name: log_remediation_result + type: console + with: + message: "{% if steps.delete_registry_value.error %}Delete warning for {{ steps.set_hit_context.output.reg_key }}\\{{ steps.set_hit_context.output.reg_value }}: {{ steps.delete_registry_value.error }}{% else %}Remove-ItemProperty submitted for {{ steps.set_hit_context.output.reg_key }} value {{ steps.set_hit_context.output.reg_value }} (data: {{ foreach.item[6] }}).{% endif %}" + + else: + - name: log_no_hits + type: console + with: + message: "No Run-key persistence correlated with endpoint alerts in the configured ES|QL window." + + - name: log_summary + type: console + with: + message: | + Processed {{ steps.run_esql.output.values | size }} unique Run-key persistence target(s). Check Security → Endpoints → Response actions history. diff --git a/examples/security/response/remediate-persistence-windows-scheduled-task.yaml b/examples/security/response/remediate-persistence-windows-scheduled-task.yaml new file mode 100644 index 0000000..e7244fb --- /dev/null +++ b/examples/security/response/remediate-persistence-windows-scheduled-task.yaml @@ -0,0 +1,142 @@ +# ============================================================================= +# Workflow: Remediate Scheduled Task Persistence +# Category: security/response +# +# Finds scheduled task file creation under C:\Windows\System32\Tasks via ES|QL on +# logs-endpoint.*, correlating with Malicious Behavior Prevention alerts within +# 5-minute windows on the same agent/host, then for each hit: +# 0. Checks newest execute in response history (pageSize 1) for same task. +# 1. execute — schtasks /Delete using file.name as the task name. +# +# ES|QL columns: [0] agent.id, [1] host.id, [2] file.path, [3] file.name +# +# The workflow is set to run every 15m; you can also attach it as a custom +# workflow action to the "Endpoint Security (Elastic Defend)" SIEM rule. +# ============================================================================= +version: "1" +name: Remediate Scheduled Task Persistence +description: | + Query logs-endpoint.* for scheduled task persistence correlated with endpoint + alerts within 5-minute windows, skip tasks already deleted per response actions + history, then remove the task via schtasks /Delete. +enabled: false +tags: + - security + - endpoint + - response + - remediation + - persistence + - scheduled-task + +triggers: + - type: manual + inputs: + - name: esql_query + type: string + description: ES|QL returning agent.id, host.id, file.path, file.name per row. + required: true + default: | + FROM logs-endpoint.* + | WHERE @timestamp > NOW() - 15 minutes and + (message like "Malicious Behavior Prevention Alert*" OR (event.category == "file" and process.name == "svchost.exe" and file.path like """C:\\Windows\\System32\\Tasks\\*""" and MV_CONTAINS(event.action, "creation"))) + | EVAL is_endpoint_alert = CASE(message like "Malicious Behavior Prevention Alert*", agent.id, NULL), is_task_persistence = CASE(event.category == "file" and process.name == "svchost.exe" and file.path like """C:\\Windows\\System32\\Tasks\\*""", agent.id, NULL) + | STATS endpoint_alerts_count = COUNT_DISTINCT(is_endpoint_alert), persistence_count = COUNT_DISTINCT(is_task_persistence), file.path = VALUES(file.path), file.name = VALUES(file.name) by agent.id, host.id, DATE_TRUNC(5 minutes, @timestamp) + | WHERE endpoint_alerts_count >= 1 and persistence_count >= 1 + | STATS BY agent.id, host.id, file.path, file.name + - name: history_lookback + type: string + description: How far back to search response actions history (Date Math, e.g. now-1d). + default: now-1d + - type: scheduled + with: + every: 15m + +steps: + - name: run_esql + type: elasticsearch.esql.query + with: + query: "{{ inputs.esql_query }}" + + - name: process_hits + type: if + condition: steps.run_esql.output.values.length > 0 + steps: + - name: per_persistence_hit + type: foreach + foreach: "{{ steps.run_esql.output.values }}" + steps: + - name: set_hit_context + type: data.set + with: + file_path: "{{ foreach.item[2] | downcase }}" + task_name: >- + {% if foreach.item[3] != '' and foreach.item[3] != null %}{{ foreach.item[3] }}{% else %}{% assign fp = foreach.item[2] %}{% assign parts = fp | split: '\' %}{{ parts | last }}{% endif %} + + - name: log_hit + type: console + with: + message: "Checking scheduled task persistence — agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}, task {{ steps.set_hit_context.output.task_name }}, path {{ foreach.item[2] }} (row {{ foreach.index }})." + + - name: check_execute_history + type: kibana.request + with: + method: GET + path: "/api/endpoint/action" + query: + agentIds: "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + commands: execute + startDate: "{{ inputs.history_lookback }}" + endDate: now + pageSize: 1 + headers: + kbn-xsrf: "true" + on-failure: + continue: true + + - name: capture_history + type: data.set + with: + should_skip: "{% if steps.check_execute_history.output.total == 0 %}false{% else %}{% for action in steps.check_execute_history.output.data limit:1 %}{% assign fp = steps.set_hit_context.output.file_path %}{% assign tn = steps.set_hit_context.output.task_name | downcase %}{% assign hc = action.parameters.command | downcase %}{% assign cm = action.comment | downcase %}{% if hc contains fp %}true{% elsif hc contains tn and hc contains 'schtasks' %}true{% elsif cm contains fp %}true{% elsif cm contains tn and cm contains 'scheduled task' %}true{% else %}false{% endif %}{% endfor %}{% endif %}" + + - name: already_remediated + type: if + condition: 'steps.capture_history.output.should_skip: "true"' + steps: + - name: log_already_in_history + type: console + with: + message: "Skipping task {{ steps.set_hit_context.output.task_name }} on agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %} — delete already in response actions history ({{ inputs.history_lookback }})." + else: + - name: delete_scheduled_task + type: kibana.request + with: + method: POST + path: "/api/endpoint/action/execute" + headers: + kbn-xsrf: "true" + body: + endpoint_ids: + - "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + comment: "Workflow Remediate Scheduled Task Persistence — delete task {{ steps.set_hit_context.output.task_name }} ({{ foreach.item[2] }})." + parameters: + command: 'schtasks /Delete /TN "{{ steps.set_hit_context.output.task_name }}" /F' + timeout: 120 + on-failure: + continue: true + + - name: log_remediation_result + type: console + with: + message: "{% if steps.delete_scheduled_task.error %}Delete warning for task {{ steps.set_hit_context.output.task_name }}: {{ steps.delete_scheduled_task.error }}{% else %}schtasks /Delete submitted for {{ steps.set_hit_context.output.task_name }} (path: {{ foreach.item[2] }}).{% endif %}" + + else: + - name: log_no_hits + type: console + with: + message: "No scheduled task persistence correlated with endpoint alerts in the configured ES|QL window." + + - name: log_summary + type: console + with: + message: | + Processed {{ steps.run_esql.output.values | size }} unique scheduled task persistence target(s). Check Security → Endpoints → Response actions history. diff --git a/examples/security/response/remediate-persistence-windows-startup-folder.yaml b/examples/security/response/remediate-persistence-windows-startup-folder.yaml new file mode 100644 index 0000000..1cdb61b --- /dev/null +++ b/examples/security/response/remediate-persistence-windows-startup-folder.yaml @@ -0,0 +1,161 @@ +# ============================================================================= +# Workflow: Remediate Startup Persistence +# Category: security/response +# +# Finds Startup-folder persistence (user AppData + ProgramData) via ES|QL on +# logs-endpoint.*, correlating persistence with Malicious Behavior Prevention +# alerts within 5-minute windows on the same agent/host, then for each hit: +# 0. Checks newest get-file in response history (pageSize 1) for same file.path. +# 1. get-file — downloads file.path (zip in response actions history). +# 2. execute — deletes file.path from Startup (after 20s wait). +# +# ES|QL columns: [0] agent.id, [1] host.id, [2] file.path +# +# The workflow is set to run every 15m, you can also attach it as a custom workflow action to the "Endpoint Security (Elastic Defend)" https://www.elastic.co/guide/en/security/8.19/endpoint-security-elastic-defend.html SIEM rule so it triggers everytime a behavior alert is generated. +# +# ============================================================================= +version: "1" +name: Remediate Startup Persistence +description: | + Query logs-endpoint.* for user and ProgramData Startup-folder persistence + correlated with endpoint alerts within 5-minute windows, skip hosts/files already in response actions + history, download via get-file, wait 20s, then delete the startup file. +enabled: false +tags: + - security + - endpoint + - response + - remediation + - persistence + +triggers: + - type: manual + inputs: + - name: esql_query + type: string + description: ES|QL returning agent.id, host.id, file.path per row. + required: true + default: | + FROM logs-endpoint.* + | WHERE @timestamp > NOW() - 15 minutes and + (message like "Malicious Behavior Prevention Alert*" OR TO_LOWER(file.path) LIKE """?:\\users\\*\\appdata\\roaming\\microsoft\\windows\\start menu\\programs\\startup\\*""" OR TO_LOWER(file.path) LIKE """?:\\programdata\\microsoft\\windows\\start menu\\programs\\startup\\*""") + | EVAL is_endpoint_alert = CASE(message like "Malicious Behavior Prevention Alert*", agent.id, NULL), is_startup_persistence = CASE(TO_LOWER(file.path) LIKE """?:\\users\\*\\appdata\\roaming\\microsoft\\windows\\start menu\\programs\\startup\\*""" OR TO_LOWER(file.path) LIKE """?:\\programdata\\microsoft\\windows\\start menu\\programs\\startup\\*""", agent.id, NULL) + | STATS endpoint_alerts_count = COUNT_DISTINCT(is_endpoint_alert), persistence_count = COUNT_DISTINCT(is_startup_persistence), rules = VALUES(message), file.path = VALUES(file.path) by agent.id, host.id, DATE_TRUNC(5 minutes, @timestamp) + | WHERE endpoint_alerts_count >= 1 and persistence_count >= 1 + | STATS BY agent.id, host.id, file.path + - name: history_lookback + type: string + description: How far back to search response actions history (Date Math, e.g. now-1d). + default: now-1d + - type: scheduled + with: + every: 15m + +steps: + - name: run_esql + type: elasticsearch.esql.query + with: + query: "{{ inputs.esql_query }}" + + - name: process_hits + type: if + condition: steps.run_esql.output.values.length > 0 + steps: + - name: per_persistence_hit + type: foreach + foreach: "{{ steps.run_esql.output.values }}" + steps: + - name: set_hit_context + type: data.set + with: + file_path: "{{ foreach.item[2] | downcase }}" + + - name: log_hit + type: console + with: + message: "Checking startup persistence — agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}, file {{ foreach.item[2] }} (row {{ foreach.index }})." + + - name: check_get_file_history + type: kibana.request + with: + method: GET + path: "/api/endpoint/action" + query: + agentIds: "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + commands: get-file + startDate: "{{ inputs.history_lookback }}" + endDate: now + pageSize: 1 + headers: + kbn-xsrf: "true" + on-failure: + continue: true + + - name: capture_history + type: data.set + with: + should_skip: "{% if steps.check_get_file_history.output.total == 0 %}false{% else %}{% for action in steps.check_get_file_history.output.data limit:1 %}{% assign fp = steps.set_hit_context.output.file_path %}{% assign hp = action.parameters.path | downcase %}{% assign hc = action.comment | downcase %}{% if hp == fp %}true{% elsif hc contains fp %}true{% else %}false{% endif %}{% endfor %}{% endif %}" + + - name: already_archived + type: if + condition: 'steps.capture_history.output.should_skip: "true"' + steps: + - name: log_already_in_history + type: console + with: + message: "Skipping {{ foreach.item[2] }} on agent {% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %} — get-file already in response actions history ({{ inputs.history_lookback }})." + else: + - name: download_file + type: kibana.request + with: + method: POST + path: "/api/endpoint/action/get_file" + headers: + kbn-xsrf: "true" + body: + endpoint_ids: + - "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + comment: "Workflow Remediate Startup Persistence — download {{ foreach.item[2] }}." + parameters: + path: "{{ foreach.item[2] }}" + on-failure: + continue: true + + - name: wait_for_download + type: wait + with: + duration: 20s + + - name: delete_startup_file + type: kibana.request + with: + method: POST + path: "/api/endpoint/action/execute" + headers: + kbn-xsrf: "true" + body: + endpoint_ids: + - "{% if foreach.item[0] != '' and foreach.item[0] != null %}{{ foreach.item[0] }}{% else %}{{ foreach.item[1] }}{% endif %}" + comment: "Workflow Remediate Startup Persistence — remove startup file {{ foreach.item[2] }}." + parameters: + command: 'if exist "{{ foreach.item[2] }}" del /f /q "{{ foreach.item[2] }}"' + timeout: 120 + on-failure: + continue: true + + - name: log_remediation_result + type: console + with: + message: "{% if steps.download_file.error %}Download warning for {{ foreach.item[2] }}: {{ steps.download_file.error }}{% else %}get-file submitted for {{ foreach.item[2] }} — download link will appear in response actions history.{% endif %} {% if steps.delete_startup_file.error %}Delete warning: {{ steps.delete_startup_file.error }}{% else %}Delete command submitted for {{ foreach.item[2] }}.{% endif %}" + + else: + - name: log_no_hits + type: console + with: + message: "No startup persistence alerts found in the configured ES|QL window." + + - name: log_summary + type: console + with: + message: | + Processed {{ steps.run_esql.output.values | size }} unique startup persistence target(s). Check Security → Endpoints → Response actions history.