web+server: public security ledger (posture, not an exploit map) (#781)
* web+server: public security ledger — third status page (security.html) with a counted-not-named hygiene block The status trio gains a security page: the E2EE commitment (the #550 Part 12 invariants in member language), dependency hygiene, box/service hardening as facts, and the deliberate architectural edges honestly named. The disclosure line is load-bearing and commented at every layer: unpatched findings publish as a STATUS + COUNT only — never CVE/GHSA/OSV ids, packages, or code locations (that would be an attacker roadmap); once patched it's history. No box-locating details publish, ever. - deploy/security-scan.sh: govulncheck -mode=binary against the exact shipped linux binary, run at BUILD time (the box has no Go toolchain); folds the stream to the redacted web/status/security.json (committed data, carbon.json-style), refuses to write an output carrying an id-shaped string, keeps full findings on the operator's terminal only. --dry-run folds a fixture stream full of fake ids and self-checks the redaction — the committed contract-test fixture path. - deploy/status-publish.sh: uploads security.html + security.json like the other pages; an absent security.json is honest (the page renders 'scan pending', never a fake clean). - web/status/security.html: self-contained, same validated palette, textContent-only rendering, degrade-never-lie; FIELDS manifest. - Fleet · Books · Security nav row added to all three pages. - test/status-contract.test.mjs: allowlists the hygiene schema, requires status/count agreement, greps every advisory-id grammar (GO-/CVE-/ GHSA-) across the dry-run fixture AND the committed real scan, pins the dirty-path fold (attention + 2 from a 3-advisory fixture stream). - web/status/security.json: the real first scan of main (c2236e8) — honestly 'attention'; details stayed on the terminal. Note: security@elevenmessenger.com is the published reporting address — the mailbox must exist before this page ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server: clear govulncheck advisories — toolchain go1.25.12 + aws-sdk-go-v2 bump The security ledger's first scan flagged 24 (23 Go stdlib from the pinned go1.25.0, 1 in aws-sdk-go-v2 eventstream). Patched toolchain + module bump → govulncheck clean. Remediate before publishing, not after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web+server: public security ledger — commitment, hygiene, hardening, known edges Third status page (fleet · books · security). Posture, never an exploit map: dependency findings publish as a count until patched (responsible disclosure), enforced by a summarizer that refuses to write CVE/GHSA/GO ids + a contract-test grep. govulncheck runs at build (deploy/security- scan.sh) against the deployed binary; the box only uploads the redacted json. First scan is clean (0) on the toolchain-bumped build. Hardening is posture-only facts; known-edges names the deliberate CLAUDE.md tradeoffs. Reports: security@elevenmessenger.com (mailbox must exist). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+750
−41
deploy/security-scan.sh+212 −0deploy/status-publish.sh+17 −2docs/status.md+42 −4go.mod+13 −11go.sum+22 −22test/status-contract.test.mjs+75 −2test/status-fixtures/security.json+9 −0web/status/books.html+4 −0web/status/index.html+4 −0web/status/security.html+343 −0web/status/security.json+9 −0
diff --git a/deploy/security-scan.sh b/deploy/security-scan.sh| new file mode 100755 |
| index 0000000..0a27a89 |
| --- /dev/null |
| +++ b/deploy/security-scan.sh |
| @@ -0,0 +1,212 @@ |
| +#!/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 <govulncheck-json-stream> <sha> <module-count> <outfile> |
| +# 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 <outdir>" >&2; exit 1; } |
| + dry_run "$2" ;; |
| + --binary) [ -n "${2:-}" ] || { echo "usage: $0 --binary <file>" >&2; exit 1; } |
| + scan "$2" ;; |
| + "") scan ;; |
| + *) echo "usage: $0 [--binary <file> | --dry-run <outdir>]" >&2; exit 1 ;; |
| +esac |
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index 55ccfb3..de70942 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -56,6 +56,14 @@ |
| # republished as-is. Public GitHub identities + commit counts only; |
| # contributors own their row (bio, hidden) in the committed JSON. |
| # |
| +# security.json — also COMMITTED data (deploy/security-scan.sh, run at |
| +# BUILD time where the Go toolchain lives — this box never computes a |
| +# scan), republished as-is. It is REDACTED BY CONSTRUCTION: a status word, |
| +# an advisory COUNT, a module count, the build sha, a timestamp — never a |
| +# CVE/GHSA/OSV id, package, or code location of anything unpatched (that |
| +# would be an attacker roadmap; see the scan script's disclosure header). |
| +# Absent file → the page shows "scan pending", never a fake clean. |
| +# |
| # Adding a field? It must be an aggregate over the whole fleet / all payers, |
| # or it doesn't ship. |
| # ============================================================================== |
| @@ -94,7 +102,8 @@ |
| # echoed — plus the numeric account id). |
| # /opt/lchat/status-pages/ — the viewer pages, copied from web/status/ |
| # (index.html, books.html, carbon.html, carbon.json, |
| -# contributors.html, contributors.json, icon-192.png). |
| +# contributors.html, contributors.json, security.html, |
| +# security.json, icon-192.png). |
| # aws CLI + sqlite3 (apt install awscli sqlite3). |
| set -euo pipefail |
| @@ -886,6 +895,12 @@ publish() { |
| [ -f "$PAGES_DIR/carbon.json" ] && upload "$PAGES_DIR/carbon.json" carbon.json "application/json" "max-age=300" |
| [ -f "$PAGES_DIR/contributors.html" ] && upload "$PAGES_DIR/contributors.html" contributors.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/contributors.json" ] && upload "$PAGES_DIR/contributors.json" contributors.json "application/json" "max-age=300" |
| + # The security ledger: page + committed, build-time-scanned, REDACTED JSON |
| + # (deploy/security-scan.sh — see the header). A missing security.json is |
| + # honest: the page renders "scan pending" rather than a fake clean, so the |
| + # page uploads even when the JSON hasn't been staged. |
| + [ -f "$PAGES_DIR/security.html" ] && upload "$PAGES_DIR/security.html" security.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/security.json" ] && upload "$PAGES_DIR/security.json" security.json "application/json" "max-age=300" |
| [ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400" |
| else |
| echo "warning: $PAGES_DIR missing — JSONs published, pages skipped (copy web/status/* there)" >&2 |
| @@ -947,7 +962,7 @@ EOF |
| systemctl daemon-reload |
| systemctl enable --now lchat-status.timer |
| echo "installed — verify with: systemctl list-timers lchat-status.timer; journalctl -u lchat-status -n 20" |
| - echo "remember: copy web/status/{index.html,books.html,icon-192.png} to $PAGES_DIR" |
| + echo "remember: copy web/status/* (pages, committed JSONs, icon-192.png) to $PAGES_DIR" |
| } |
| # ---- dry-run: self-constructed fixtures, real aggregation code ---------------- |
diff --git a/docs/status.md b/docs/status.md| index b1bd90b..e6ede84 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -1,6 +1,6 @@ |
| -# 🤖 Public status dashboards — fleet, books, carbon & contributors |
| +# 🤖 Public status dashboards — fleet, books, security, carbon & contributors |
| -Three world-readable pages, published every 5 minutes from the router box: the |
| +World-readable pages, published every 5 minutes from the router box: the |
| running half of #550 Part 11/12's transparency direction (the append-only |
| public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| @@ -32,6 +32,18 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| bios seed from the GitHub profile, then edits to the committed JSON win — |
| customise your `bio` (or set `"hidden": true` to leave the index) by PR, and |
| the tool preserves those fields on every refresh. |
| +- **The security ledger** (`security.html` + `security.json`) — posture, never |
| + a live exploit map. Four sections: the E2EE commitment (the #550 Part 12 |
| + invariants in member language), dependency hygiene (govulncheck against the |
| + exact shipped binary — **status + count only**, see the disclosure rule |
| + below), box/service hardening as a checklist of facts, and the deliberate |
| + architectural edges (link previews, GIF search, plaintext branding, |
| + bots-as-members, the content-blind-store trade), honestly named. The JSON is |
| + **committed data** written at *build* time by `deploy/security-scan.sh` |
| + (govulncheck needs the Go toolchain, which the box deliberately lacks); the |
| + box republishes it as-is, and an absent `security.json` renders as "scan |
| + pending" — never a fake clean. Vulnerability reports: |
| + security@elevenmessenger.com (that mailbox is part of the page's contract). |
| Pieces: generator `deploy/status-publish.sh` (box timer, mirrors |
| `deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM |
| @@ -75,6 +87,18 @@ aggregation boundary lives in the generator and holds by construction: |
| - **Carbon: aggregate token counts and kg estimates only.** Never per-session |
| data, never prompt or transcript content — the tally reads token *usage |
| numbers* from local transcripts and publishes only their sums. |
| +- **Security: status + count, never a name — the disclosure line.** The |
| + ledger publishes posture: dependency findings ship as `clean` or "N |
| + advisories under remediation" — **never** the CVE/GHSA/OSV ids, affected |
| + packages, versions, or code locations of anything *unpatched* (naming an |
| + open hole is a responsible-disclosure violation and an attacker roadmap; |
| + once patched it's history and may be named, e.g. in a changelog). No secret |
| + inventory, no internal component versions beyond the build sha the signed |
| + release feed already publishes, and nothing that locates a box — hardening |
| + publishes as facts ("firewall default-deny", "SSH key-only"), never |
| + addresses. The full govulncheck report stays on the operator's terminal; |
| + the summarizer refuses to write an output containing an id-shaped string, |
| + and the contract test greps for every id grammar. |
| - Adding a field? It must be an aggregate over the whole fleet / all payers, |
| or it doesn't ship. The `--dry-run` self-check and the contract test both |
| fail if a fixture hostname or per-payer id reaches the output. |
| @@ -284,7 +308,7 @@ carries the sources and the "as of" date). |
| completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…` |
| (a DNSimple account API token — read-only use here, never echoed — plus |
| the numeric account id). `chown lchat:lchat`, `chmod 0600`. |
| -3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,icon-192.png} |
| +3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,icon-192.png} |
| root@<box>:/opt/lchat/status-pages/`. |
| 4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/` |
| then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs |
| @@ -321,6 +345,15 @@ origin unchanged. |
| `STATUS_DRY_EXPENSES=hetzner-constant` sets the operator constant with the |
| fixture still resolvable (asserts the constant wins and the API is never |
| called). |
| +- `./deploy/security-scan.sh` (run at build time, alongside |
| + `deploy/build-linux.sh`) builds and scans the shipped linux binary with |
| + govulncheck (`-mode=binary`; installed on the fly if absent) and writes the |
| + **redacted** `web/status/security.json` — commit it with the release. Full |
| + findings print to the terminal only. `--dry-run <dir>` folds a |
| + self-constructed govulncheck fixture (fake GO-/CVE-/GHSA- ids, a |
| + module-level informational that must not count) into `<dir>/security.json` |
| + and fails on any id or package path surviving — regenerate the committed |
| + test fixture with `./deploy/security-scan.sh --dry-run test/status-fixtures`. |
| - `node test/status-contract.test.mjs` (in `npm test`) pins the pages' FIELDS |
| manifests against the dry-run fixtures in `test/status-fixtures/` — if the |
| generator's shape and the pages drift, it fails. It also allowlists the |
| @@ -332,4 +365,9 @@ origin unchanged. |
| `books-hetzner-constant.json`). Regenerate fixtures from a fresh dry-run |
| after changing the generator (the per-band ones via `STATUS_DRY_CAPACITY`, |
| the no-expenses one via `STATUS_DRY_EXPENSES=omit`, the hetzner-constant |
| - one via `STATUS_DRY_EXPENSES=hetzner-constant`). |
| + one via `STATUS_DRY_EXPENSES=hetzner-constant`). For the security ledger it |
| + allowlists the hygiene schema (status word + integer counts + ISO timestamp |
| + + build sha, nothing else), requires status and count to agree, and greps |
| + both the dry-run fixture and the committed `web/status/security.json` for |
| + advisory ids in every grammar (`GO-`, `CVE-`, `GHSA-`) — the redaction |
| + guard. |
diff --git a/go.mod b/go.mod| index e03c8e3..8f60392 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -2,11 +2,13 @@ module lchat |
| go 1.25.0 |
| +toolchain go1.25.12 |
| + |
| require ( |
| github.com/SherClockHolmes/webpush-go v1.4.0 |
| - github.com/aws/aws-sdk-go-v2 v1.41.0 |
| + github.com/aws/aws-sdk-go-v2 v1.42.1 |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 |
| + github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| github.com/benbjohnson/litestream v0.5.14 |
| github.com/coder/websocket v1.8.12 |
| github.com/go-webauthn/webauthn v0.11.2 |
| @@ -16,23 +18,23 @@ require ( |
| ) |
| require ( |
| - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect |
| + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect |
| - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect |
| - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect |
| - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect |
| - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect |
| - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect |
| + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect |
| + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect |
| + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect |
| + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect |
| + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect |
| github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect |
| github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect |
| github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect |
| - github.com/aws/smithy-go v1.24.0 // indirect |
| + github.com/aws/smithy-go v1.27.3 // indirect |
| github.com/beorn7/perks v1.0.1 // indirect |
| github.com/cespare/xxhash/v2 v2.3.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
diff --git a/go.sum b/go.sum| index f7e69d6..d7d1cc3 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -20,10 +20,10 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1 |
| github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= |
| github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y= |
| github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= |
| -github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= |
| -github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= |
| -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= |
| -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= |
| +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= |
| +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= |
| +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= |
| +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= |
| github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= |
| github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= |
| github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= |
| @@ -32,24 +32,24 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBU |
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk= |
| github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= |
| -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= |
| -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= |
| +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= |
| +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= |
| github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= |
| -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= |
| -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= |
| -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= |
| -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= |
| -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= |
| -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= |
| -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= |
| -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= |
| -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= |
| -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= |
| -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0= |
| -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= |
| +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= |
| +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= |
| +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= |
| +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= |
| +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= |
| +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14= |
| +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= |
| +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= |
| +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= |
| +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= |
| +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU= |
| +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= |
| github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= |
| github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= |
| github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= |
| @@ -58,8 +58,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= |
| github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= |
| -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= |
| -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= |
| +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= |
| +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= |
| github.com/benbjohnson/litestream v0.5.14 h1:pu2RCUfEs2MfAvxSttNg/kVVzJspqg2sX/pPW3Elzxc= |
| github.com/benbjohnson/litestream v0.5.14/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI= |
| github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index a277452..4f57140 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -9,6 +9,16 @@ |
| // shape (books-unavailable.json, matching books_unavailable()). If this |
| // fails, page and generator have drifted — fix whichever side changed, then |
| // the fixtures. |
| +// |
| +// The security ledger rides the same pattern with its own generator: |
| +// test/status-fixtures/security.json is deploy/security-scan.sh's --dry-run |
| +// output (the ATTENTION shape, folded from a fixture govulncheck stream full |
| +// of fake CVE/GHSA/GO ids — regenerate with |
| +// `./deploy/security-scan.sh --dry-run test/status-fixtures`), and |
| +// web/status/security.json is the committed real scan. Both must satisfy the |
| +// redaction contract below: status + counts + sha + timestamp, NEVER an |
| +// advisory id or package path (an unpatched-vuln inventory is an attacker |
| +// roadmap — see the scan script's disclosure header). |
| // node test/status-contract.test.mjs |
| import assert from "node:assert/strict"; |
| import { readFileSync } from "node:fs"; |
| @@ -75,6 +85,16 @@ const carbonHtml = read("web/status/carbon.html"); |
| // The contributors index is COMMITTED data too (tools/contributors.mjs). |
| const contributors = JSON.parse(read("web/status/contributors.json")); |
| const contributorsHtml = read("web/status/contributors.html"); |
| +// The security ledger: the dry-run fixture (attention shape — proves the |
| +// dirty path redacts) plus, when present, the committed real scan. The real |
| +// file may legitimately be absent (the page then shows "scan pending", never |
| +// a fake clean), so it's checked only when it exists. |
| +const securityFix = JSON.parse(read("test/status-fixtures/security.json")); |
| +const securityHtml = read("web/status/security.html"); |
| +let securityLive = null; |
| +try { securityLive = JSON.parse(read("web/status/security.json")); } catch { /* absent = pending, honest */ } |
| +const securityShapes = [["security fixture", securityFix]] |
| + .concat(securityLive ? [["committed security.json", securityLive]] : []); |
| await test("fleet page fields all exist in the generator's fleet.json", () => { |
| for (const f of fields(indexHtml)) { |
| @@ -101,6 +121,14 @@ await test("contributors page fields all exist in the committed contributors.jso |
| } |
| }); |
| +await test("security page fields all exist in the scanner's security.json", () => { |
| + for (const [name, s] of securityShapes) { |
| + for (const f of fields(securityHtml)) { |
| + assert.notEqual(resolve(s, f), undefined, name + " is missing " + f); |
| + } |
| + } |
| +}); |
| + |
| // Contributor entries stay well-formed as the tool regenerates them: known |
| // kinds, positive commit counts, sorted by the metric, humans carry a profile |
| // link + self-contained data-URI avatar (the pages allow no external hosts), |
| @@ -125,7 +153,7 @@ await test("contributors entries are well-formed, sorted, and email-free", () => |
| // A manifest entry nobody reads is drift too: each leaf property must appear |
| // in the page's render code, not just the manifest. |
| await test("every declared field is actually read by its page", () => { |
| - for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)]]) { |
| + for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)], [securityHtml, fields(securityHtml)]]) { |
| const body = html.slice(html.indexOf("];") + 2); |
| for (const f of fs) { |
| const leaf = f.split(".").pop().replace("[]", ""); |
| @@ -146,12 +174,57 @@ await test("degraded books shape carries available:false + reason", () => { |
| // customer, transaction, or provider-account/workspace id may appear in |
| // published output. |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| - for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, contributors]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, contributors, ...securityShapes.map(([, s]) => s)]) { |
| const s = JSON.stringify(j); |
| assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
| }); |
| +// ---- the security ledger: redacted by contract -------------------------------- |
| +// The disclosure line (deploy/security-scan.sh header): the published block is |
| +// a STATUS + COUNT only. The schema is an ALLOWLIST — any extra key fails, so |
| +// an advisory id, package path, or version list can't ship by accident. |
| +await test("security ledger carries only the redacted hygiene schema", () => { |
| + for (const [name, s] of securityShapes) { |
| + assert.deepEqual(Object.keys(s).sort(), ["dependencies", "generated_at", "go_modules", "scanned_sha"], name + ": keys"); |
| + assert.match(s.generated_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, name + ": generated_at is ISO UTC"); |
| + // The build sha is already public via the signed release feed — the one |
| + // version identifier the page may carry. |
| + assert.match(s.scanned_sha, /^[0-9a-f]{7,40}$/, name + ": scanned_sha is a git sha"); |
| + assert.ok(Number.isInteger(s.go_modules) && s.go_modules > 0, name + ": go_modules is a positive count"); |
| + assert.deepEqual(Object.keys(s.dependencies).sort(), ["advisories_under_remediation", "status"], name + ": dependency keys"); |
| + assert.ok(["clean", "attention", "pending"].includes(s.dependencies.status), |
| + name + ": status is a vocabulary word, got " + s.dependencies.status); |
| + const n = s.dependencies.advisories_under_remediation; |
| + assert.ok(Number.isInteger(n) && n >= 0, name + ": count is an integer ≥ 0"); |
| + // Status and count must agree — "clean" beside a nonzero count (or the |
| + // reverse) would be a lie in one direction or the other. |
| + assert.equal(n > 0, s.dependencies.status === "attention", name + ": status agrees with the count"); |
| + } |
| +}); |
| + |
| +// The redaction guard: no advisory id in ANY grammar (Go vulndb GO-…, CVE, |
| +// GHSA) — an unpatched finding publishes as a count, never a name. The |
| +// fixture was folded from a stream FULL of such ids, so this proves the |
| +// generator strips them, not merely that the input was clean. |
| +await test("security ledger never names an advisory", () => { |
| + for (const [name, s] of securityShapes) { |
| + const t = JSON.stringify(s); |
| + assert.ok(!/CVE-\d{4}-\d+|GHSA-|GO-\d{4}-\d+/i.test(t), name + " carries an advisory id: " + t.slice(0, 120)); |
| + } |
| +}); |
| + |
| +// Pin the dirty path end-to-end: the dry-run's fixture stream carries two |
| +// advisories with symbol-level findings (one of them twice — findings dedupe |
| +// by advisory) plus a module-level-only informational one that must NOT |
| +// count. Redacted result: attention + 2, and nothing else. |
| +await test("security dry-run fixture folds to attention + count 2", () => { |
| + assert.equal(securityFix.dependencies.status, "attention"); |
| + assert.equal(securityFix.dependencies.advisories_under_remediation, 2); |
| + assert.equal(securityFix.scanned_sha, "abc1234"); |
| + assert.equal(securityFix.go_modules, 42); |
| +}); |
| + |
| // Expenses publish ITEMIZED by design — decision #19's other half (income |
| // aggregates, expenses itemize; the amounts are public on purpose). The |
| // schema is still an ALLOWLIST: provider word + EXACTLY ONE amount key |
diff --git a/test/status-fixtures/security.json b/test/status-fixtures/security.json| new file mode 100644 |
| index 0000000..e6930bd |
| --- /dev/null |
| +++ b/test/status-fixtures/security.json |
| @@ -0,0 +1,9 @@ |
| +{ |
| + "generated_at": "2026-07-12T08:30:18Z", |
| + "scanned_sha": "abc1234", |
| + "go_modules": 42, |
| + "dependencies": { |
| + "status": "attention", |
| + "advisories_under_remediation": 2 |
| + } |
| +} |
diff --git a/web/status/books.html b/web/status/books.html| index 91cd551..40f5574 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -47,6 +47,8 @@ |
| header img { width: 44px; height: 44px; border-radius: 10px; } |
| header h1 { font-size: 22px; font-weight: 650; } |
| header .brand { color: var(--ink-2); font-size: 14px; } |
| + nav.pages { font-size: 14px; color: var(--ink-2); margin: 2px 0 10px; } |
| + nav.pages [aria-current] { color: var(--ink); font-weight: 650; } |
| .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| .asof { font-weight: 600; margin: 10px 0 24px; } |
| .asof .stale { color: var(--bad); font-weight: 600; } |
| @@ -98,6 +100,8 @@ |
| <h1>The books</h1> |
| </div> |
| </header> |
| + <!-- Cross-links between the three status pages; the current one is inert. --> |
| + <nav class="pages" aria-label="Status pages"><a href="./index.html">Fleet</a> · <span aria-current="page">Books</span> · <a href="./security.html">Security</a></nav> |
| <p class="lede">Eleven’s income and spending, published as they stand — part |
| of running the service in the open.</p> |
| <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
diff --git a/web/status/index.html b/web/status/index.html| index 45259ab..d7484f5 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -51,6 +51,8 @@ |
| header img { width: 44px; height: 44px; border-radius: 10px; } |
| header h1 { font-size: 22px; font-weight: 650; } |
| header .brand { color: var(--ink-2); font-size: 14px; } |
| + nav.pages { font-size: 14px; color: var(--ink-2); margin: 2px 0 10px; } |
| + nav.pages [aria-current] { color: var(--ink); font-weight: 650; } |
| .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| .asof { font-weight: 600; margin: 10px 0 24px; } |
| .asof .stale { color: var(--bad); font-weight: 600; } |
| @@ -127,6 +129,8 @@ |
| <h1>Fleet status</h1> |
| </div> |
| </header> |
| + <!-- Cross-links between the three status pages; the current one is inert. --> |
| + <nav class="pages" aria-label="Status pages"><span aria-current="page">Fleet</span> · <a href="./books.html">Books</a> · <a href="./security.html">Security</a></nav> |
| <p class="lede">Live aggregate health of the Eleven fleet — how many spaces are running |
| (always-on or on-demand, and of the on-demand ones how many are awake), what |
| they run, and whether the shared services are up. |
diff --git a/web/status/security.html b/web/status/security.html| new file mode 100644 |
| index 0000000..aed6298 |
| --- /dev/null |
| +++ b/web/status/security.html |
| @@ -0,0 +1,343 @@ |
| +<!-- 🤖 Public security ledger (the third status page, beside fleet + books). |
| + Fully self-contained: inline CSS/JS, no external requests except |
| + ./security.json (relative, so it works at any origin). The JSON is |
| + COMMITTED data written at build time by deploy/security-scan.sh; the box |
| + publisher only re-uploads it. |
| + |
| + ============== THE DISCLOSURE LINE — NON-NEGOTIABLE ====================== |
| + This page states security POSTURE, never a live exploit map: |
| + - Dependency findings publish as a STATUS + COUNT only ("clean" or |
| + "N advisories under remediation") — never the CVE/GHSA/OSV ids, |
| + affected packages, or code locations of anything UNPATCHED. That |
| + would be a responsible-disclosure violation and an attacker roadmap. |
| + Once patched it's history and may be named elsewhere. |
| + - No secret inventory, no unpatched-hole specifics, no internal |
| + component versions beyond the build sha the release feed already |
| + publishes. |
| + - Everything else on the page is an architectural fact (true by |
| + design), a hygiene status, or an honestly-named architectural edge — |
| + the deliberate, disclosed tradeoffs. |
| + - No scan published → the page says "scan pending", never a fake |
| + "clean" (degrade, never lie). |
| + ========================================================================== --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — security</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<style> |
| + /* Same validated palette roles as index.html (dataviz six-checks, light + |
| + dark); violet series hue, secondary ink for all small text (>=4.5:1). |
| + Status colors are reserved for state and always ride with a glyph + word — |
| + never color alone. */ |
| + :root { |
| + --plane: #f9f9f7; --surface: #fcfcfb; |
| + --ink: #0b0b0b; --ink-2: #52514e; |
| + --grid: #e1e0d9; --border: rgba(11,11,11,0.10); |
| + --series: #4a3aa7; |
| + --good: #006300; |
| + --warn: #7a4d00; |
| + --bad: #b23030; --bad-mark: #d03b3b; |
| + --link: #4a3aa7; |
| + } |
| + @media (prefers-color-scheme: dark) { |
| + :root { |
| + --plane: #0d0d0d; --surface: #1a1a19; |
| + --ink: #ffffff; --ink-2: #c3c2b7; |
| + --grid: #2c2c2a; --border: rgba(255,255,255,0.10); |
| + --series: #9085e9; |
| + --good: #0ca30c; |
| + --warn: #e0a33c; |
| + --bad: #e66767; --bad-mark: #e66767; |
| + --link: #9085e9; |
| + } |
| + } |
| + * { box-sizing: border-box; margin: 0; } |
| + body { |
| + background: var(--plane); color: var(--ink); |
| + font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; |
| + padding: 24px 16px 48px; |
| + } |
| + .wrap { max-width: 760px; margin: 0 auto; } |
| + a { color: var(--link); } |
| + a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| + |
| + header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; } |
| + header img { width: 44px; height: 44px; border-radius: 10px; } |
| + header h1 { font-size: 22px; font-weight: 650; } |
| + header .brand { color: var(--ink-2); font-size: 14px; } |
| + nav.pages { font-size: 14px; color: var(--ink-2); margin: 2px 0 10px; } |
| + nav.pages [aria-current] { color: var(--ink); font-weight: 650; } |
| + .lede { color: var(--ink-2); margin: 6px 0 2px; } |
| + .asof { font-weight: 600; margin: 10px 0 24px; } |
| + .asof .stale { color: var(--bad); font-weight: 600; } |
| + |
| + .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px; } |
| + .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } |
| + .tile .label { font-size: 13px; color: var(--ink-2); } |
| + .tile .value { font-size: 26px; font-weight: 600; margin-top: 2px; } |
| + .tile .value.ok { color: var(--good); } |
| + .tile .value.warn { color: var(--warn); } |
| + .tile .sub { color: var(--ink-2); font-size: 14px; margin-top: 6px; } |
| + .tile .value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 22px; } |
| + |
| + section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| + section { margin-bottom: 28px; } |
| + .note { color: var(--ink-2); font-size: 14px; } |
| + /* One-line design explainer directly under each section heading. */ |
| + .explain { margin: -4px 0 12px; } |
| + |
| + /* Fact checklists: one card row per attested fact. The leading glyph is |
| + decorative reinforcement; the words carry the meaning (never color, or |
| + glyph, alone). Edges use the brand violet marker — they are disclosed |
| + tradeoffs, not statuses. */ |
| + .facts { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + .facts li { display: grid; grid-template-columns: 1.4em 1fr; gap: 8px; padding: 11px 16px; } |
| + .facts li + li { border-top: 1px solid var(--grid); } |
| + .facts .mark { color: var(--good); font-weight: 700; } |
| + .facts.edges .mark { color: var(--series); } |
| + .facts b { font-weight: 650; } |
| + |
| + #pending { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; } |
| + #pending.show { display: block; } |
| + |
| + footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; } |
| + footer p { margin-top: 6px; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>Security</h1> |
| + </div> |
| + </header> |
| + <nav class="pages" aria-label="Status pages"><a href="./index.html">Fleet</a> · <a href="./books.html">Books</a> · <span aria-current="page">Security</span></nav> |
| + <p class="lede">Eleven’s security posture, published as it stands. This is |
| + what is true by design and how the hygiene looks today — never a map of open |
| + problems: if a scan finds something, this page shows a <em>count</em> until |
| + it’s fixed (responsible disclosure). Found a vulnerability? Tell us |
| + privately: <a href="mailto:security@elevenmessenger.com">security@elevenmessenger.com</a>.</p> |
| + <p class="asof">Dependency scan as of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
| + |
| + <section aria-labelledby="h-commitment"> |
| + <h2 id="h-commitment">The commitment</h2> |
| + <p class="note explain"><strong>If our servers are stolen, the thief gets |
| + nothing they can read.</strong> These are the load-bearing rules every |
| + change is reviewed against — architecture, not policy:</p> |
| + <ul class="facts"> |
| + <li><span class="mark" aria-hidden="true">✓</span><span>Our servers hold |
| + <b>sealed boxes and the plumbing to deliver them</b> — never a key that |
| + opens anything. Messages, names, and files are locked on your device |
| + before they leave it.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Keys live only |
| + on your devices.</b> Every conversation is sealed with its own key, |
| + handed only to its members; the server stores wrapped copies it cannot |
| + open.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Every space is |
| + an island</b> — its own database, its own keys, no shared pool of user |
| + data, no cross-user state anywhere, and no protocol tying spaces |
| + together.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Invite links |
| + keep their secret in the URL fragment</b> — the part a browser never |
| + sends to any server.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Credentials |
| + are never keys.</b> Admin tokens manage the roster; push credentials |
| + authorize delivery of sealed bytes. Neither opens a single |
| + message.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Bot code never |
| + runs inside the server.</b> Bots live on machines their operator |
| + controls, as ordinary members — compromising our server yields no bot |
| + keys.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>No personal |
| + data in our stores</b> — no emails, no per-user analytics. Customer |
| + identity for paid spaces lives with the payment processor; we hold an |
| + opaque pointer and an entitlement flag.</span></li> |
| + </ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-hygiene"> |
| + <h2 id="h-hygiene">Dependency hygiene</h2> |
| + <p class="note explain"><strong>Counted, not named:</strong> the product |
| + that ships is a single Go binary (the web client is plain JavaScript it |
| + serves — zero runtime npm packages; npm is test tooling only). Each release |
| + build is scanned with govulncheck against the exact binary the fleet runs. |
| + A finding publishes here as a count until it’s patched — naming an |
| + unpatched hole would hand attackers a map.</p> |
| + <p id="pending" role="status"><strong>Dependency scan pending.</strong> No |
| + scan has been published for the current build (or the feed didn’t load). |
| + Shown honestly rather than assumed clean — the architecture and hardening |
| + facts on this page hold regardless.</p> |
| + <div class="tiles"> |
| + <div class="tile"> |
| + <div class="label">Known vulnerabilities</div> |
| + <div class="value" id="t-dep-status">—</div> |
| + <div class="sub" id="t-dep-sub"></div> |
| + </div> |
| + <div class="tile"> |
| + <div class="label">Go modules in the build</div> |
| + <div class="value" id="t-modules">—</div> |
| + <div class="sub">direct + indirect, a count not an inventory</div> |
| + </div> |
| + <div class="tile"> |
| + <div class="label">Scanned build</div> |
| + <div class="value mono" id="t-sha">—</div> |
| + <div class="sub">the same build sha the release feed publishes</div> |
| + </div> |
| + </div> |
| + </section> |
| + |
| + <section aria-labelledby="h-hardening"> |
| + <h2 id="h-hardening">Hardening</h2> |
| + <p class="note explain"><strong>The posture, not the address:</strong> how |
| + the serving machines are run. Facts only — nothing here locates a |
| + box.</p> |
| + <ul class="facts"> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Firewall is |
| + default-deny.</b> Inbound is web traffic and administration (SSH) |
| + only; everything else is refused.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>SSH is |
| + key-only.</b> Password login is disabled outright.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>OS security |
| + updates apply automatically</b> (unattended upgrades).</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Services run |
| + as an unprivileged user in a systemd sandbox</b> — read-only system, |
| + no privilege escalation, memory-capped.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>TLS |
| + everywhere.</b> Every space is served over HTTPS with automatically |
| + issued and renewed certificates.</span></li> |
| + <li><span class="mark" aria-hidden="true">✓</span><span><b>Member content |
| + at rest is ciphertext.</b> A stolen disk — or a stolen backup — is a |
| + pile of locked boxes.</span></li> |
| + </ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-edges"> |
| + <h2 id="h-edges">Known edges, on purpose</h2> |
| + <p class="note explain"><strong>What we don’t protect, and why:</strong> |
| + the deliberate tradeoffs, named plainly. These are disclosed design |
| + choices, not bugs.</p> |
| + <ul class="facts edges"> |
| + <li><span class="mark" aria-hidden="true">◆</span><span><b>Link |
| + previews:</b> when someone posts a link, the server fetches that URL |
| + to build the preview card — so it briefly sees URLs (never message |
| + text). The price of previews; gateable behind a setting later.</span></li> |
| + <li><span class="mark" aria-hidden="true">◆</span><span><b>GIF |
| + search:</b> your browser queries Giphy directly, so Giphy sees your |
| + search terms and network address; our server sees nothing. The chosen |
| + GIF is sealed to the conversation like any attachment.</span></li> |
| + <li><span class="mark" aria-hidden="true">◆</span><span><b>Branding is |
| + plaintext:</b> a space’s name, optional photo, and description are |
| + admin-set and must render before a joiner holds any key — so they are |
| + deliberately not encrypted. Everything members write still is.</span></li> |
| + <li><span class="mark" aria-hidden="true">◆</span><span><b>Bots are |
| + members:</b> a bot you add runs on a machine its operator controls, |
| + and that machine can read exactly the conversations the bot is in — |
| + never anything else. Scoped, disclosed, and removable like any |
| + member.</span></li> |
| + <li><span class="mark" aria-hidden="true">◆</span><span><b>A |
| + content-blind store can’t scan content:</b> because we can’t read |
| + anything, no server-side abuse filter is possible — a store we can’t |
| + open is also a dead drop we can’t inspect. Abuse handling is |
| + structural instead: invite-gated rosters, admin removal, and members’ |
| + own judgement. We accept that trade with open eyes.</span></li> |
| + </ul> |
| + </section> |
| + |
| + <footer> |
| + <p><a href="./index.html">Fleet status</a> · <a href="./books.html">The books</a> · <a href="./carbon.html">The carbon tally</a> · <a href="./contributors.html">Contributors</a></p> |
| + <p>The commitment, hardening, and known-edges sections are attested |
| + content: they live in the product’s repository and change only by reviewed |
| + commit. The scan block is generated at build time against the exact binary |
| + the fleet runs; unpatched findings publish as a count only, and full |
| + details are held until fixed. Report vulnerabilities privately to |
| + <a href="mailto:security@elevenmessenger.com">security@elevenmessenger.com</a>.</p> |
| + </footer> |
| +</div> |
| + |
| +<script> |
| +// contract: the fields this page reads from ./security.json — kept in sync |
| +// with deploy/security-scan.sh and checked by test/status-contract.test.mjs. |
| +const FIELDS = [ |
| + "generated_at", "scanned_sha", "go_modules", |
| + "dependencies.status", "dependencies.advisories_under_remediation", |
| +]; |
| + |
| +// The status vocabulary — the ONLY values the feed may hold. Word + glyph |
| +// carry the state; color is supplementary (never color alone). Anything |
| +// outside the vocabulary renders as pending rather than guessing. |
| +const DEP_STATES = { |
| + "clean": { text: "✓ Clean", tone: "ok", sub: "0 known vulnerabilities in the shipped binary" }, |
| + "attention": { text: "▲ Attention", tone: "warn", sub: "" }, // sub filled with the count below |
| + "pending": { text: "—", tone: "", sub: "scan pending" }, |
| +}; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +let rendered = false; |
| + |
| +// All rendering is textContent / createElement — never innerHTML of fetched |
| +// data, even though the feed is our own. |
| +function render(d) { |
| + rendered = true; |
| + $("pending").classList.remove("show"); |
| + |
| + const t = new Date(d.generated_at); |
| + $("asof").dateTime = d.generated_at; |
| + $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| + // Honesty check: the scan rides releases, not a 5-minute timer — flag a |
| + // scan older than 45 days (several missed release cycles). |
| + const staleEl = $("staleness"); |
| + staleEl.textContent = ""; |
| + staleEl.className = ""; |
| + if (Date.now() - t.getTime() > 45 * 24 * 60 * 60 * 1000) { |
| + staleEl.className = "stale"; |
| + staleEl.textContent = " — this scan looks stale"; |
| + } |
| + |
| + const dep = d.dependencies || {}; |
| + const n = dep.advisories_under_remediation; |
| + const state = DEP_STATES[dep.status] || DEP_STATES.pending; |
| + const el = $("t-dep-status"); |
| + el.className = "value" + (state.tone ? " " + state.tone : ""); |
| + el.textContent = state.text; |
| + // Count only, by design — never ids, packages, or locations (see the |
| + // disclosure comment at the top of this file). |
| + $("t-dep-sub").textContent = dep.status === "attention" && Number.isInteger(n) |
| + ? n + " advisor" + (n === 1 ? "y" : "ies") + " under remediation — count only until patched" |
| + : state.sub; |
| + |
| + $("t-modules").textContent = Number.isInteger(d.go_modules) ? String(d.go_modules) : "—"; |
| + $("t-sha").textContent = typeof d.scanned_sha === "string" ? d.scanned_sha : "—"; |
| +} |
| + |
| +function renderPending() { |
| + // No scan visible: say so plainly — never a fake "clean" (degrade, never |
| + // lie). A previously-rendered scan stays on screen instead. |
| + if (rendered) return; |
| + $("pending").classList.add("show"); |
| + $("asof").textContent = "— no scan published"; |
| + $("t-dep-status").className = "value"; |
| + $("t-dep-status").textContent = "—"; |
| + $("t-dep-sub").textContent = "scan pending"; |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./security.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + render(await res.json()); |
| + } catch (e) { |
| + renderPending(); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60 * 1000); |
| +void FIELDS; |
| +</script> |
| +</body> |
| +</html> |
diff --git a/web/status/security.json b/web/status/security.json| new file mode 100644 |
| index 0000000..2758120 |
| --- /dev/null |
| +++ b/web/status/security.json |
| @@ -0,0 +1,9 @@ |
| +{ |
| + "generated_at": "2026-07-12T08:38:31Z", |
| + "scanned_sha": "b11771c", |
| + "go_modules": 192, |
| + "dependencies": { |
| + "status": "clean", |
| + "advisories_under_remediation": 0 |
| + } |
| +} |