agentleFS
greglas75greglas75/zuvo/skills/infra-audit/SKILL.md

infra-audit

Skill7 starsChanged 28 days agoMITView on GitHub

Server / infrastructure security audit of LIVE hosts over SSH (12 dimensions IS1-IS12: SSH hardening, accounts, network exposure, TLS, firewall, patch posture, logging, web services, Docker, databases, filesystem, host secrets). Deterministic collector + parallel LLM analysts; dual-vantage (internal SSH + external via proxy); per-target authorization gate; consent-gated tool installs; read-only with one consented exception. Flags: --host user@addr[:port] (single ad-hoc host), --quick (IS1+IS3+IS4, <3 min/host), --dimensions IS1,IS3,… (explicit subset), --no-install (hard read-only), --dry-run (print commands, no connections), --resume <run-dir> (continue interrupted run), --proxy <url> (external-scan proxy), --external direct (proxyless external scan, polite timing), --skip-external (internal vantage only), --deep-scan (nmap -p- full port sweep), --confirm-targets <sha256> (non-interactive authorization).

  • Reads credentials

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

21 rules. About 0 are common, 6 are distinctive, and 15 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 15

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Read in two stages (pentest model).

    line 87
  2. Distinctive:

    If any Stage 1 file is missing, STOP.

    line 100
  3. Very specific:

    Read ../../shared/includes/env-compat.md for agent dispatch, path resolution, the [AUTO-DECISION] non-interactive contract, and the [MODE …

    line 117
  4. Very specific:

    Write/update state.json after EACH host transitions (collecting → analyzed → reported) so the next auto-resume sees accurate progress.

    line 233
  5. Very specific:

    Before writing ANY file under zuvo/, confirm zuvo/ is gitignored.

    line 238
  6. Very specific:

    Duplicate-IP dedup (E11): if two entries resolve to the same IP, MERGE them into one target and print [WARN] duplicate IP <ip> — entries …

    line 292
  7. Very specific:

    No password fields exist in the schema.

    line 295
  8. Very specific:

    OS detection (E10 — Alpine branch): read /etc/os-release; if /etc/alpine-release exists → Alpine path: skip lynis, use apk-based checks …

    line 345
  9. Very specific:

    BEFORE any bundle/raw write (IC-5).

    line 371
  10. Distinctive:

    NEVER fed to the LLM.

    line 372
  11. Distinctive:

    Claude Code (primary): use the Agent tool to run all four in parallel, once per host bundle:

    line 440
  12. Very specific:

    Cursor / Antigravity: no agent spawning. Execute each analyst's procedure sequentially yourself, reading agents/<name>.md, preserving the …

    line 475
  13. Very specific:

    Consecutive dispatch rate-limits = agent failure: If sub-agent dispatch returns a rate-limit / overloaded / quota error twice in a row for …

    line 482
  14. Very specific:

    Every analyst finding MUST cite an existing bundle.checks[].id.

    line 521
  15. Very specific:

    Write each per-host report (<host>.md + <host>.json) FIRST, then assemble fleet-summary.md LAST (a crash mid-run leaves no fleet-summary — …

    line 572
  16. Very specific:

    When any tool is absent / install declined / wall-clock-skipped, the per-host header MUST carry coverage_mode: DEGRADED (AC9) and the Tool …

    line 606
  17. Very specific:

    MUST list every missing tool with its per-dimension fallback note.

    line 608
  18. Very specific:

    If gate_status = FAIL → VERDICT = INCOMPLETE.

    line 692
  19. Very specific:

    Follow the retrospective protocol from retrospective.md: gate check → structured questions → RETRO: TSV emit → retros.log + retros.md …

    line 696
  20. Distinctive:

    Run: <ISO-8601-Z> infra-audit <project> <N-critical> <N-total> <VERDICT> <N-hosts> <dimensions-mode> <NOTES> <BRANCH> <SHA7> <INCLUDES> …

    line 707
  21. Distinctive:

    If fixable findings were detected, suggest the v2 follow-up:

    line 723

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

zuvo:infra-audit

Audit the security of LIVE servers/infrastructure the user owns, over SSH, plus external exposure verification through a proxy. This is the orthogonal companion to security-audit (repo-resident IaC config), db-audit (DB as a data layer), and pentest (app-code exploitability). Routing boundary: "is my server secure?" → infra-audit.

Architecture (DD-5): a deterministic collector (scripts/infra-collect.sh) gathers everything per host into a normalized JSON bundle; parallel LLM analysts each see only their layer's normalized findings; deterministic dedup/scoring/report assembly. The LLM interprets, never detects.

Argument Parsing

ArgumentMeaning
[path/to/hosts.yaml]Fleet audit from inventory (default: zuvo/infra/hosts.yaml if present)
--host user@addr[:port]Single ad-hoc host, no inventory file needed
--quickIS1 + IS3 (internal) + IS4 only; < 3 min/host target
--dimensions IS1,IS3,…Explicit dimension subset
--no-installHard read-only: never offer tool installation (disables the DD-3 gate)
--dry-runPrint every SSH/local command without executing; no connections beyond DNS
--resume <run-dir>Continue an interrupted run from state.json
--proxy <url>External-scan proxy override (else hosts.yaml defaults.proxy, else $ZUVO_SCAN_PROXY)
--scan-via <ssh-target>Run the external leg FROM this SSH host via portable nc/openssl/curl (no nmap/testssl/nuclei/proxychains; macOS-safe; a real internet vantage). Highest external-vantage priority. Else hosts.yaml defaults.scan_via, else $ZUVO_SCAN_VIA
--external directExplicit opt-in to a proxyless external scan (polite timing -T2 --max-rate 50)
--skip-externalInternal vantage only (no external leg)
--deep-scannmap -p- instead of --top-ports 1000 (EXCLUDED from the AC-S2 timing SLA + 30-min wall clock)
--confirm-targets <sha256>Non-interactive authorization: sha256 of the resolved target list printed by a prior --dry-run. REQUIRED for any non-interactive run — without it the run ABORTS at the gate (targets are never auto-approved)

Parse flags first. --host and a hosts.yaml path are mutually-complete: exactly one target source must resolve. --quick and --dimensions are mutually exclusive (error if both). --resume <run-dir> overrides target resolution from the run's own state.json.

Variable mapping for flags that the collector invocation threads through:

FlagVariable set
--external <mode> (e.g. --external direct)EXTERNAL_MODE=<mode>
--skip-externalSKIP_EXTERNAL=1
--proxy <url>proxy=<url> (overrides hosts.yaml/env)
--scan-via <ssh-target>SCAN_VIA=<ssh-target> (overrides hosts.yaml defaults.scan_via / $ZUVO_SCAN_VIA)
--quickQUICK=1
--dimensions <list>DIMENSIONS=<list>
--no-installNO_INSTALL=1
--deep-scanDEEP_SCAN=1

--external <mode> and --skip-external are mutually exclusive; error if both supplied.

Dimensions (IS1-IS12)

IDDimensionAnalyst
IS1SSH hardening (sshd_config)host-analyst
IS2Accounts & authhost-analyst
IS3Network exposure — dual-vantagenetwork-analyst
IS4TLS & certificatesnetwork-analyst
IS5Firewall & kernel nethost-analyst
IS6Patch posturehost-analyst
IS7Logging & intrusion detectionhost-analyst
IS8Deployed web services (safe tags only)network-analyst
IS9Dockercontainer-analyst
IS10Database serversdata-analyst
IS11Filesystem & kernel hardeninghost-analyst
IS12Secrets hygiene on hostdata-analyst

--quick = IS1 + IS3 (internal only) + IS4. Full = all applicable; dimensions auto-skip when the surface is absent (e.g. IS9 without Docker → N/A).


Mandatory File Loading

Read in two stages (pentest model). Stage 1 before Phase 0; Stage 2 before Phase 3 (report writing). STOP if any Stage 1 file is missing.

Stage 1 — Before starting (STOP if any missing)

CORE FILES LOADED:
  1. ../../shared/includes/env-compat.md           -- [READ | MISSING -> STOP]   (dispatch + [MODE SWITCH] rate-limit fallback)
  2. ../../shared/includes/no-pause-protocol.md     -- [READ | MISSING -> STOP]   (HARD: no mid-loop pauses across hosts/dimensions)
  3. ../../shared/includes/infra-check-registry.md  -- [READ | MISSING -> STOP]   (check_id → severity → remediation → CIS; Phase 3 authority)
  4. ../../shared/includes/ssh-probe-protocol.md    -- [READ | MISSING -> STOP]   (IC-8 SSH invariants; authorization gate §1; runtime carrier)

If any Stage 1 file is missing, STOP.

Stage 2 — Before Phase 3 (report writing)

  5. ../../shared/includes/backlog-protocol.md       -- [READ | MISSING -> STOP]   (findings are recorded through backlog-add)
  6. ../../shared/includes/run-logger.md             -- [READ | MISSING]   (Run: line + append-runlog)
  7. ../../shared/includes/retrospective.md          -- [READ | MISSING]   (RETRO: marker + retros.log/.md append)
  8. ../../shared/includes/report-output-location.md -- [READ | MISSING]   (canonical $ZUVO_DIR — output MUST go to zuvo/audits/)

Environment Compatibility

Dispatch follows ../../shared/includes/execution-policy.md through env-compat. Reuse existing authorization within that policy; session restrictions take precedence. Run each required gate and report its actual independence or an unmet requirement.

Read ../../shared/includes/env-compat.md for agent dispatch, path resolution, the [AUTO-DECISION] non-interactive contract, and the [MODE SWITCH] rate-limit fallback. Sub-agent dispatch is Claude-Code-native; Codex/Cursor/Antigravity run the analysts sequentially inline (no spawning) — see the Phase 2 dispatch block.

Mandatory acknowledgment (REQUIRED — print verbatim before Phase 0)

This audit connects to LIVE servers over SSH. It is read-only (one consented
exception: DD-3 tool installs). No target is contacted before the authorization
gate (ssh-probe-protocol §1) is explicitly confirmed. Secrets are redacted at the
collector (IC-5); SSH private key material is never read or stored (ssh-probe-
protocol §4).

Phase 0 — PREFLIGHT (laptop, before any SSH connection)

Phase 0 runs entirely on the laptop. No SSH connection opens until the Authorization Gate is explicitly confirmed.

Phase 0.0 — retro-marker (passive checkpoint capture)

Emit a passive run marker so an API-error-killed run is still attributable. This is a fire-and-forget bash block — never block the audit on it.

# >>> zuvo:retro-marker  (infra-audit — passive checkpoint capture)
_RS=$(command -v retro-stub 2>/dev/null || ls ~/.claude/plugins/cache/zuvo-marketplace/zuvo/*/scripts/zuvo-home/retro-stub 2>/dev/null | head -1)
_ZH="${ZUVO_HOME:-$HOME/.zuvo}"
_RSK="${SKILL:-infra-audit}"
_RPR="${PROJECT:-$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")}"
_RSHA=$(git rev-parse --short HEAD 2>/dev/null || echo "-")
[ -n "$_RS" ] && "$_RS" --sweep >/dev/null 2>&1 || true
if mkdir -p "$_ZH/run-markers" 2>/dev/null; then
  { printf 'start_ts=%s\nskill=%s\nproject=%s\nsha7=%s\nbranch=%s\nsession_id=%s\nrepo_root=%s\n' \
      "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$_RSK" "$_RPR" "$_RSHA" \
      "$(git branch --show-current 2>/dev/null || echo -)" "${ZUVO_SESSION_ID:-$_RSHA}" \
      "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" ; } \
      > "$_ZH/run-markers/$_RSK-$_RPR-$_RSHA-$$-$(date +%s).marker" 2>/dev/null || true
fi
# <<< zuvo:retro-marker

Phase 0.1 — Resolve the output directory (IC-1)

Resolve the canonical run directory per report-output-location.md:

ZUVO_DIR="${ZUVO_OUTPUT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)/zuvo}"
RUN_TS="$(date -u +%Y-%m-%d-%H%M)"
RUN_DIR="$ZUVO_DIR/audits/infra-audit-$RUN_TS"     # IC-1
mkdir -p "$RUN_DIR/bundle" "$RUN_DIR/raw" "$RUN_DIR/findings"

On --resume <run-dir>, set RUN_DIR to the supplied dir instead and DO NOT re-timestamp (resume appends to its own dir only).

Implicit resume after an interrupted run (StopFailure / watchdog auto-resume). The collector is invoked from an orchestrator turn that can die on an API error / rate-limit; the StopFailure watchdog then re-invokes zuvo:infra-audit fresh, with the same args — without --resume. A naive fresh run would mint a NEW timestamped RUN_DIR and re-collect every host from zero, discarding the bundles the interrupted run already wrote (observed 2026-06-12: 7 auto-resumes). So BEFORE minting a new RUN_DIR, detect an in-progress run and adopt it:

# Implicit-resume detection: the most-recent infra-audit run whose state.json
# still has a host NOT in a terminal status (reported|unreachable|failed) is an
# unfinished run — adopt its dir instead of creating a new one. A fresh first
# run (no such dir) falls through to the new-RUN_DIR mint above.
# NOTE: iterate via a glob (NOT `$(ls)`) — a `for x in $(ls)` word-splits on
# whitespace/glob chars in dir names. The glob yields one safe word per match;
# pick the newest by mtime without parsing `ls`.
if [ -z "${RESUME_DIR:-}" ]; then   # only when --resume was NOT passed explicitly
  _now="$(date +%s)"; _fresh_window=1800   # 30 min — the crash-recovery window
  _newest=""; _newest_mt=0
  for _d in "$ZUVO_DIR"/audits/infra-audit-*/; do
    [ -d "$_d" ] && [ -f "${_d}state.json" ] || continue
    _mt="$(stat -c %Y "${_d}state.json" 2>/dev/null || stat -f %m "${_d}state.json" 2>/dev/null || echo 0)"
    # FRESHNESS BOUND: only auto-adopt a run whose state.json changed within the
    # last 30 min — a genuine StopFailure/watchdog resume fires minutes after the
    # kill. An OLDER unfinished run is NOT silently adopted (that would return
    # stale results, or trust a poisoned/abandoned dir); the user gets a fresh run
    # and can resume the old one explicitly with `--resume <dir>`.
    [ $((_now - _mt)) -le "$_fresh_window" ] || continue
    if jq -e '[.hosts[]?|select(.status|IN("reported","unreachable","failed")|not)]|length>0' \
         "${_d}state.json" >/dev/null 2>&1; then
      [ "$_mt" -gt "$_newest_mt" ] && { _newest_mt="$_mt"; _newest="${_d%/}"; }
    fi
  done
  if [ -n "$_newest" ]; then
    RUN_DIR="$_newest"
    echo "[RESUME] adopting recent in-progress run $RUN_DIR (interrupted <30m ago) — skipping completed hosts"
  fi
fi

On adoption, a per-host skip additionally validates the bundle at the gate before trusting collection_complete: true: the bundle must be valid JSON whose .host matches the inventory entry and whose bundle_sha256 (recomputed) is self-consistent. A bundle that fails validation re-collects from scratch — the collection_complete flag is a convenience signal, not a security boundary (everything under zuvo/ is local user state; a local attacker who can write it already has the access the audit would report).

Then in Phase 0.6 / Phase 1, per-host idempotency: skip any host whose state.json status is already reported (its report is immutable) or whose bundle/<host>.json has collection_complete == true (an unambiguous flag the collector sets ONLY on its final, fully-scanned write — NEVER infer "done" from a non-empty checks[]; an early/partial checkpoint has collection_complete: false and an empty/partial checks[] is NOT a clean result). A host whose bundle is absent or collection_complete: false re-collects from scratch (a partial bundle is never trusted — the resume table). This makes every auto-resume idempotent: finished hosts are not re-audited, the unfinished one continues. Write/update state.json after EACH host transitions (collecting → analyzed → reported) so the next auto-resume sees accurate progress.

Phase 0.2 — Secrets-hygiene preflight (DD-10)

Before writing ANY file under zuvo/, confirm zuvo/ is gitignored. If missing, append the single line zuvo/ with a warning. This gate runs BEFORE any SSH connection — a git secrets leak must be prevented before collection begins.

GITIGNORE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gitignore"
if ! { [ -f "$GITIGNORE" ] && grep -qE '^/?zuvo/?$' "$GITIGNORE"; }; then
  printf 'zuvo/\n' >> "$GITIGNORE"
  echo "[WARN] DD-10: appended 'zuvo/' to .gitignore (was not gitignored) — audit output must never be committed"
fi

The ONLY writes outside RUN_DIR permitted by the whole run are this one-time zuvo/ line (AC10 exemption) and, through backlog-add, memory/backlog.md and memory/findings/.

Phase 0.3 — Target source resolution + first-run scaffold (DD-2)

SourceAction
--host user@addr[:port]Single ad-hoc host; no inventory file.
[path/to/hosts.yaml]Parse the supplied inventory.
neither, defaultUse zuvo/infra/hosts.yaml if present.
neither, and zuvo/infra/hosts.yaml ABSENTFirst-run scaffold (DD-2): write a commented zuvo/infra/hosts.yaml template (the Data Model schema below), print "scaffolded zuvo/infra/hosts.yaml — edit it and re-run", and EXIT. Never invent hosts.

hosts.yaml scaffold (DD-2) — written verbatim on first run when no inventory exists:

# zuvo:infra-audit inventory — edit then re-run `zuvo:infra-audit`.
defaults:
  ssh_user: deploy
  ssh_port: 22
  # External-vantage source (pick ONE; scan_via takes precedence). scan_via is the
  # recommended + macOS-safe option: the external leg runs FROM this SSH host using
  # portable nc/openssl/curl — no proxychains, no local scanner install. proxy is the
  # SOCKS/HTTP path (needs proxychains-ng; does NOT work on macOS due to SIP).
  scan_via: bastion.example.com    # ssh target to scan from; overridable per host / $ZUVO_SCAN_VIA
  # proxy: socks5://127.0.0.1:1080 # alt external-scan proxy; overridable / $ZUVO_SCAN_PROXY
hosts:
  - name: web01                    # required, unique
    address: 203.0.113.10          # required (IP or DNS name)
    # ssh_user: root               # optional, overrides defaults
    # ssh_port: 22                 # optional
    # ssh_key: ~/.ssh/id_ed25519   # optional (else ssh config/agent); path only — key never read
    # jump_host: bastion.example.com  # optional → ProxyJump
    # roles: [web, docker, postgres]  # optional hints for dimension targeting
    # external_fqdn: example.com   # optional; target for IS4/IS8 external checks

Phase 0.4 — Inventory parse, dedup, resolve (E11)

Parse the inventory into a host list (name, address, ssh_user, ssh_port, ssh_key, jump_host, roles, external_fqdn). Then:

  1. Resolve each address → IP and reverse PTR.
  2. Duplicate-IP dedup (E11): if two entries resolve to the same IP, MERGE them into one target and print [WARN] duplicate IP <ip> — entries <a>,<b> merged; audited once. Audit the merged host exactly once.
  3. name must be unique (error on collision).
  4. No password fields exist in the schema; password auth must be pre-configured in ~/.ssh/config/agent. The skill never prompts for or stores SSH passwords (ssh-probe-protocol §4).

Phase 0.5 — AUTHORIZATION GATE (ssh-probe-protocol §1) — BLOCKING

This gate precedes any SSH connection. Display the resolved target list as a host→IP→PTR table and require explicit confirmation. Follow ssh-probe-protocol.md §1 exactly.

AUTHORIZATION GATE — these LIVE hosts will be audited over SSH:

  name     resolved-ip      PTR (reverse DNS)        ssh
  web01    203.0.113.10     web01.example.com.       deploy@203.0.113.10:22
  db01     203.0.113.20     db01.example.com.        deploy@203.0.113.20:22

Confirm you OWN and are AUTHORIZED to audit every host above. Connect? [y/N]
ScenarioAction
User confirms ("y")Proceed to Phase 0.6 (per-host probes).
User declines ("n" / anything not "y")ABORT — zero SSH connections opened, no run-dir writes beyond the Phase 0.1 mkdir + DD-10 line.
Non-interactive runAuthorization ONLY via --confirm-targets <sha256> matching the sha256 of the resolved target list (canonical serialization per ssh-probe-protocol §1).
--confirm-targets absent OR hash mismatch (non-interactive)ABORT immediately — re-resolve, recompute the canonical hash, and abort on any mismatch (DNS-TOCTOU recompute-and-abort). Target authorization is NEVER [AUTO-DECISION].

--dry-run prints the canonical target list AND its sha256 so the user can copy the hash into a subsequent --confirm-targets <sha256> invocation. [AUTO-DECISION] applies ONLY to low-risk defaults (E13 skip-external when no proxy), never to target authorization.

Phase 0.6 — Per-host probe sequence

For each authorized host, in order (a host failing any step writes bundle/<host>.phase0.json so Phase 3 still renders its per-host report — E4/AC8):

  1. Reachability (E5): nc -zw5 <ip> <port>. Fail → phase0.json status UNREACHABLE, fleet CONTINUES (AC1).
  2. SSH probe (E1): connect with the IC-8 flag string (ssh-probe-protocol §2). BatchMode=yes fails fast on key-auth failure → SKIPPED — key-auth-failed, never prompt for a password. Host-key unknown (first contact) → SKIPPED — host-key-unknown with the ssh-keyscan instruction.
  3. Host-key mismatch (E4): LC_ALL=C ssh; if stderr carries REMOTE HOST IDENTIFICATION HAS CHANGED → halt this host, write phase0.json reason host-key-mismatch with a CRITICAL HOST-KEY-MISMATCH finding, print ssh-keygen -R <address>. Fleet continues.
  4. Privilege probe (E3 / §3): sudo -n true → privilege_mode root | passwordless-sudo | limited-sudo | no-sudo. needs_sudo checks become insufficient-data when unprivileged (never ok).
  5. OS detection (E10 — Alpine branch): read /etc/os-release; if /etc/alpine-release exists → Alpine path: skip lynis, use apk-based checks (apk version, apk info) for patch posture, label lynis-sourced dimensions DEGRADED (alpine — lynis N/A). Containers-as-targets are audited via docker exec from the host, not SSH.
  6. Remote tool probe: which lynis nmap trivy grype debsecan needrestart docker ss → populate tool_availability (records absent tools as null).
  7. Consent-gated install (DD-3 / E14): for each missing tool, unless --no-install, offer installation with a per-host consent gate. Consent → install logged with its uninstall command (apt remove <tool>); decline or --no-install → affected dimension runs manual fallbacks, labeled DEGRADED. pgdsat dual consent (E14): pgdsat (IS10) requires BOTH the DD-3 install consent AND a separate query-consent (it runs SQL against the live DB); declining either → IS10 runs SSH-only config reads + checklist, labeled DEGRADED (pgdsat declined). pgdsat runs ON the host as the postgres OS user over SSH (never a laptop-direct DB connection).

state.json for each host: pending → collecting as Phase 1 begins.


Phase 1 — COLLECT (deterministic, scripts/infra-collect.sh, per host)

The collector is the only writer of the bundle; analysts are read-only consumers. One SSH session per host; long scans run via nohup with a .rc completion marker (ssh-probe-protocol §2). Secrets are redacted at the collector BEFORE any bundle/raw write (IC-5). Raw outputs are preserved under raw/ and NEVER fed to the LLM.

Collector invocation contract

Resolve the collector path with a glob fallback (Claude Code distributes it via install.sh's cp scripts/*.sh; the install-root path differs per platform):

COLLECTOR="$(command -v infra-collect.sh 2>/dev/null \
  || ls ~/.claude/plugins/cache/zuvo-marketplace/zuvo/*/scripts/infra-collect.sh \
       ~/.codex/scripts/infra-collect.sh ~/.cursor/scripts/infra-collect.sh 2>/dev/null | head -1)"
[ -n "$COLLECTOR" ] || { echo "ERROR: infra-collect.sh not found (install.sh not run?)"; exit 1; }

Invoke per host, passing ssh_key / known_hosts / proxy resolved from hosts.yaml (DD-2 schema). The collector applies the IC-8 SSH invariants itself.

"$COLLECTOR" \
  --host "$ssh_user@$ip:$ssh_port" \
  --ssh-key "$ssh_key" \                  # path only — key material never read (§4)
  --known-hosts "$known_hosts" \          # StrictHostKeyChecking stays =yes
  ${proxy:+--proxy "$proxy"} \            # IC-4: resolved --proxy > hosts.yaml default > $ZUVO_SCAN_PROXY
  ${scan_via:+--scan-via "$scan_via"} \   # IC-4: external leg from a remote SSH host (portable nc/openssl/curl; macOS-safe). resolved --scan-via > defaults.scan_via > $ZUVO_SCAN_VIA
  ${QUICK:+--quick} \
  ${DIMENSIONS:+--dimensions "$DIMENSIONS"} \
  ${NO_INSTALL:+--no-install} \
  ${DEEP_SCAN:+--deep-scan} \
  ${SKIP_EXTERNAL:+--skip-external} \
  ${EXTERNAL_MODE:+--external "$EXTERNAL_MODE"} \
  ${external_fqdn:+--external-target "$external_fqdn"} \   # IC-4: public surface scanned externally (else bare addr)
  --run-id "$RUN_ID" \
  --out "$RUN_DIR/bundle/$name.json" \
  --raw-dir "$RUN_DIR/raw"

The collector emits the IC-3 bundle: host, collected_at, privilege_mode, os, tool_availability (incl. grype), tools_installed_this_run, checks[] (each {id, dimension, status, evidence, source, raw_ref, needs_sudo}), and external.vantage. The collector DETECTS — it classifies each check into ok | finding | insufficient-data | skipped | error deterministically (DD-5/DD-8; empty output ≠ PASS). On preflight failure it writes bundle/<host>.phase0.json.

state.json: host collecting → analyzed-eligible once the bundle is written. Incremental bundle writes make the collect step resume-safe.


Phase 2 — ANALYZE (parallel LLM analyst agents, per layer)

Dispatch the four analysts in parallel. Each receives ONLY its layer's normalized bundle slice + the registry; each emits findings grounded on existing bundle.checks[].id (IC-3) and records bundle_sha256 of the source bundle.

Analyst dimension grouping

AgentFileDimensions
Host Analystagents/host-analyst.mdIS1, IS2, IS5, IS6, IS7, IS11
Network Analystagents/network-analyst.mdIS3, IS4, IS8
Container Analystagents/container-analyst.mdIS9
Data Analystagents/data-analyst.mdIS10, IS12

Agent dispatch

Refer to ../../shared/includes/env-compat.md for dispatch patterns per environment.

Claude Code (primary): use the Agent tool to run all four in parallel, once per host bundle:

Agent 1: Host Analyst
  model: "sonnet"
  type: "Explore"
  instructions: read agents/host-analyst.md
  input: path to bundle/<host>.json + the registry; writes findings/<host>-host.json

Agent 2: Network Analyst
  model: "sonnet"
  type: "Explore"
  instructions: read agents/network-analyst.md
  input: path to bundle/<host>.json + the registry; writes findings/<host>-network.json

Agent 3: Container Analyst
  model: "sonnet"
  type: "Explore"
  instructions: read agents/container-analyst.md
  input: path to bundle/<host>.json + the registry; writes findings/<host>-container.json

Agent 4: Data Analyst
  model: "sonnet"
  type: "Explore"
  instructions: read agents/data-analyst.md
  input: path to bundle/<host>.json + the registry; writes findings/<host>-data.json
<!-- PLATFORM:CODEX -->

Codex: define TOML agents per env-compat.md patterns; each runs in a read-only sandbox referencing agents/<name>.md.

<!-- /PLATFORM:CODEX --><!-- PLATFORM:CURSOR -->

Cursor / Antigravity: no agent spawning. Execute each analyst's procedure sequentially yourself, reading agents/<name>.md, preserving the identical findings/<host>-<layer>.json output format.

<!-- /PLATFORM:CURSOR -->

Degraded-dispatch fallback (env-compat)

Consecutive dispatch rate-limits = agent failure. If sub-agent dispatch returns a rate-limit / overloaded / quota error twice in a row for the same host's analysis stage, print [MODE SWITCH] dispatch rate-limited ×2 → single-agent, record reason same-model-fallback/rate-limited, and execute that host's four analyst roles inline (single-agent) per the checkpoint protocol. Do NOT spin retrying a rate-limited dispatch — fall back and keep moving (no-pause-protocol).

Already in a storm → go inline immediately (don't prolong it). If THIS run has already been auto-resumed by the StopFailure watchdog (i.e. you entered via a watchdog RESUME, or you've seen ≥1 API-error/rate-limit auto-resume this run), do NOT attempt the parallel 4-agent dispatch at all for the remaining hosts — print [MODE SWITCH] mid-storm → single-agent inline (skip dispatch) and run the analysts inline from the first host. Dispatching N×4 sub-agents into an active rate-limit storm just extends it; inline analysis over the already-written bundles is deterministic (severity is registry-driven — DD-7) and completes without burning more dispatch quota. This is the observed-2026-06-12 recovery path: 7 auto-resumes, recovered by inline analysis over the checkpointed bundles.

bundle_sha256 stale-findings guard (IC-3)

Each findings/<host>-<layer>.json records the bundle_sha256 of the bundle it was derived from. At aggregation OR resume time, if bundle_sha256 does not match the current bundle's shasum -a 256, the findings are stale — the mismatch forces re-analysis of that bundle (re-dispatch the analyst). Findings whose sha matches are trusted as-is.

state.json: host analyzed once all four findings files exist with matching sha.


Phase 3 — AGGREGATE (deterministic)

Load Stage 2 includes before writing any report file.

Phase 3 is deterministic — no LLM scoring. Collate the four findings files per host, then per the registry.

Phase 3.1 — Grounding gate (IC-3)

Every analyst finding MUST cite an existing bundle.checks[].id. Drop any finding whose check_id is not present in that host's bundle, logging it:

UNGROUNDED-FINDING dropped: <check_id> (host <name>) — not in bundle.checks[]

Phase 3.2 — IC-6 CVE grep gate

For every candidate finding, every CVE-\d{4}-\d+ string it carries MUST appear verbatim in that host's raw/ tool output (trivy/grype/debsecan/apt). Grep the raw dir; a CVE with no raw evidence is stripped and logged:

for cve in $(printf '%s\n' "$finding_cves" | grep -oE 'CVE-[0-9]{4}-[0-9]+' | sort -u); do
  if ! grep -rqF "$cve" "$RUN_DIR/raw/"; then
    echo "CVE-EVIDENCE-MISSING dropped: $cve (host $name) — not in raw/ tool output"
    # strip $cve from the finding; LLM version→CVE mapping is forbidden (IC-6/DD-6)
  fi
done

Phase 3.3 — Severity via registry (DD-7)

Severity is assigned ONLY by infra-check-registry.md lookup keyed on check_id. The analyst PROPOSED a severity; the registry DECIDES. Lynis defaults: WARNING → MEDIUM, SUGGESTION → LOW; escalation above MEDIUM requires an explicit registry row. Map to the unified vocabulary per severity-vocabulary.md (CRITICAL/HIGH/MEDIUM/LOW → S1/S2/S3/S4, identical to security-audit).

Phase 3.4 — Internal/external diff (firewall verdict)

Diff the internal listeners (ss -tulpn / loopback nmap) against the external vantage view (external.open_ports). The diff is the firewall-effectiveness proof. Verdict per external.vantage:

vantageIS3 firewall verdict
proxy / directfull diff: ports visible externally but not intended → finding
nonerules-only (rules read but not externally verified)
failedrules-only, IS4/IS8 DEGRADED (proxy-failed)

The collector's external block is: external: { vantage, proxy_used, open_ports, tls, nuclei_findings, notes }. notes is an array of degradation/abort messages (tool-absent skips, the DD-4 zero-open-ports nuclei abort, proxy/refused notes) — the network-analyst surfaces them so reduced external coverage is visible in the IS3/IS4/IS8 verdict, never silently masked by an empty open_ports/tls/nuclei_findings.

Phase 3.5 — Per-host report → fleet-summary written LAST

Write each per-host report (<host>.md + <host>.json) FIRST, then assemble fleet-summary.md LAST (a crash mid-run leaves no fleet-summary — its absence signals an incomplete run; per-host reports are immutable once written).

# fleet-summary is written AFTER every per-host report exists (written LAST).
write_fleet_summary "$RUN_DIR"/*.md > "$RUN_DIR/fleet-summary.md"

state.json: host analyzed → reported after its per-host report is written.

Per-host report header template

# infra-audit — <host>  (<resolved-ip>)
- host_status: OK | DEGRADED | UNREACHABLE | FAILED | SKIPPED
- privilege_mode: root | passwordless-sudo | limited-sudo | no-sudo
- coverage_mode: FULL | DEGRADED            # DEGRADED when any tool absent/declined/wall-clock
- external.vantage: proxy | direct | none | failed
- collected_at: <ISO-8601Z>   bundle_sha256: <sha>

## Tool Availability Block
| tool | available | source |
|------|-----------|--------|
| lynis | 3.1.1 | host | … |       # each missing tool listed; DEGRADED dimensions note their fallback

## Findings (severity via registry)
| severity | check_id | dimension | evidence | remediation (CIS) |
| ... |

## Coverage notes
- per-dimension fallback notes for every DEGRADED dimension

When any tool is absent / install declined / wall-clock-skipped, the per-host header MUST carry coverage_mode: DEGRADED (AC9) and the Tool Availability Block MUST list every missing tool with its per-dimension fallback note. A fully-covered host gets coverage_mode: FULL.

Fleet-summary template

# infra-audit fleet-summary  (<RUN_TS>)
| host | status | grade | critical | high | vantage | coverage_mode |
|------|--------|-------|----------|------|---------|---------------|
| web01 | OK | B | 0 | 2 | proxy | FULL |
| db01  | UNREACHABLE | - | - | - | - | - |     # unreachable hosts still listed (AC1)

Phase 3.6 — Backlog Persistence

This audit has no flag for it, so it files only the CRITICAL and HIGH findings of the run, after the fleet-summary is written, one backlog-add call per finding (../../shared/includes/backlog-protocol.md "Filing from a skill"; the bars and the dedupe are the protocol's, so compute no fingerprint and restate nothing). <level> is critical or high.

A Generic write per finding. A host has no repository file, so the record is a target record, which takes no anchor: ~/.zuvo/backlog-add --repo "$R" --class <C> --conf <N> --source infra-audit --target "$U" --evidence "$E" --severity <level> --title "$T". U is the host's address or name, without the user@ part. <C> is SEC for a security finding and CQ for an operational-hygiene finding with no security impact. E is the check id and the grounding line from the bundle, already redacted at the collector (IC-5); a secret or user data is never copied into a record (backlog-protocol.md "Filing from a skill", "The location rule", the secrets sentence), and U has no query string.

Assign "$R", "$U", "$E" and "$T" before each call, as "Free text goes through a variable" in that section says; an empty value you pass is a bug, and backlog-add refuses a missing required field (REFUSED missing-field:<f>). Report the B- or F-id each call prints and Recorded: N (backlog: a, ledger: b) ("The report line" in backlog-protocol.md), plus each NOT PERSISTED (<reason>) by name; on a non-zero exit act on the protocol's exit table, and never append by hand.

This audit does not commit: it stages nothing. It lists the wrote= paths backlog-add printed as Backlog writes: <path>, ... (Backlog writes: none when there are none), in its report for the user when it runs standalone, and in its report for the calling skill's closing commit to carry when another skill runs it (backlog-protocol.md "Filing from a skill", Committing what backlog-add wrote).


Resume Semantics (--resume <run-dir>)

state.json workflow status per host drives resume. Process states (pending|collecting|analyzed|reported|unreachable|failed) are distinct from the outcome vocabulary (OK|DEGRADED|UNREACHABLE|FAILED|SKIPPED, IC-2).

statusaction on resume
pendingrun full collection + analysis + report
collectingre-run collection from scratch (idempotent; a partial bundle is never trusted — overwrite)
analyzedskip collection AND analysis ONLY when all four findings/<host>-<layer>.json files exist AND each file's bundle_sha256 matches the current bundle (IC-3 stale-findings guard); on any bundle_sha256 mismatch the mismatch forces re-analysis from the bundle
reportedskip entirely; the per-host report file is untouched (mtime unchanged)
unreachableretry from pending (the cause may have been fixed)
failedretry from pending (the cause may have been fixed)

fleet-summary.md is regenerated from per-host reports at the end of every resumed run (always written LAST).


Edge Cases (E1-E15, condensed)

#CaseHandling
E1Key auth fails / password-only hostBatchMode=yes fails fast → SKIPPED — key-auth-failed; never prompt/store passwords
E2Jump host requiredjump_host → -o ProxyJump=; jump unreachable → UNREACHABLE — jump-host-failed
E3No sudosudo -n true probe; needs_sudo checks → insufficient-data, never ok (AC4)
E4Host-key mismatchCRITICAL HOST-KEY-MISMATCH, halt host, phase0.json, manual ssh-keygen -R; never auto-accept
E5Unreachable / typo'd addressnc -zw5 preflight → UNREACHABLE, fleet continues (AC1)
E6Missing remote toolsDD-3 consent gate → install or fallback matrix + DEGRADED
E7Long scans / dropped sessionIC-8 nohup .rc marker; incremental bundle; --resume
E8fail2ban / IDSExternal leg via scan_via (ban hits the scan host's IP, not the operator's) or proxy; --external direct → -T2 --max-rate 50 + abort threshold
E9Secrets in collected configsIC-5 redaction at collector + DD-10 gitignore preflight
E10Alpine / containers as targets/etc/alpine-release detection → skip lynis, use apk checks; containers via docker exec
E11Duplicate inventory IPsmerge + [WARN] duplicate IP, audit once
E12lynis < 3.0version probe → manual fallback + DEGRADED (lynis vX < 3.0)
E13No external vantage configuredDual-vantage is the default when a vantage resolves (scan_via / proxy): the skill runs internal AND external automatically. With NONE configured: ask once → --scan-via <ssh-host> (recommended, macOS-safe), --external direct (polite proxyless), or --skip-external; non-interactive → skip-external [AUTO-DECISION] (targets are NEVER auto-decided). On macOS a bare --proxy is steered to --scan-via (proxychains cannot inject under SIP).
E14pgdsat (IS10)runs ON host as postgres via SSH; requires BOTH install consent AND query consent; decline either → DEGRADED (pgdsat declined)
E15IS4 with no external_fqdn (bare IP)IS4 = insufficient-data (no external_fqdn) — never guessed from nginx configs

Completion gates (in order: retro → runlog → Run block)

VALIDITY GATE (REQUIRED — print BEFORE the Run line, AFTER retro append + append-runlog)

VALIDITY GATE
  collector_runs: [<N hosts> | NOT_RUN]
  authorization_gate: [confirmed-interactive | confirm-targets-sha-matched | DECLINED-ABORT]
  grounding_gate: [UNGROUNDED-FINDING dropped=<N>]
  cve_gate: [CVE-EVIDENCE-MISSING dropped=<N>]
  fleet_summary_written_last: [yes | NO]
  postamble:
    retros_log_appended: [yes(bytes_added=N) | NOT_APPENDED]
    retros_md_appended: [yes(entry_count=N) | NOT_APPENDED]
  gate_status: [PASS | FAIL]

If gate_status = FAIL → VERDICT = INCOMPLETE.

Retrospective (REQUIRED)

Follow the retrospective protocol from retrospective.md: gate check → structured questions → RETRO: TSV emit → retros.log + retros.md append. If the gate check skips: print RETRO: skipped (trivial session) and proceed. The retro is not done until both files are written.

Run line — append via the retro-gated wrapper (NOT direct >> runs.log)

printf '%b\n' "$RUN_LINE" | ~/.zuvo/append-runlog

Run: <ISO-8601-Z> infra-audit <project> <N-critical> <N-total> <VERDICT> <N-hosts> <dimensions-mode> <NOTES> <BRANCH> <SHA7> <INCLUDES> <TIER>

VERDICT: PASS (0 critical findings across the fleet), WARN (1-3 critical), FAIL (4+ critical).


INFRA-AUDIT COMPLETE

INFRA-AUDIT COMPLETE
Fleet: <N hosts> audited (<N OK> / <N DEGRADED> / <N UNREACHABLE> / <N FAILED>)
Findings: <N critical> / <N high> / <N total>  | Vantage: <proxy|direct|none|failed>
Run dir: zuvo/audits/infra-audit-<RUN_TS>/  (fleet-summary written last)
Result: PASS | WARN | FAIL

If fixable findings were detected, suggest the v2 follow-up:

Suggested next step (v2): zuvo:infra-fix is not yet available — remediation is
manual. Each per-host report lists the registry remediation + CIS reference and
(for consented installs) the exact uninstall command.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.