deploy+status: mesh boxes merge each other's self-reported posture — no more "not reported" for collector-running peers (#1181)
Each collector now uploads its self row to posture/<label>.json in the status bucket; when building peer rows, a box prefers a peer's fresh self-report over the probe-only unknowns — except ssh_internet_facing, where an SSH banner seen from outside vetoes any self-claim (a compromised box must not self-report its exposure away). This is also what lets N collector boxes share one hardening.json without flip-flopping viewpoints: every writer builds the same merged rows. Plus the first hermetic test that actually RUNS the collector script end to end — the class of coverage whose absence cost four consecutive first-real-box failures today.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+195
−3
deploy/hardening-posture.sh+50 −0package.json+1 −1test/hardening-posture.test.mjs+136 −0web/status/hardening.html+8 −2
diff --git a/deploy/hardening-posture.sh b/deploy/hardening-posture.sh| index 6be88ea..4bc8aab 100755 |
| --- a/deploy/hardening-posture.sh |
| +++ b/deploy/hardening-posture.sh |
| @@ -371,6 +371,34 @@ classify_probe() { |
| esac |
| } |
| +# peer_posture <label> — a peer's own self-published posture row, if fresh. |
| +# Every box running this collector uploads its self row to |
| +# "posture/<label>.json" in the status bucket (see the upload section), so any |
| +# box can read back every other box's AUTHORITATIVE local read instead of |
| +# stopping at the outside :22 hint. This is also what lets N collector boxes |
| +# share the one hardening.json without flip-flopping viewpoints: whichever box |
| +# uploads last, it built the same merged rows from the same self-reports. |
| +# Freshness gates on the row's own `generated` stamp — a box that stopped |
| +# collecting (or died) falls back to the probe view within POSTURE_STALE_SECONDS. |
| +# Test seam: HARDENING_POSTURE_DIR serves "<label>.json" files instead of S3. |
| +POSTURE_STALE_SECONDS=${HARDENING_POSTURE_STALE_SECONDS:-900} |
| +peer_posture() { |
| + local label=$1 f gen now age |
| + [ -n "$label" ] || return 0 |
| + f="$TMP/posture-$label.json" |
| + if [ -n "${HARDENING_POSTURE_DIR:-}" ]; then |
| + cat "$HARDENING_POSTURE_DIR/$label.json" 2>/dev/null || true |
| + return 0 |
| + fi |
| + aws s3 cp "s3://$BUCKET/posture/$label.json" "$f" --region "$REGION" >/dev/null 2>&1 || return 0 |
| + gen=$(jq -r '.generated // empty' "$f" 2>/dev/null) || true |
| + [ -n "$gen" ] || return 0 |
| + now=$(date -u +%s) |
| + age=$(( now - $(date -u -d "$gen" +%s 2>/dev/null || echo 0) )) |
| + [ "$age" -ge 0 ] && [ "$age" -le "$POSTURE_STALE_SECONDS" ] || return 0 |
| + cat "$f" |
| +} |
| + |
| # ---- 3. build the boxes array ------------------------------------------------ |
| echo '[]' > "$TMP/boxes.json" |
| add_box() { # $1 = a JSON object string |
| @@ -392,6 +420,9 @@ add_self() { |
| firewall_active:$fwactive, source:"local"} |
| + (if $region == "" then {} else {region:$region} end)') |
| add_box "$obj" |
| + # The same row, stamped, becomes this box's self-report for PEERS to merge |
| + # (posture/<label>.json — see peer_posture). Uploaded in section 4. |
| + printf '%s' "$obj" | jq --arg now "$NOW_ISO" '. + {generated:$now}' > "$TMP/self-posture.json" |
| self_added=1 |
| } |
| @@ -416,6 +447,22 @@ while IFS= read -r row; do |
| verdict=${probe_out%%$'\t'*}; banner=${probe_out#*$'\t'} |
| fi |
| classify_probe "$verdict" "$banner" |
| + # Prefer the peer's own fresh self-report over the probe-only row — but the |
| + # probe still has veto power the loud way: an SSH banner WE saw from outside |
| + # overrides whatever the peer claims about itself (a compromised or |
| + # misconfigured box must not be able to self-report its exposure away). |
| + # Every other field (root login, wireguard, firewall…) only the box itself |
| + # can know, so the self-report carries them verbatim. |
| + posture=$(peer_posture "$label") |
| + if [ -n "$posture" ]; then |
| + obj=$(printf '%s' "$posture" | jq \ |
| + --argjson pfacing "$FACING" --arg pdetail "$DETAIL" \ |
| + 'del(.generated) |
| + + {is_self:false, source:"self-reported"} |
| + + (if $pfacing == true then {ssh_internet_facing:true, detail:$pdetail} else {} end)') |
| + add_box "$obj" |
| + continue |
| + fi |
| [ -n "$label" ] || label="unidentified box" |
| obj=$(jq -n \ |
| --arg label "$label" --arg region "$region" \ |
| @@ -460,6 +507,9 @@ if [ "$upload" = 1 ]; then |
| up() { aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \ |
| --content-type "$3" --cache-control "$4" --no-progress >/dev/null; } |
| up "$OUT" hardening.json "application/json" "max-age=60" |
| + # The self-report peers merge (peer_posture) — short cache: it's read |
| + # box-to-box via s3 cp, so the cache header only matters if a human fetches it. |
| + [ -f "$TMP/self-posture.json" ] && up "$TMP/self-posture.json" "posture/$SELF_LABEL.json" "application/json" "max-age=60" |
| [ -f "$PAGES_DIR/hardening.html" ] && up "$PAGES_DIR/hardening.html" hardening.html "text/html; charset=utf-8" "max-age=300" |
| echo "hardening posture published to s3://$BUCKET ($NOW_ISO)" |
| fi |
diff --git a/package.json b/package.json| index fa3ce79..9df2d13 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs" |
| + "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs" |
| } |
| } |
diff --git a/test/hardening-posture.test.mjs b/test/hardening-posture.test.mjs| new file mode 100644 |
| index 0000000..4fd261f |
| --- /dev/null |
| +++ b/test/hardening-posture.test.mjs |
| @@ -0,0 +1,136 @@ |
| +// 🤖 Hermetic run of deploy/hardening-posture.sh — the box-side hardening |
| +// collector — through its documented fixture seams. This exists because the |
| +// collector shipped fixture-checked but never run, and its first FOUR real-box |
| +// encounters failed (lost merge #1155, ReadWritePaths namespacing #1159, |
| +// AF_NETLINK #1171, IAM prefix scope #1176): the parsing was always right and |
| +// the plumbing always wrong. This test at least pins the whole script path — |
| +// enumerate → probe/merge → sort → output JSON — end to end in one process. |
| +// Requires bash + jq (same toolchain the status-contract fixtures assume). |
| +// node test/hardening-posture.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { execFileSync } from "node:child_process"; |
| +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; |
| +import { tmpdir } from "node:os"; |
| +import { join } from "node:path"; |
| + |
| +let failures = 0; |
| +function test(name, fn) { |
| + try { |
| + fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +const script = new URL("../deploy/hardening-posture.sh", import.meta.url).pathname; |
| + |
| +// Run the collector against fixtures; return the parsed hardening.json. |
| +// members: TSV rows; posture: {label: rowObject} self-reports peers published. |
| +// The default ufw fixture proves "wireguard-only": active, a 22-on-wg0 allow, |
| +// and NO bare public 22 — the shape firewall_ssh_scope requires for a hardened |
| +// (false) self verdict rather than the loud unknown. |
| +const UFW_HARDENED = [ |
| + "Status: active", |
| + "", |
| + "To Action From", |
| + "-- ------ ----", |
| + "80,443/tcp ALLOW Anywhere", |
| + "22/tcp on wg0 ALLOW Anywhere", |
| + "51820/udp ALLOW Anywhere", |
| +].join("\n"); |
| + |
| +function collect({ members, posture = {}, ufw = UFW_HARDENED }) { |
| + const dir = mkdtempSync(join(tmpdir(), "hardening-test-")); |
| + const state = join(dir, "state"), pages = join(dir, "pages"), postures = join(dir, "posture"); |
| + mkdirSync(state); mkdirSync(pages); mkdirSync(postures); |
| + writeFileSync(join(dir, "members.tsv"), members); |
| + writeFileSync(join(dir, "router.env"), |
| + "LCHAT_MESH_SELF=https://self.example\nLCHAT_BOX_REGION=EU · Finland\n"); |
| + writeFileSync(join(dir, "ufw.txt"), ufw + "\n"); |
| + writeFileSync(join(dir, "sshd.txt"), "permitrootlogin no\npasswordauthentication no\n"); |
| + writeFileSync(join(dir, "au.txt"), "active\n"); |
| + writeFileSync(join(dir, "wg0.txt"), "42: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP>\n"); |
| + for (const [label, row] of Object.entries(posture)) { |
| + writeFileSync(join(postures, label + ".json"), JSON.stringify(row)); |
| + } |
| + execFileSync("bash", [script], { |
| + env: { |
| + ...process.env, |
| + HARDENING_UFW_CMD: `cat ${join(dir, "ufw.txt")}`, |
| + HARDENING_SSHD_CMD: `cat ${join(dir, "sshd.txt")}`, |
| + HARDENING_SYSTEMCTL_CMD: `cat ${join(dir, "au.txt")}`, |
| + HARDENING_IP_CMD: `cat ${join(dir, "wg0.txt")}`, |
| + HARDENING_ROUTER_ENV: join(dir, "router.env"), |
| + HARDENING_MEMBERS_FILE: join(dir, "members.tsv"), |
| + HARDENING_POSTURE_DIR: postures, |
| + HARDENING_STATE_DIR: state, |
| + HARDENING_PAGES_DIR: pages, |
| + HARDENING_NO_UPLOAD: "1", |
| + }, |
| + }); |
| + return JSON.parse(readFileSync(join(pages, "hardening.json"), "utf8")); |
| +} |
| + |
| +const SELF_ROW = "https://self.example\t\t\t\n"; |
| +const kepler = (probe) => `https://198.51.100.7\tkepler-conducting\tUK · London\t${probe}\n`; |
| + |
| +test("probe-only peer renders unknowns, sorted by exposure", () => { |
| + const out = collect({ members: SELF_ROW + kepler("serving-ssh") }); |
| + assert.equal(out.boxes.length, 2); |
| + const [first, second] = out.boxes; |
| + assert.equal(first.label, "kepler-conducting"); // exposed sorts first |
| + assert.equal(first.ssh_internet_facing, true); |
| + assert.equal(first.source, "probe"); |
| + assert.equal(first.root_login, "unknown"); |
| + assert.equal(second.is_self, true); |
| + assert.equal(second.ssh_internet_facing, false); // wg up + no public 22 in ufw |
| + assert.equal(second.firewall_active, true); |
| +}); |
| + |
| +test("fresh peer self-report replaces the probe row's unknowns", () => { |
| + const out = collect({ |
| + members: SELF_ROW + kepler("not-reachable"), |
| + posture: { |
| + "kepler-conducting": { |
| + label: "kepler-conducting", is_self: true, source: "local", |
| + ssh_internet_facing: false, detail: "wireguard-only", |
| + root_login: "no", password_auth: "no", auto_updates: "active", |
| + wireguard: "up", firewall_active: true, region: "UK · London", |
| + generated: new Date().toISOString().replace(/\.\d+Z$/, "Z"), |
| + }, |
| + }, |
| + }); |
| + const peer = out.boxes.find((b) => b.label === "kepler-conducting"); |
| + assert.equal(peer.source, "self-reported"); |
| + assert.equal(peer.is_self, false); // reader's perspective wins |
| + assert.equal(peer.root_login, "no"); |
| + assert.equal(peer.wireguard, "up"); |
| + assert.equal(peer.firewall_active, true); |
| + assert.equal(peer.ssh_internet_facing, false); |
| + assert.equal(peer.generated, undefined); // stamp stays in posture/, not the page |
| +}); |
| + |
| +test("an outside SSH banner vetoes a self-report claiming closed", () => { |
| + const out = collect({ |
| + members: SELF_ROW + kepler("serving-ssh"), |
| + posture: { |
| + "kepler-conducting": { |
| + label: "kepler-conducting", is_self: true, source: "local", |
| + ssh_internet_facing: false, detail: "wireguard-only", |
| + root_login: "no", password_auth: "no", auto_updates: "active", |
| + wireguard: "up", firewall_active: true, |
| + generated: new Date().toISOString().replace(/\.\d+Z$/, "Z"), |
| + }, |
| + }, |
| + }); |
| + const peer = out.boxes.find((b) => b.label === "kepler-conducting"); |
| + assert.equal(peer.source, "self-reported"); |
| + assert.equal(peer.ssh_internet_facing, true); // the banner we saw wins |
| + assert.match(peer.detail, /serving SSH/); |
| + assert.equal(peer.root_login, "no"); // the rest stays self-reported |
| + assert.equal(out.boxes[0].label, "kepler-conducting"); // and it still sorts as exposed |
| +}); |
| + |
| +process.exit(failures ? 1 : 0); |
diff --git a/web/status/hardening.html b/web/status/hardening.html| index 2d58625..b4d3247 100644 |
| --- a/web/status/hardening.html |
| +++ b/web/status/hardening.html |
| @@ -167,8 +167,9 @@ |
| // Which self-reported sub-states to show, in order, and how each value maps to |
| // a good / warn / bad / muted state. Only keys PRESENT on a box render — a |
| - // peer (outside probe) carries just the three it might one day self-report, as |
| - // "not reported"; the local box carries all five with real values. |
| + // probe-only peer carries just three, as "not reported"; a box that runs the |
| + // collector carries all five with real values (its own row via source |
| + // "local", and on every OTHER box's upload via the merged "self-reported"). |
| const SUBS = [ |
| ["root_login", "root login"], |
| ["password_auth", "password login"], |
| @@ -271,6 +272,11 @@ |
| note.className = "probe-note"; |
| note.textContent = "Seen from another box in the mesh — this box’s own report appears once it runs the collector."; |
| li.appendChild(note); |
| + } else if (box.source === "self-reported") { |
| + const note = document.createElement("p"); |
| + note.className = "probe-note"; |
| + note.textContent = "Self-reported by this box; its port 22 is also probed from the mesh outside."; |
| + li.appendChild(note); |
| } |
| list.appendChild(li); |