eleven / messenger Public

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>
Paul Campbell a42bce91149a7804fe15c9da1d8233496303fdf8 parent 3d5972b
11 files changed, +750 −41
  • deploy/security-scan.sh +212 −0
  • deploy/status-publish.sh +17 −2
  • docs/status.md +42 −4
  • go.mod +13 −11
  • go.sum +22 −22
  • test/status-contract.test.mjs +75 −2
  • test/status-fixtures/security.json +9 −0
  • web/status/books.html +4 −0
  • web/status/index.html +4 −0
  • web/status/security.html +343 −0
  • web/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
+ }
+}