| name | infra-audit |
| description | 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). |
| codesift_tools | {"always":["plan_turn","index_status"],"by_stack":{}} |
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
| Argument | Meaning |
|---|
[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 |
--quick | IS1 + IS3 (internal) + IS4 only; < 3 min/host target |
--dimensions IS1,IS3,… | Explicit dimension subset |
--no-install | Hard read-only: never offer tool installation (disables the DD-3 gate) |
--dry-run | Print 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 direct | Explicit opt-in to a proxyless external scan (polite timing -T2 --max-rate 50) |
--skip-external | Internal vantage only (no external leg) |
--deep-scan | nmap -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:
| Flag | Variable set |
|---|
--external <mode> (e.g. --external direct) | EXTERNAL_MODE=<mode> |
--skip-external | SKIP_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) |
--quick | QUICK=1 |
--dimensions <list> | DIMENSIONS=<list> |
--no-install | NO_INSTALL=1 |
--deep-scan | DEEP_SCAN=1 |
--external <mode> and --skip-external are mutually exclusive; error if both supplied.
Dimensions (IS1-IS12)
| ID | Dimension | Analyst |
|---|
| IS1 | SSH hardening (sshd_config) | host-analyst |
| IS2 | Accounts & auth | host-analyst |
| IS3 | Network exposure — dual-vantage | network-analyst |
| IS4 | TLS & certificates | network-analyst |
| IS5 | Firewall & kernel net | host-analyst |
| IS6 | Patch posture | host-analyst |
| IS7 | Logging & intrusion detection | host-analyst |
| IS8 | Deployed web services (safe tags only) | network-analyst |
| IS9 | Docker | container-analyst |
| IS10 | Database servers | data-analyst |
| IS11 | Filesystem & kernel hardening | host-analyst |
| IS12 | Secrets hygiene on host | data-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] (findings → backlog entries)
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
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.
_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
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"
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:
if [ -z "${RESUME_DIR:-}" ]; then
_now="$(date +%s)"; _fresh_window=1800
_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)"
[ $((_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 write outside RUN_DIR permitted by the whole run is this one-time
zuvo/ line (AC10 exemption).
Phase 0.3 — Target source resolution + first-run scaffold (DD-2)
| Source | Action |
|---|
--host user@addr[:port] | Single ad-hoc host; no inventory file. |
[path/to/hosts.yaml] | Parse the supplied inventory. |
| neither, default | Use zuvo/infra/hosts.yaml if present. |
neither, and zuvo/infra/hosts.yaml ABSENT | First-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:
defaults:
ssh_user: deploy
ssh_port: 22
scan_via: bastion.example.com
hosts:
- name: web01
address: 203.0.113.10
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:
- Resolve each
address → IP and reverse PTR.
- 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.
name must be unique (error on collision).
- 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]
| Scenario | Action |
|---|
| 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 run | Authorization 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):
- Reachability (E5):
nc -zw5 <ip> <port>. Fail → phase0.json status
UNREACHABLE, fleet CONTINUES (AC1).
- 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.
- 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.
- 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).
- 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.
- Remote tool probe:
which lynis nmap trivy grype debsecan needrestart docker ss
→ populate tool_availability (records absent tools as null).
- 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" \
--known-hosts "$known_hosts" \
${proxy:+--proxy "$proxy"} \
${scan_via:+--scan-via "$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"} \
--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
| Agent | File | Dimensions |
|---|
| Host Analyst | agents/host-analyst.md | IS1, IS2, IS5, IS6, IS7, IS11 |
| Network Analyst | agents/network-analyst.md | IS3, IS4, IS8 |
| Container Analyst | agents/container-analyst.md | IS9 |
| Data Analyst | agents/data-analyst.md | IS10, 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
Codex: define TOML agents per env-compat.md patterns; each runs in a
read-only sandbox referencing agents/<name>.md.
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.
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"
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:
| vantage | IS3 firewall verdict |
|---|
proxy / direct | full diff: ports visible externally but not intended → finding |
none | rules-only (rules read but not externally verified) |
failed | rules-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).
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)
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).
| status | action on resume |
|---|
pending | run full collection + analysis + report |
collecting | re-run collection from scratch (idempotent; a partial bundle is never trusted — overwrite) |
analyzed | skip 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 |
reported | skip entirely; the per-host report file is untouched (mtime unchanged) |
unreachable | retry from pending (the cause may have been fixed) |
failed | retry 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)
| # | Case | Handling |
|---|
| E1 | Key auth fails / password-only host | BatchMode=yes fails fast → SKIPPED — key-auth-failed; never prompt/store passwords |
| E2 | Jump host required | jump_host → -o ProxyJump=; jump unreachable → UNREACHABLE — jump-host-failed |
| E3 | No sudo | sudo -n true probe; needs_sudo checks → insufficient-data, never ok (AC4) |
| E4 | Host-key mismatch | CRITICAL HOST-KEY-MISMATCH, halt host, phase0.json, manual ssh-keygen -R; never auto-accept |
| E5 | Unreachable / typo'd address | nc -zw5 preflight → UNREACHABLE, fleet continues (AC1) |
| E6 | Missing remote tools | DD-3 consent gate → install or fallback matrix + DEGRADED |
| E7 | Long scans / dropped session | IC-8 nohup .rc marker; incremental bundle; --resume |
| E8 | fail2ban / IDS | External 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 |
| E9 | Secrets in collected configs | IC-5 redaction at collector + DD-10 gitignore preflight |
| E10 | Alpine / containers as targets | /etc/alpine-release detection → skip lynis, use apk checks; containers via docker exec |
| E11 | Duplicate inventory IPs | merge + [WARN] duplicate IP, audit once |
| E12 | lynis < 3.0 | version probe → manual fallback + DEGRADED (lynis vX < 3.0) |
| E13 | No external vantage configured | Dual-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). |
| E14 | pgdsat (IS10) | runs ON host as postgres via SSH; requires BOTH install consent AND query consent; decline either → DEGRADED (pgdsat declined) |
| E15 | IS4 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: infra-audit
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.