diff --git a/CHANGELOG.md b/CHANGELOG.md index 2704557..f721a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to soc-stack are documented in this file. Format follows [Ke ## [Unreleased] ### Added +- ATT&CK Navigator coverage layer export: `scripts/lib/navigator.sh` + declarative map in `scripts/lib/data/attack-coverage.json`, written after install to `--navigator-out` (default `/root/soc-stack-navigator.json`); offline regenerator `tools/export-navigator-layer.sh`. Only deployed components and successfully integrated links contribute techniques. - README adoption pass: prominent website link, live CI and release badges, a keyword-rich "What it does" section, a redacted result-JSON example block, and "Why not something else?" / "What soc-stack is not" sections - `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1) - `.github/ISSUE_TEMPLATE/config.yml` (disables blank issues, routes security reports and questions off the issue tracker) diff --git a/README.md b/README.md index 78594c6..29e2746 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ sudo bash install.sh --components all --dry-run # validate + plan, deploy noth After install: - `/root/soc-stack.json` lists every component with its LXC VMID, IP, ports, endpoints, warnings, and secret file paths. Raw passwords and API tokens are redacted by default; pass `--include-secrets-json` only when an automation workflow explicitly needs them. - `/root/mcp-clients.json` is a paste-ready `mcpServers` config block for Claude Desktop, OpenClaw, or any MCP client. It contains bearer tokens and is written root-only. +- `/root/soc-stack-navigator.json` is a MITRE ATT&CK Navigator layer describing declared detection coverage for deployed components and verified integrations. Import it at https://mitre-attack.github.io/attack-navigator/. Regenerate later with `tools/export-navigator-layer.sh` without redeploying. - `/var/lib/soc-stack/state/` has per-component state files used for idempotent re-runs. - `/var/lib/soc-stack/secrets/` has every generated credential (mode 0600, root-only) for audit recovery. @@ -182,6 +183,7 @@ Designed so an AI agent can SSH into a Proxmox host and one-shot a SOC. The full --state-dir PATH State directory (default: /var/lib/soc-stack) --json-out PATH Result JSON path (default: /root/soc-stack.json) --mcp-config-out PATH MCP client config (default: /root/mcp-clients.json) +--navigator-out PATH ATT&CK Navigator coverage layer (default: /root/soc-stack-navigator.json) --log-file PATH Install log (default: /var/log/soc-stack-install.log) --dry-run Validate + plan only, no deploy --force Redeploy components already marked deployed @@ -200,7 +202,7 @@ soc-stack/ ├── install.sh # repo-root wrapper for curl|bash ├── scripts/ │ ├── install.sh # orchestrator (~430 lines) -│ ├── lib/ # 8 shared bash modules (bats-tested) +│ ├── lib/ # shared bash modules (bats-tested) │ │ ├── logging.sh │ │ ├── secrets.sh │ │ ├── json-out.sh @@ -208,7 +210,10 @@ soc-stack/ │ │ ├── network.sh │ │ ├── manifest.sh │ │ ├── preflight.sh -│ │ └── lxc.sh +│ │ ├── lxc.sh +│ │ ├── navigator.sh # ATT&CK Navigator coverage layer emitter +│ │ └── data/ +│ │ └── attack-coverage.json │ └── components/ │ ├── wazuh/ # manifest.jsonc + 5 scripts per component │ ├── thehive-cortex/ @@ -217,13 +222,17 @@ soc-stack/ │ ├── dashboards/ │ └── mcp/ # 9 MCP servers + mcp-proxy SSE bridge ├── tests/ -│ ├── unit/ # 105 bats tests, mocked Proxmox binaries +│ ├── unit/ # bats unit tests, mocked Proxmox binaries │ └── integration/ # per-component + cross-component assertions ├── docs/ │ ├── design/specs/ # design spec (result JSON schema lives here) │ ├── gotchas.md │ ├── adding-a-component.md # component contract walk-through │ └── architecture/ +├── tools/ +│ ├── export-navigator-layer.sh # regenerate ATT&CK Navigator coverage from state +│ ├── setup-ci-runner.sh +│ └── soc-stack-test-reaper.sh ├── playbooks/ # incident response playbooks ├── cases/ # case study evidence └── mcp-servers/ @@ -286,7 +295,7 @@ This stops and destroys the component's LXC and removes its state file. Other co for comp in mcp dashboards zeek-suricata misp thehive-cortex wazuh; do sudo bash scripts/components/${comp}/destroy.sh done -sudo rm -rf /var/lib/soc-stack /root/soc-stack.json /root/mcp-clients.json +sudo rm -rf /var/lib/soc-stack /root/soc-stack.json /root/mcp-clients.json /root/soc-stack-navigator.json ``` The final `rm` removes state, generated secrets, and the emitted JSON; skip it if you want credential recovery later. diff --git a/scripts/install.sh b/scripts/install.sh index 34d26a0..427c08b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -38,6 +38,8 @@ OPT_STATE_DIR="/var/lib/soc-stack" OPT_JSON_OUT="/root/soc-stack.json" # shellcheck disable=SC2034 OPT_MCP_CONFIG_OUT="/root/mcp-clients.json" +# shellcheck disable=SC2034 +OPT_NAVIGATOR_OUT="/root/soc-stack-navigator.json" OPT_LOG_FILE="/var/log/soc-stack-install.log" # shellcheck disable=SC2034 OPT_DRY_RUN="0" @@ -72,6 +74,7 @@ Flags: --state-dir PATH State directory (default: /var/lib/soc-stack) --json-out PATH Result JSON (default: /root/soc-stack.json) --mcp-config-out PATH MCP client config (default: /root/mcp-clients.json) + --navigator-out PATH ATT&CK Navigator coverage layer (default: /root/soc-stack-navigator.json) --log-file PATH Log file (default: /var/log/soc-stack-install.log) --dry-run Validate + plan, do not deploy --force Redeploy even if state shows complete @@ -89,7 +92,7 @@ parse_args() { local flag while [[ $# -gt 0 ]]; do case "$1" in - --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--log-file|--mcp-bind-host) + --components|--preset|--bridge|--storage|--ip-mode|--ip-range|--vlan|--vmid-start|--manifest|--state-dir|--json-out|--mcp-config-out|--navigator-out|--log-file|--mcp-bind-host) flag="$1" if [[ $# -lt 2 || "$2" == --* ]]; then printf 'missing value for %s\n' "${flag}" >&2 @@ -109,6 +112,7 @@ parse_args() { --state-dir) OPT_STATE_DIR="$2" ;; --json-out) OPT_JSON_OUT="$2" ;; --mcp-config-out) OPT_MCP_CONFIG_OUT="$2" ;; + --navigator-out) OPT_NAVIGATOR_OUT="$2" ;; --log-file) OPT_LOG_FILE="$2" ;; --mcp-bind-host) OPT_MCP_BIND_HOST="$2" ;; esac @@ -254,6 +258,7 @@ source_libs() { source "${LIB_DIR}/preflight.sh" source "${LIB_DIR}/lxc.sh" source "${LIB_DIR}/manifest.sh" + source "${LIB_DIR}/navigator.sh" } # Known components in canonical order @@ -769,6 +774,11 @@ main() { emit_mcp_config "${OPT_MCP_CONFIG_OUT}" msg_ok "MCP client config written to ${OPT_MCP_CONFIG_OUT}" + if [[ -n "${OPT_NAVIGATOR_OUT}" ]]; then + emit_navigator_layer "${OPT_NAVIGATOR_OUT}" + msg_ok "ATT&CK Navigator layer written to ${OPT_NAVIGATOR_OUT}" + fi + return "${exit_status}" } diff --git a/scripts/lib/data/attack-coverage.json b/scripts/lib/data/attack-coverage.json new file mode 100644 index 0000000..7dcb83b --- /dev/null +++ b/scripts/lib/data/attack-coverage.json @@ -0,0 +1,122 @@ +{ + "version": "1.0", + "domain": "enterprise-attack", + "attack_version": "17", + "navigator_version": "5.1.0", + "layer_version": "4.5", + "name": "SOC Stack Detection Coverage", + "description": "Declared detection coverage for the installed soc-stack components and verified integrations. Score is the number of contributing sources. Only deployed components and successfully integrated links contribute.", + "components": { + "wazuh": { + "display_name": "Wazuh", + "techniques": [ + { "id": "T1003", "comment": "Credential dumping detection via rules and FIM" }, + { "id": "T1021", "comment": "Remote services / lateral movement alerting" }, + { "id": "T1036", "comment": "Masquerading and anomalous process naming" }, + { "id": "T1041", "comment": "Suspicious outbound / exfil patterns in logs" }, + { "id": "T1053", "comment": "Scheduled task and cron monitoring" }, + { "id": "T1055", "comment": "Process injection indicators" }, + { "id": "T1059", "comment": "Command and scripting interpreter execution" }, + { "id": "T1070", "comment": "Indicator removal / log clearing" }, + { "id": "T1078", "comment": "Valid account misuse and auth anomalies" }, + { "id": "T1082", "comment": "System information discovery" }, + { "id": "T1105", "comment": "Ingress tool transfer" }, + { "id": "T1110", "comment": "Brute force authentication" }, + { "id": "T1190", "comment": "Exploit of public-facing application" }, + { "id": "T1486", "comment": "Ransomware / destructive file activity" }, + { "id": "T1547", "comment": "Boot or logon autostart persistence" }, + { "id": "T1548", "comment": "Abuse elevation control mechanism" }, + { "id": "T1562", "comment": "Defense impairment (agent/service stop)" }, + { "id": "T1566", "comment": "Phishing-related mail and endpoint signals" } + ] + }, + "zeek-suricata": { + "display_name": "Zeek + Suricata", + "techniques": [ + { "id": "T1018", "comment": "Remote system discovery via connection metadata" }, + { "id": "T1021", "comment": "Remote services observed in conn/ssh logs and IDS" }, + { "id": "T1040", "comment": "Network sniffing / SPAN visibility via NSM" }, + { "id": "T1041", "comment": "C2 and exfil over application protocols" }, + { "id": "T1046", "comment": "Network service scanning" }, + { "id": "T1048", "comment": "Exfiltration over alternative protocol" }, + { "id": "T1071", "comment": "Application-layer protocol C2 (HTTP/DNS/TLS)" }, + { "id": "T1095", "comment": "Non-application layer protocol traffic" }, + { "id": "T1105", "comment": "Tool transfer over the wire (files.log / fileinfo)" }, + { "id": "T1189", "comment": "Drive-by compromise signatures" }, + { "id": "T1190", "comment": "Exploit attempts against public services" }, + { "id": "T1203", "comment": "Client-side exploitation signatures" }, + { "id": "T1498", "comment": "Network denial of service" }, + { "id": "T1571", "comment": "Non-standard port usage" }, + { "id": "T1573", "comment": "Encrypted channel anomalies (JA3/TLS)" }, + { "id": "T1595", "comment": "Active scanning" } + ] + }, + "misp": { + "display_name": "MISP", + "techniques": [ + { "id": "T1071", "comment": "IOC correlation for known C2 infrastructure" }, + { "id": "T1102", "comment": "Web service C2 indicators from threat feeds" }, + { "id": "T1566", "comment": "Phishing IOCs (domains, URLs, hashes)" }, + { "id": "T1583", "comment": "Acquired infrastructure tracking via feeds" }, + { "id": "T1584", "comment": "Compromised infrastructure indicators" } + ] + }, + "thehive-cortex": { + "display_name": "TheHive + Cortex", + "techniques": [ + { "id": "T1041", "comment": "Observable enrichment for exfil/C2 indicators" }, + { "id": "T1071", "comment": "Analyzer enrichment of network observables" }, + { "id": "T1566", "comment": "Phishing case triage and observable analysis" } + ] + } + }, + "integrations": { + "wazuh-thehive-webhook": { + "display_name": "Wazuh -> TheHive webhook", + "from": "wazuh", + "to": "thehive-cortex", + "type": "webhook", + "wired_by": "wazuh", + "techniques": [ + { "id": "T1059", "comment": "Alert-to-case handoff for script execution detections" }, + { "id": "T1078", "comment": "Alert-to-case handoff for account misuse" }, + { "id": "T1486", "comment": "Alert-to-case handoff for destructive activity" } + ] + }, + "thehive-cortex-api": { + "display_name": "TheHive <-> Cortex API", + "from": "thehive-cortex", + "to": "thehive-cortex", + "type": "api", + "wired_by": "thehive-cortex", + "techniques": [ + { "id": "T1071", "comment": "Automated analyzer enrichment on case observables" }, + { "id": "T1566", "comment": "Phishing observable fan-out to Cortex analyzers" } + ] + }, + "misp-suricata-rule-feed": { + "display_name": "MISP -> Suricata rule feed", + "from": "misp", + "to": "zeek-suricata", + "type": "rule-feed", + "wired_by": "zeek-suricata", + "techniques": [ + { "id": "T1071", "comment": "IOC-derived Suricata rules for known C2" }, + { "id": "T1102", "comment": "IOC-derived rules for web-service C2" }, + { "id": "T1566", "comment": "IOC-derived rules for phishing infrastructure" } + ] + }, + "zeek-wazuh-agent-forward": { + "display_name": "Zeek -> Wazuh agent forward", + "from": "zeek-suricata", + "to": "wazuh", + "type": "agent-forward", + "wired_by": "zeek-suricata", + "techniques": [ + { "id": "T1046", "comment": "NSM scan evidence correlated in SIEM" }, + { "id": "T1071", "comment": "NSM protocol metadata correlated in SIEM" }, + { "id": "T1105", "comment": "NSM file-transfer evidence correlated in SIEM" } + ] + } + } +} diff --git a/scripts/lib/navigator.sh b/scripts/lib/navigator.sh new file mode 100755 index 0000000..2e73f26 --- /dev/null +++ b/scripts/lib/navigator.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# scripts/lib/navigator.sh - emit a MITRE ATT&CK Navigator coverage layer +# from deployed component state + the declarative attack-coverage map. +# Requires: jq, lib/json-out.sh (state_file helpers optional; uses SOC_STATE_DIR) + +: "${SOC_STATE_DIR:=/var/lib/soc-stack}" + +# Default coverage map lives next to this library. +_navigator_default_coverage_map() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + printf '%s/data/attack-coverage.json\n' "${here}" +} + +: "${NAVIGATOR_COVERAGE_MAP:=$(_navigator_default_coverage_map)}" + +# _navigator_component_deployed +# Exit 0 when state file exists and status is exactly "deployed". +_navigator_component_deployed() { + local name="$1" + local f="${SOC_STATE_DIR}/state/${name}.json" + [[ -f "${f}" ]] || return 1 + [[ "$(jq -r '.status // empty' "${f}")" == "deployed" ]] +} + +# _navigator_integration_eligible +# An integration contributes coverage only when: +# 1. from and to components are deployed +# 2. wired_by's integration.status is exactly "integrated" (failed/missing skip) +# 3. If wired_by records a matching .integrations[] entry, it must be status=configured +_navigator_integration_eligible() { + local wired_by="$1" + local from="$2" + local to="$3" + local type="$4" + local f entry_status integ_status + + _navigator_component_deployed "${from}" || return 1 + _navigator_component_deployed "${to}" || return 1 + + f="${SOC_STATE_DIR}/state/${wired_by}.json" + [[ -f "${f}" ]] || return 1 + + integ_status="$(jq -r '.integration.status // empty' "${f}")" + [[ "${integ_status}" == "integrated" ]] || return 1 + + # If a matching per-link record exists, require configured (skip failed/pending). + entry_status="$(jq -r --arg to "${to}" --arg type "${type}" ' + (.integrations // []) + | map(select(.to == $to and .type == $type)) + | .[0].status // empty + ' "${f}")" + if [[ -n "${entry_status}" && "${entry_status}" != "configured" ]]; then + return 1 + fi + return 0 +} + +# _navigator_accumulate_techniques +# sources_json: [{kind,id,display_name}] +# Prints technique accumulator JSON: { "T1059": {score, comments:[], sources:[]} } +_navigator_accumulate_techniques() { + local coverage_json="$1" + local sources_json="$2" + + jq -n --argjson cov "${coverage_json}" --argjson sources "${sources_json}" ' + def add_tech($acc; $tid; $src; $comment): + ($acc[$tid] // {score: 0, sources: [], comments: []}) as $cur + | $acc + { + ($tid): { + score: ($cur.score + 1), + sources: ($cur.sources + [$src] | unique), + comments: ($cur.comments + [ + (if ($comment | length) > 0 then "\($src): \($comment)" else $src end) + ] | unique) + } + }; + + reduce $sources[] as $s ({}; + if $s.kind == "component" then + (($cov.components[$s.id].techniques // []) ) as $techs + | reduce $techs[] as $t (.; + add_tech(.; $t.id; $s.display_name; ($t.comment // "")) + ) + elif $s.kind == "integration" then + (($cov.integrations[$s.id].techniques // []) ) as $techs + | reduce $techs[] as $t (.; + add_tech(.; $t.id; $s.display_name; ($t.comment // "")) + ) + else . end + ) + ' +} + +# _navigator_collect_sources +# Walk state dir against the coverage map; print JSON array of contributing sources. +_navigator_collect_sources() { + local coverage_json="$1" + local sources='[]' + local name display integ_id from to type wired_by display_i + + # Components + while IFS= read -r name; do + [[ -n "${name}" ]] || continue + if _navigator_component_deployed "${name}"; then + display="$(jq -r --arg n "${name}" '.components[$n].display_name // $n' <<< "${coverage_json}")" + sources="$(jq --arg id "${name}" --arg d "${display}" \ + '. + [{kind:"component", id:$id, display_name:$d}]' <<< "${sources}")" + fi + done < <(jq -r '.components | keys[]' <<< "${coverage_json}") + + # Integrations + while IFS= read -r integ_id; do + [[ -n "${integ_id}" ]] || continue + from="$(jq -r --arg id "${integ_id}" '.integrations[$id].from' <<< "${coverage_json}")" + to="$(jq -r --arg id "${integ_id}" '.integrations[$id].to' <<< "${coverage_json}")" + type="$(jq -r --arg id "${integ_id}" '.integrations[$id].type' <<< "${coverage_json}")" + wired_by="$(jq -r --arg id "${integ_id}" '.integrations[$id].wired_by' <<< "${coverage_json}")" + if _navigator_integration_eligible "${wired_by}" "${from}" "${to}" "${type}"; then + display_i="$(jq -r --arg id "${integ_id}" '.integrations[$id].display_name // $id' <<< "${coverage_json}")" + sources="$(jq --arg id "${integ_id}" --arg d "${display_i}" \ + '. + [{kind:"integration", id:$id, display_name:$d}]' <<< "${sources}")" + fi + done < <(jq -r '.integrations | keys[]' <<< "${coverage_json}") + + printf '%s\n' "${sources}" +} + +# emit_navigator_layer [coverage_map_path] +# Reads SOC_STATE_DIR component state and writes a Navigator layer JSON (v4.5). +# Always writes a valid layer (possibly with empty techniques) so operators can +# import a blank coverage view after a partial install. +emit_navigator_layer() { + local out="$1" + local map_path="${2:-${NAVIGATOR_COVERAGE_MAP}}" + local coverage_json sources_json tech_acc max_score + local installed_at parent + + if [[ -z "${out}" ]]; then + printf 'emit_navigator_layer: output path required\n' >&2 + return 1 + fi + if [[ ! -f "${map_path}" ]]; then + printf 'emit_navigator_layer: coverage map not found: %s\n' "${map_path}" >&2 + return 1 + fi + if ! coverage_json="$(jq -c . "${map_path}" 2>/dev/null)"; then + printf 'emit_navigator_layer: coverage map is not valid JSON: %s\n' "${map_path}" >&2 + return 1 + fi + + parent="$(dirname "${out}")" + if [[ "${parent}" != "." ]]; then + mkdir -p "${parent}" + chmod 700 "${parent}" 2>/dev/null || true + fi + + sources_json="$(_navigator_collect_sources "${coverage_json}")" + tech_acc="$(_navigator_accumulate_techniques "${coverage_json}" "${sources_json}")" + max_score="$(jq '[.[].score] | max // 1' <<< "${tech_acc}")" + if [[ "${max_score}" -lt 1 ]]; then + max_score=1 + fi + installed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + jq -n \ + --argjson cov "${coverage_json}" \ + --argjson acc "${tech_acc}" \ + --argjson sources "${sources_json}" \ + --argjson max_score "${max_score}" \ + --arg generated_at "${installed_at}" \ + --arg soc_stack_version "${SOC_STACK_VERSION:-1.0.0}" \ + ' + ($acc | to_entries | map({ + techniqueID: .key, + score: .value.score, + comment: (.value.comments | join("; ")), + enabled: true, + showSubtechniques: false + }) | sort_by(.techniqueID)) as $techniques + | { + name: $cov.name, + versions: { + attack: ($cov.attack_version | tostring), + navigator: $cov.navigator_version, + layer: $cov.layer_version + }, + domain: $cov.domain, + description: $cov.description, + filters: { + platforms: ["Windows", "Linux", "macOS", "Network"] + }, + sorting: 3, + layout: { + layout: "side", + aggregateFunction: "average", + showID: true, + showName: true, + showAggregateScores: false, + countUnscored: false, + expandedSubtechniques: "annotated" + }, + hideDisabled: false, + techniques: $techniques, + gradient: { + colors: ["#ffffff", "#90caf9", "#1565c0"], + minValue: 0, + maxValue: $max_score + }, + legendItems: [ + {label: "1 contributing source", color: "#90caf9"}, + {label: "Multiple contributing sources", color: "#1565c0"} + ], + metadata: [ + {name: "generated_by", value: "soc-stack"}, + {name: "soc_stack_version", value: $soc_stack_version}, + {name: "generated_at", value: $generated_at}, + {name: "contributing_sources", value: ($sources | map(.display_name) | join(", "))} + ], + selectTechniquesAcrossTactics: true, + selectSubtechniquesWithParent: false, + selectVisibleTechniques: false + } + ' > "${out}" + + chmod 600 "${out}" 2>/dev/null || true +} diff --git a/tests/unit/test_navigator.bats b/tests/unit/test_navigator.bats new file mode 100644 index 0000000..f1bdde2 --- /dev/null +++ b/tests/unit/test_navigator.bats @@ -0,0 +1,123 @@ +#!/usr/bin/env bats +# ATT&CK Navigator coverage layer emitter + +load helpers/load.bash + +setup() { + export SOC_STATE_DIR="${BATS_TEST_TMPDIR}/var/lib/soc-stack" + export SOC_LOG_FILE="${BATS_TEST_TMPDIR}/soc-stack.log" + export SOC_STACK_VERSION="1.0.0-test" + mkdir -p "${SOC_STATE_DIR}/state" + source_lib logging + source "${REPO_ROOT}/scripts/lib/navigator.sh" +} + +write_state() { + local name="$1" + local json="$2" + printf '%s\n' "${json}" > "${SOC_STATE_DIR}/state/${name}.json" +} + +@test "emit_navigator_layer writes valid Navigator v4.5 layer with empty techniques when no state" { + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.name' "${out}" >/dev/null + jq -e '.domain == "enterprise-attack"' "${out}" + jq -e '.versions.layer == "4.5"' "${out}" + jq -e '.versions.navigator' "${out}" >/dev/null + jq -e '.techniques | type == "array"' "${out}" + jq -e '.techniques | length == 0' "${out}" + [[ "$(stat -c "%a" "${out}")" == "600" ]] +} + +@test "emit_navigator_layer includes techniques from a deployed component" { + write_state wazuh '{"component":"wazuh","status":"deployed"}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.techniques | length > 0' "${out}" + jq -e '[.techniques[].techniqueID] | index("T1059") != null' "${out}" + jq -e '.techniques[] | select(.techniqueID=="T1059") | .score >= 1' "${out}" + jq -e '.techniques[] | select(.techniqueID=="T1059") | .comment | test("Wazuh")' "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh")' "${out}" +} + +@test "emit_navigator_layer skips components that are not deployed" { + write_state wazuh '{"component":"wazuh","status":"failed"}' + write_state misp '{"component":"misp","status":"deployed"}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + # MISP covers T1566; Wazuh also covers T1059 — T1059 must be absent + jq -e '[.techniques[].techniqueID] | index("T1059") == null' "${out}" + jq -e '[.techniques[].techniqueID] | index("T1566") != null' "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh") | not' "${out}" +} + +@test "emit_navigator_layer includes integration techniques when integrated and peers deployed" { + write_state wazuh '{"component":"wazuh","status":"deployed","integration":{"status":"integrated"},"integrations":[{"to":"thehive-cortex","type":"webhook","status":"configured"}]}' + write_state thehive-cortex '{"component":"thehive-cortex","status":"deployed","integration":{"status":"integrated"}}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + # Integration adds T1486 comment about alert-to-case; score for shared tech > component alone + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh -> TheHive webhook")' "${out}" + jq -e '.techniques[] | select(.techniqueID=="T1486") | .score >= 2' "${out}" +} + +@test "emit_navigator_layer excludes integration when integration.status is failed" { + write_state wazuh '{"component":"wazuh","status":"deployed","integration":{"status":"failed"},"integrations":[{"to":"thehive-cortex","type":"webhook","status":"configured"}]}' + write_state thehive-cortex '{"component":"thehive-cortex","status":"deployed","integration":{"status":"integrated"}}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh -> TheHive webhook") | not' "${out}" + # Component still contributes + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh")' "${out}" +} + +@test "emit_navigator_layer excludes integration when link record is not configured" { + write_state wazuh '{"component":"wazuh","status":"deployed","integration":{"status":"integrated"},"integrations":[{"to":"thehive-cortex","type":"webhook","status":"failed"}]}' + write_state thehive-cortex '{"component":"thehive-cortex","status":"deployed","integration":{"status":"integrated"}}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh -> TheHive webhook") | not' "${out}" +} + +@test "emit_navigator_layer excludes integration when peer component is not deployed" { + write_state wazuh '{"component":"wazuh","status":"deployed","integration":{"status":"integrated"},"integrations":[{"to":"thehive-cortex","type":"webhook","status":"configured"}]}' + write_state thehive-cortex '{"component":"thehive-cortex","status":"failed"}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("Wazuh -> TheHive webhook") | not' "${out}" +} + +@test "emit_navigator_layer excludes unverified integrations (missing integration.status)" { + write_state misp '{"component":"misp","status":"deployed"}' + write_state zeek-suricata '{"component":"zeek-suricata","status":"deployed"}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + jq -e '.metadata[] | select(.name=="contributing_sources") | .value | test("MISP -> Suricata") | not' "${out}" +} + +@test "emit_navigator_layer aggregates scores across multiple sources" { + write_state wazuh '{"component":"wazuh","status":"deployed"}' + write_state zeek-suricata '{"component":"zeek-suricata","status":"deployed"}' + local out="${BATS_TEST_TMPDIR}/layer.json" + emit_navigator_layer "${out}" + # T1105 is covered by both wazuh and zeek-suricata + jq -e '.techniques[] | select(.techniqueID=="T1105") | .score == 2' "${out}" + jq -e '.gradient.maxValue >= 2' "${out}" +} + +@test "emit_navigator_layer fails when coverage map is missing" { + local out="${BATS_TEST_TMPDIR}/layer.json" + run emit_navigator_layer "${out}" "${BATS_TEST_TMPDIR}/no-such-map.json" + [[ "$status" -ne 0 ]] +} + +@test "tools/export-navigator-layer.sh regenerates from state dir" { + write_state misp '{"component":"misp","status":"deployed"}' + local out="${BATS_TEST_TMPDIR}/exported.json" + run "${REPO_ROOT}/tools/export-navigator-layer.sh" \ + --state-dir "${SOC_STATE_DIR}" \ + --out "${out}" + assert_success + jq -e '[.techniques[].techniqueID] | index("T1566") != null' "${out}" +} diff --git a/tests/unit/test_orchestrator_flag_parsing.bats b/tests/unit/test_orchestrator_flag_parsing.bats index c255bd0..ade69b1 100644 --- a/tests/unit/test_orchestrator_flag_parsing.bats +++ b/tests/unit/test_orchestrator_flag_parsing.bats @@ -19,6 +19,7 @@ setup() { [[ "${OPT_IP_MODE}" == "dhcp" ]] [[ "${OPT_STATE_DIR}" == "/var/lib/soc-stack" ]] [[ "${OPT_JSON_OUT}" == "/root/soc-stack.json" ]] + [[ "${OPT_NAVIGATOR_OUT}" == "/root/soc-stack-navigator.json" ]] [[ "${OPT_MCP_BIND_HOST}" == "127.0.0.1" ]] } @@ -55,6 +56,11 @@ setup() { [[ "${OPT_MCP_BIND_HOST}" == "0.0.0.0" ]] } +@test "parse_args overrides --navigator-out" { + parse_args --navigator-out /tmp/attack-layer.json + [[ "${OPT_NAVIGATOR_OUT}" == "/tmp/attack-layer.json" ]] +} + @test "parse_args sets OPT_VMID_START" { parse_args --vmid-start 9000 [[ "${OPT_VMID_START}" == "9000" ]] diff --git a/tools/export-navigator-layer.sh b/tools/export-navigator-layer.sh new file mode 100755 index 0000000..6829af4 --- /dev/null +++ b/tools/export-navigator-layer.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# tools/export-navigator-layer.sh - regenerate an ATT&CK Navigator coverage +# layer from an existing soc-stack state directory (no redeploy required). +# +# Usage: +# tools/export-navigator-layer.sh [--state-dir PATH] [--out PATH] [--coverage-map PATH] +# +# Defaults: +# --state-dir /var/lib/soc-stack +# --out ./soc-stack-navigator.json +# --coverage-map scripts/lib/data/attack-coverage.json +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +OPT_STATE_DIR="/var/lib/soc-stack" +OPT_OUT="./soc-stack-navigator.json" +OPT_COVERAGE_MAP="${REPO_ROOT}/scripts/lib/data/attack-coverage.json" + +usage() { + cat <&2; exit 1; } + OPT_STATE_DIR="$2"; shift 2 ;; + --out) + [[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for %s\n' "$1" >&2; exit 1; } + OPT_OUT="$2"; shift 2 ;; + --coverage-map) + [[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for %s\n' "$1" >&2; exit 1; } + OPT_COVERAGE_MAP="$2"; shift 2 ;; + --help|-h) + usage; exit 0 ;; + *) + printf 'unknown flag: %s\n' "$1" >&2 + usage >&2 + exit 1 ;; + esac +done + +export SOC_STATE_DIR="${OPT_STATE_DIR}" +# shellcheck source=/dev/null +source "${REPO_ROOT}/scripts/lib/navigator.sh" + +emit_navigator_layer "${OPT_OUT}" "${OPT_COVERAGE_MAP}" +printf 'navigator layer written to %s\n' "${OPT_OUT}"