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
- SethGammon/Citadeltheir own version, first seen Nov 4, 2026
- mayankjain1815-commits/CITADELtheir own version, first seen Oct 13, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- SethGammon/Citadel★ 927same name
- mayankjain1815-commits/CITADEL★ 4same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository58 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- a11y-auditSkill
- agent-benchmarkSkill
- api-auditSkill
- architectureSkill
- backlogSkill
- benchmarkSkill
- brainstormSkill
- buildSkill
- canarySkill
- ci-auditSkill
- code-auditSkill
- container-auditSkill
- content-auditSkill
- content-expandSkill
- content-fixSkill
- content-migrationSkill
- context-auditSkill
- db-auditSkill
- debugSkill
- dependency-auditSkill
- deploySkill
- design-reviewSkill
- designSkill
- docsSkill
- env-auditSkill
- executeSkill
- fix-testsSkill
- geo-auditSkill
- geo-fixSkill
- incidentSkill
- leadsSkill
- mutation-testSkill
- pentestSkill
- performance-auditSkill
- planSkill
- presentationSkill
- profile-sessionSkill
- receive-reviewSkill
- refactor-radarSkill
- refactorSkill
- release-docsSkill
- retroSkill
- reviewSkill
- security-auditSkill
- seo-auditSkill
- seo-fixSkill
- shipSkill
- skill-evalSkill
- structure-auditSkill
- test-auditSkill
- tests-performanceSkill
- ui-design-teamSkill
- using-zuvoSkill
- worktreeSkill
- write-articleSkill
- write-e2eSkill
- write-testsSkill
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
- Distinctive: line 87
Read in two stages (pentest model).
- Distinctive: line 100
If any Stage 1 file is missing, STOP.
- Very specific: line 117
Read ../../shared/includes/env-compat.md for agent dispatch, path resolution, the [AUTO-DECISION] non-interactive contract, and the [MODE …
- Very specific: line 233
Write/update state.json after EACH host transitions (collecting → analyzed → reported) so the next auto-resume sees accurate progress.
- Very specific: line 238
Before writing ANY file under zuvo/, confirm zuvo/ is gitignored.
- Very specific: line 292
Duplicate-IP dedup (E11): if two entries resolve to the same IP, MERGE them into one target and print [WARN] duplicate IP <ip> — entries …
- Very specific: line 295
No password fields exist in the schema.
- Very specific: line 345
OS detection (E10 — Alpine branch): read /etc/os-release; if /etc/alpine-release exists → Alpine path: skip lynis, use apk-based checks …
- Very specific: line 371
BEFORE any bundle/raw write (IC-5).
- Distinctive: line 372
NEVER fed to the LLM.
- Distinctive: line 440
Claude Code (primary): use the Agent tool to run all four in parallel, once per host bundle:
- Very specific: line 475
Cursor / Antigravity: no agent spawning. Execute each analyst's procedure sequentially yourself, reading agents/<name>.md, preserving the …
- Very specific: line 482
Consecutive dispatch rate-limits = agent failure: If sub-agent dispatch returns a rate-limit / overloaded / quota error twice in a row for …
- Very specific: line 521
Every analyst finding MUST cite an existing bundle.checks[].id.
- Very specific: line 572
Write each per-host report (<host>.md + <host>.json) FIRST, then assemble fleet-summary.md LAST (a crash mid-run leaves no fleet-summary — …
- Very specific: line 606
When any tool is absent / install declined / wall-clock-skipped, the per-host header MUST carry coverage_mode: DEGRADED (AC9) and the Tool …
- Very specific: line 608
MUST list every missing tool with its per-dimension fallback note.
- Very specific: line 692
If gate_status = FAIL → VERDICT = INCOMPLETE.
- Very specific: line 696
Follow the retrospective protocol from retrospective.md: gate check → structured questions → RETRO: TSV emit → retros.log + retros.md …
- Distinctive: line 707
Run: <ISO-8601-Z> infra-audit <project> <N-critical> <N-total> <VERDICT> <N-hosts> <dimensions-mode> <NOTES> <BRANCH> <SHA7> <INCLUDES> …
- Distinctive: line 723
If fixable findings were detected, suggest the v2 follow-up:
≈ “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.
Files
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), andpentest(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 -> 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-markerPhase 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
fiOn 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"
fiThe 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)
| 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:
# 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 checksPhase 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. namemust 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.jsonstatusUNREACHABLE, fleet CONTINUES (AC1). - SSH probe (E1): connect with the IC-8 flag string (ssh-probe-protocol §2).
BatchMode=yesfails fast on key-auth failure →SKIPPED — key-auth-failed, never prompt for a password. Host-key unknown (first contact) →SKIPPED — host-key-unknownwith thessh-keyscaninstruction. - Host-key mismatch (E4):
LC_ALL=Cssh; if stderr carriesREMOTE HOST IDENTIFICATION HAS CHANGED→ halt this host, writephase0.jsonreasonhost-key-mismatchwith a CRITICALHOST-KEY-MISMATCHfinding, printssh-keygen -R <address>. Fleet continues. - Privilege probe (E3 / §3):
sudo -n true→privilege_moderoot | passwordless-sudo | limited-sudo | no-sudo.needs_sudochecks becomeinsufficient-datawhen unprivileged (neverok). - OS detection (E10 — Alpine branch): read
/etc/os-release; if/etc/alpine-releaseexists → Alpine path: skip lynis, useapk-based checks (apk version,apk info) for patch posture, label lynis-sourced dimensionsDEGRADED (alpine — lynis N/A). Containers-as-targets are audited viadocker execfrom the host, not SSH. - Remote tool probe:
which lynis nmap trivy grype debsecan needrestart docker ss→ populatetool_availability(records absent tools asnull). - 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, labeledDEGRADED. 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, labeledDEGRADED (pgdsat declined). pgdsat runs ON the host as thepostgresOS 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
| 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<!-- 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
donePhase 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).
# 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 dimensionWhen 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).
| 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-runlogRun: <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 | FAILIf 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.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
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.

