#!/usr/bin/env bash # πŸ€– Dependency-hygiene scan for the public security ledger # (web/status/security.html + web/status/security.json). # # govulncheck needs the Go toolchain, which the router box deliberately lacks β€” # so this runs WHERE THE BINARY IS BUILT (a dev machine or CI, wrapped around # deploy/build-linux.sh), scans the exact artifact the fleet will run # (govulncheck -mode=binary), and writes a REDACTED summary to # web/status/security.json. That file is COMMITTED data, like carbon.json: # the box's status publisher (deploy/status-publish.sh) only re-uploads it β€” # it never computes a scan, and needs no IAM or toolchain change. # # ===================== DISCLOSURE β€” NON-NEGOTIABLE ============================ # Everything this script writes to web/status/security.json is PUBLIC. The # page states security POSTURE, never a live exploit map: # # - The published block is a STATUS + COUNT only β€” "clean" or "N advisories # under remediation". NEVER the CVE/GHSA/OSV ids, affected packages, # module paths, versions, or call sites of anything UNPATCHED: naming an # unpatched hole is a responsible-disclosure violation and an attacker # roadmap. Once patched it's history and may be named elsewhere (e.g. a # changelog entry) β€” but never by this file while open. # - The FULL govulncheck report goes to the operator's terminal only (they # must see it to fix it) and dies with $TMP β€” it is never written into # the repo, never uploaded. # - The published keys are exactly: generated_at (ISO), scanned_sha (the # build sha, already public via the signed release feed), go_modules (a # count), dependencies.status ∈ {clean, attention}, # dependencies.advisories_under_remediation (an integer β‰₯ 0). The # contract test (test/status-contract.test.mjs) allowlists this shape and # greps for id-shaped strings; the summarizer below refuses to write an # output that carries one (belt on top of the by-construction shape). # - A clean scan publishes "clean" + 0 β€” the honest happy path, stated # plainly. No scan β†’ no file β†’ the page shows "scan pending", never a # fake clean (degrade, never lie). # ============================================================================== # # Usage: # deploy/security-scan.sh # build the linux binary (via # # deploy/build-linux.sh), scan it, # # write web/status/security.json # deploy/security-scan.sh --binary FILE # scan an already-built binary # deploy/security-scan.sh --dry-run DIR # fold a self-constructed # # govulncheck fixture (fake ids in # # every grammar we redact) into # # DIR/security.json β€” no toolchain, # # no network; the contract-test # # fixture path set -euo pipefail cd "$(dirname "$0")/.." # repo root; paths below are repo-relative OUT_JSON=${SECURITY_JSON:-web/status/security.json} TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT # summarize # Folds the stream into the redacted block. govulncheck -json emits a stream # of concatenated JSON objects; findings whose first trace frame names a # function are symbol-level ("affects the scanned binary" β€” govulncheck's own # headline set); module-level-only findings are informational and do NOT # count. Only the DISTINCT affecting-advisory count survives β€” every id, # package, and version dies here, and the script refuses to write an output # carrying an id-shaped string. summarize() { node --input-type=module - "$1" "$2" "$3" "$4" <<'NODE' import { readFileSync, writeFileSync } from "node:fs"; const [report, sha, modules, out] = process.argv.slice(2); if (!/^[0-9a-f]{7,40}$/.test(sha)) { console.error("summarize: sha must be a git sha, got " + sha); process.exit(1); } const mods = Number(modules); if (!Number.isInteger(mods) || mods <= 0) { console.error("summarize: module count must be a positive integer"); process.exit(1); } // Split the concatenated-JSON stream at top-level brace depth (string-aware β€” // a "{" inside a summary string must not open an object). const raw = readFileSync(report, "utf8"); const objs = []; let depth = 0, start = -1, inStr = false, esc = false; for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; } if (c === '"') inStr = true; else if (c === "{") { if (depth === 0) start = i; depth++; } else if (c === "}") { depth--; if (depth === 0) objs.push(JSON.parse(raw.slice(start, i + 1))); } } const affecting = new Set(), informational = new Set(); for (const o of objs) { const f = o.finding; if (!f || !f.osv) continue; const symbolLevel = Array.isArray(f.trace) && f.trace[0] && f.trace[0].function; (symbolLevel ? affecting : informational).add(f.osv); } for (const id of affecting) informational.delete(id); // FULL DETAIL β€” the operator's terminal only. Never written to the repo, // never uploaded (see the disclosure header). console.error("govulncheck: " + affecting.size + " advisory(ies) affect the scanned binary; " + informational.size + " module-level informational"); for (const id of [...affecting].sort()) { console.error(" UNPATCHED (local detail, never published): " + id); } const doc = { generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"), scanned_sha: sha, go_modules: mods, dependencies: { status: affecting.size > 0 ? "attention" : "clean", advisories_under_remediation: affecting.size, }, }; const text = JSON.stringify(doc, null, 2) + "\n"; // Redaction self-check: refuse to publish anything id-shaped, ever. if (/CVE-\d{4}-\d+|GHSA-|GO-\d{4}-\d+/.test(text)) { console.error("summarize: REDACTION FAILURE β€” an advisory id reached the output; refusing to write"); process.exit(1); } writeFileSync(out, text); console.log(text.trimEnd()); NODE } scan() { local bin=${1:-} command -v go >/dev/null 2>&1 || { echo "security-scan: needs the Go toolchain β€” the scan runs at build time, never on the box" >&2; exit 1; } command -v node >/dev/null 2>&1 || { echo "security-scan: needs node (the stream summarizer)" >&2; exit 1; } if [ -z "$bin" ]; then # Scan the artifact we actually ship: the linux binary, built exactly as # deploy/build-linux.sh builds it (same flags, same stamp). echo "security-scan: building the linux binary…" >&2 OUT="$TMP/lchat-scan" ./deploy/build-linux.sh >&2 bin="$TMP/lchat-scan" fi [ -f "$bin" ] || { echo "security-scan: no binary at $bin" >&2; exit 1; } local gvc gvc=$(command -v govulncheck || true) if [ -z "$gvc" ]; then echo "security-scan: installing govulncheck (golang.org/x/vuln)…" >&2 GOBIN="$TMP/bin" go install golang.org/x/vuln/cmd/govulncheck@latest gvc="$TMP/bin/govulncheck" fi # -json exits 0 even with findings (the fold decides the status); the raw # stream lives in $TMP and dies with it. echo "security-scan: scanning $bin against the Go vulnerability database…" >&2 "$gvc" -mode=binary -json "$bin" > "$TMP/govulncheck.json" local sha mods sha=$(git rev-parse --short HEAD) # The build list, main module excluded: every module the binary could draw # code from (direct + indirect). A count, not an inventory. mods=$(go list -m all | grep -c '') mods=$((mods - 1)) summarize "$TMP/govulncheck.json" "$sha" "$mods" "$OUT_JSON" echo "security-scan: wrote $OUT_JSON (redacted β€” commit it; the box republishes it as-is)" >&2 } dry_run() { local outdir=$1 mkdir -p "$outdir" # A self-constructed govulncheck -json stream: two advisories with # symbol-level findings (affecting β€” they drive the count) and one # module-level-only informational finding (must NOT count). The ids are # fixture markers in every grammar the redaction guards (GO-/CVE-/GHSA-), # and the fixture packages are .example paths β€” the self-check plus the # contract test prove none of them survives into the summary. cat > "$TMP/govulncheck.json" <<'EOF' {"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scan_mode":"binary"}} {"progress":{"message":"Scanning your binary for known vulnerabilities..."}} {"osv":{"id":"GO-2098-0001","aliases":["CVE-2098-11111","GHSA-ffff-gggg-hhhh"],"summary":"Fixture advisory one { must never publish }","affected":[{"package":{"name":"depone.example/fixture"}}]}} {"osv":{"id":"GO-2098-0002","aliases":["CVE-2098-22222"],"summary":"Fixture advisory two β€” must never publish","affected":[{"package":{"name":"deptwo.example/fixture"}}]}} {"osv":{"id":"GO-2098-0003","aliases":[],"summary":"Fixture advisory three β€” module-level only, informational","affected":[{"package":{"name":"depthree.example/fixture"}}]}} {"finding":{"osv":"GO-2098-0001","fixed_version":"v1.2.3","trace":[{"module":"depone.example/fixture","version":"v1.2.0","package":"depone.example/fixture","function":"Parse"}]}} {"finding":{"osv":"GO-2098-0001","fixed_version":"v1.2.3","trace":[{"module":"depone.example/fixture","version":"v1.2.0","package":"depone.example/fixture","function":"Decode"}]}} {"finding":{"osv":"GO-2098-0002","fixed_version":"v0.9.1","trace":[{"module":"deptwo.example/fixture","version":"v0.9.0","package":"deptwo.example/fixture","function":"Handle"}]}} {"finding":{"osv":"GO-2098-0003","trace":[{"module":"depthree.example/fixture","version":"v2.0.0"}]}} EOF summarize "$TMP/govulncheck.json" abc1234 42 "$outdir/security.json" # Self-checks: two affecting advisories fold to attention/2 (dup findings # dedupe by advisory; the informational one never counts), and nothing # id-shaped or fixture-marked reached the output. grep -q '"status": "attention"' "$outdir/security.json" && grep -q '"advisories_under_remediation": 2' "$outdir/security.json" || { echo "dry-run: expected status attention + count 2 from the fixture stream" >&2; exit 1; } if grep -qE 'CVE-|GHSA-|GO-2098|\.example|fixture' "$outdir/security.json"; then echo "dry-run: DISCLOSURE LEAK β€” an advisory id or package path reached the output" >&2 exit 1 fi echo "dry-run ok: redacted summary (attention, count only) at $outdir/security.json" } case "${1:-}" in --dry-run) [ -n "${2:-}" ] || { echo "usage: $0 --dry-run " >&2; exit 1; } dry_run "$2" ;; --binary) [ -n "${2:-}" ] || { echo "usage: $0 --binary " >&2; exit 1; } scan "$2" ;; "") scan ;; *) echo "usage: $0 [--binary | --dry-run ]" >&2; exit 1 ;; esac