eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env bash
2# 🤖 Dependency-hygiene scan for the public security ledger
3# (web/status/security.html + web/status/security.json).
4#
5# govulncheck needs the Go toolchain, which the router box deliberately lacks —
6# so this runs WHERE THE BINARY IS BUILT (a dev machine or CI, wrapped around
7# deploy/build-linux.sh), scans the exact artifact the fleet will run
8# (govulncheck -mode=binary), and writes a REDACTED summary to
9# web/status/security.json. That file is COMMITTED data, like carbon.json:
10# the box's status publisher (deploy/status-publish.sh) only re-uploads it —
11# it never computes a scan, and needs no IAM or toolchain change.
12#
13# ===================== DISCLOSURE — NON-NEGOTIABLE ============================
14# Everything this script writes to web/status/security.json is PUBLIC. The
15# page states security POSTURE, never a live exploit map:
16#
17# - The published block is a STATUS + COUNT only — "clean" or "N advisories
18# under remediation". NEVER the CVE/GHSA/OSV ids, affected packages,
19# module paths, versions, or call sites of anything UNPATCHED: naming an
20# unpatched hole is a responsible-disclosure violation and an attacker
21# roadmap. Once patched it's history and may be named elsewhere (e.g. a
22# changelog entry) — but never by this file while open.
23# - The FULL govulncheck report goes to the operator's terminal only (they
24# must see it to fix it) and dies with $TMP — it is never written into
25# the repo, never uploaded.
26# - The published keys are exactly: generated_at (ISO), scanned_sha (the
27# build sha, already public via the signed release feed), go_modules (a
28# count), dependencies.status ∈ {clean, attention},
29# dependencies.advisories_under_remediation (an integer ≥ 0). The
30# contract test (test/status-contract.test.mjs) allowlists this shape and
31# greps for id-shaped strings; the summarizer below refuses to write an
32# output that carries one (belt on top of the by-construction shape).
33# - A clean scan publishes "clean" + 0 — the honest happy path, stated
34# plainly. No scan → no file → the page shows "scan pending", never a
35# fake clean (degrade, never lie).
36# ==============================================================================
37#
38# Usage:
39# deploy/security-scan.sh # build the linux binary (via
40# # deploy/build-linux.sh), scan it,
41# # write web/status/security.json
42# deploy/security-scan.sh --binary FILE # scan an already-built binary
43# deploy/security-scan.sh --dry-run DIR # fold a self-constructed
44# # govulncheck fixture (fake ids in
45# # every grammar we redact) into
46# # DIR/security.json — no toolchain,
47# # no network; the contract-test
48# # fixture path
49set -euo pipefail
50
51cd "$(dirname "$0")/.." # repo root; paths below are repo-relative
52
53OUT_JSON=${SECURITY_JSON:-web/status/security.json}
54
55TMP=$(mktemp -d)
56trap 'rm -rf "$TMP"' EXIT
57
58# summarize <govulncheck-json-stream> <sha> <module-count> <outfile>
59# Folds the stream into the redacted block. govulncheck -json emits a stream
60# of concatenated JSON objects; findings whose first trace frame names a
61# function are symbol-level ("affects the scanned binary" — govulncheck's own
62# headline set); module-level-only findings are informational and do NOT
63# count. Only the DISTINCT affecting-advisory count survives — every id,
64# package, and version dies here, and the script refuses to write an output
65# carrying an id-shaped string.
66summarize() {
67 node --input-type=module - "$1" "$2" "$3" "$4" <<'NODE'
68import { readFileSync, writeFileSync } from "node:fs";
69const [report, sha, modules, out] = process.argv.slice(2);
70
71if (!/^[0-9a-f]{7,40}$/.test(sha)) { console.error("summarize: sha must be a git sha, got " + sha); process.exit(1); }
72const mods = Number(modules);
73if (!Number.isInteger(mods) || mods <= 0) { console.error("summarize: module count must be a positive integer"); process.exit(1); }
74
75// Split the concatenated-JSON stream at top-level brace depth (string-aware —
76// a "{" inside a summary string must not open an object).
77const raw = readFileSync(report, "utf8");
78const objs = [];
79let depth = 0, start = -1, inStr = false, esc = false;
80for (let i = 0; i < raw.length; i++) {
81 const c = raw[i];
82 if (inStr) {
83 if (esc) esc = false;
84 else if (c === "\\") esc = true;
85 else if (c === '"') inStr = false;
86 continue;
87 }
88 if (c === '"') inStr = true;
89 else if (c === "{") { if (depth === 0) start = i; depth++; }
90 else if (c === "}") { depth--; if (depth === 0) objs.push(JSON.parse(raw.slice(start, i + 1))); }
91}
92
93const affecting = new Set(), informational = new Set();
94for (const o of objs) {
95 const f = o.finding;
96 if (!f || !f.osv) continue;
97 const symbolLevel = Array.isArray(f.trace) && f.trace[0] && f.trace[0].function;
98 (symbolLevel ? affecting : informational).add(f.osv);
99}
100for (const id of affecting) informational.delete(id);
101
102// FULL DETAIL — the operator's terminal only. Never written to the repo,
103// never uploaded (see the disclosure header).
104console.error("govulncheck: " + affecting.size + " advisory(ies) affect the scanned binary; "
105 + informational.size + " module-level informational");
106for (const id of [...affecting].sort()) {
107 console.error(" UNPATCHED (local detail, never published): " + id);
108}
109
110const doc = {
111 generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
112 scanned_sha: sha,
113 go_modules: mods,
114 dependencies: {
115 status: affecting.size > 0 ? "attention" : "clean",
116 advisories_under_remediation: affecting.size,
117 },
118};
119const text = JSON.stringify(doc, null, 2) + "\n";
120// Redaction self-check: refuse to publish anything id-shaped, ever.
121if (/CVE-\d{4}-\d+|GHSA-|GO-\d{4}-\d+/.test(text)) {
122 console.error("summarize: REDACTION FAILURE — an advisory id reached the output; refusing to write");
123 process.exit(1);
124}
125writeFileSync(out, text);
126console.log(text.trimEnd());
127NODE
128}
129
130scan() {
131 local bin=${1:-}
132 command -v go >/dev/null 2>&1 ||
133 { echo "security-scan: needs the Go toolchain — the scan runs at build time, never on the box" >&2; exit 1; }
134 command -v node >/dev/null 2>&1 ||
135 { echo "security-scan: needs node (the stream summarizer)" >&2; exit 1; }
136
137 if [ -z "$bin" ]; then
138 # Scan the artifact we actually ship: the linux binary, built exactly as
139 # deploy/build-linux.sh builds it (same flags, same stamp).
140 echo "security-scan: building the linux binary…" >&2
141 OUT="$TMP/lchat-scan" ./deploy/build-linux.sh >&2
142 bin="$TMP/lchat-scan"
143 fi
144 [ -f "$bin" ] || { echo "security-scan: no binary at $bin" >&2; exit 1; }
145
146 local gvc
147 gvc=$(command -v govulncheck || true)
148 if [ -z "$gvc" ]; then
149 echo "security-scan: installing govulncheck (golang.org/x/vuln)…" >&2
150 GOBIN="$TMP/bin" go install golang.org/x/vuln/cmd/govulncheck@latest
151 gvc="$TMP/bin/govulncheck"
152 fi
153
154 # -json exits 0 even with findings (the fold decides the status); the raw
155 # stream lives in $TMP and dies with it.
156 echo "security-scan: scanning $bin against the Go vulnerability database…" >&2
157 "$gvc" -mode=binary -json "$bin" > "$TMP/govulncheck.json"
158
159 local sha mods
160 sha=$(git rev-parse --short HEAD)
161 # The build list, main module excluded: every module the binary could draw
162 # code from (direct + indirect). A count, not an inventory.
163 mods=$(go list -m all | grep -c '')
164 mods=$((mods - 1))
165
166 summarize "$TMP/govulncheck.json" "$sha" "$mods" "$OUT_JSON"
167 echo "security-scan: wrote $OUT_JSON (redacted — commit it; the box republishes it as-is)" >&2
168}
169
170dry_run() {
171 local outdir=$1
172 mkdir -p "$outdir"
173 # A self-constructed govulncheck -json stream: two advisories with
174 # symbol-level findings (affecting — they drive the count) and one
175 # module-level-only informational finding (must NOT count). The ids are
176 # fixture markers in every grammar the redaction guards (GO-/CVE-/GHSA-),
177 # and the fixture packages are .example paths — the self-check plus the
178 # contract test prove none of them survives into the summary.
179 cat > "$TMP/govulncheck.json" <<'EOF'
180{"config":{"protocol_version":"v1.0.0","scanner_name":"govulncheck","scan_mode":"binary"}}
181{"progress":{"message":"Scanning your binary for known vulnerabilities..."}}
182{"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"}}]}}
183{"osv":{"id":"GO-2098-0002","aliases":["CVE-2098-22222"],"summary":"Fixture advisory two — must never publish","affected":[{"package":{"name":"deptwo.example/fixture"}}]}}
184{"osv":{"id":"GO-2098-0003","aliases":[],"summary":"Fixture advisory three — module-level only, informational","affected":[{"package":{"name":"depthree.example/fixture"}}]}}
185{"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"}]}}
186{"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"}]}}
187{"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"}]}}
188{"finding":{"osv":"GO-2098-0003","trace":[{"module":"depthree.example/fixture","version":"v2.0.0"}]}}
189EOF
190 summarize "$TMP/govulncheck.json" abc1234 42 "$outdir/security.json"
191
192 # Self-checks: two affecting advisories fold to attention/2 (dup findings
193 # dedupe by advisory; the informational one never counts), and nothing
194 # id-shaped or fixture-marked reached the output.
195 grep -q '"status": "attention"' "$outdir/security.json" &&
196 grep -q '"advisories_under_remediation": 2' "$outdir/security.json" ||
197 { echo "dry-run: expected status attention + count 2 from the fixture stream" >&2; exit 1; }
198 if grep -qE 'CVE-|GHSA-|GO-2098|\.example|fixture' "$outdir/security.json"; then
199 echo "dry-run: DISCLOSURE LEAK — an advisory id or package path reached the output" >&2
200 exit 1
201 fi
202 echo "dry-run ok: redacted summary (attention, count only) at $outdir/security.json"
203}
204
205case "${1:-}" in
206 --dry-run) [ -n "${2:-}" ] || { echo "usage: $0 --dry-run <outdir>" >&2; exit 1; }
207 dry_run "$2" ;;
208 --binary) [ -n "${2:-}" ] || { echo "usage: $0 --binary <file>" >&2; exit 1; }
209 scan "$2" ;;
210 "") scan ;;
211 *) echo "usage: $0 [--binary <file> | --dry-run <outdir>]" >&2; exit 1 ;;
212esac
213