doxa-maps/internal/check-no-public-binds.mjs
Doxa Maps 2eaea5cedf P (repo): neutral wording in the gates and docs, and no private-process checks
Comments, messages and docs only; no check logic changes.
- The role-word gate is renamed check-no-internal-role-nouns (and its baseline fixture). Same
  logic: it builds the word from parts so the file does not carry it, and a planted line in
  title case and in capitals still fails it while WebDriver and "GPU driver" do not.
- public-hygiene-check: the two role patterns are built from parts (the regexes are byte-
  identical), the header loses the forge and date wording, and the prayer comment loses its
  decision date. Its output on the tree is identical in normal and --strict modes.
- landing-preconditions, check-no-public-binds, check-published-tree-weight: no fleet tool
  name, no "this box", no forge name in comments and messages.
- Removed: check-backlog-refs, check-backlog-status-enum, check-done-evidence,
  check-verification-tags and check-dxlife-untouched (they police a private backlog and
  workspace), with their scripts, docs, gate-list entries and orphan-baseline entry, and the
  committed needs-eyes pages (check-embedded-rtl --emit writes them on demand).
- package.json docs: no agent names. Docs: the stale check mentions and the snapshot
  references are gone (the snapshots themselves leave in the studio P commit).
gates:pre: PASS 115, FAIL 0, NOT MEASURED 1 (browser seat).
2026-10-01 00:25:38 +00:00

96 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* check-no-public-binds.mjs — no script in this repo may start a server on a public interface.
*
* THE DEFECT THIS CATCHES, measured not imagined. `serve` was
* python3 -m http.server 4173 --bind 0.0.0.0 --directory public
* Every browser gate runs `bun run serve` first, and every agent that runs a browser gate is a
* separate process, so the binds accumulated. A live audit of the build VPS found NINETEEN publicly
* bound http.server processes on ports 4820-4970 plus one on 8899, every one rooted at public/, and
* a curl to the box's own public IP returned HTTP 200. None of them were noticed by anyone, because
* a listening socket produces no output and no failure — the gate it served passed, and the process
* outlived it.
*
* ★ WHY THIS IS THE INTERESTING CLASS. The standing rule "keep the build machine closed" lived in prose, in
* instructions to an agent. The actual behaviour lived in a FLAG in package.json. Prose cannot
* constrain a flag. The rule was never violated by a decision — it was violated by a default that
* nobody re-read, and it stayed violated because nothing ever asked the question out loud. It was
* stated plainly: a build machine needs no open ports, because changes are pushed to the git remote
* and pulled onto a laptop; Playwright is for internal testing, not for public access.
*
* Loopback is sufficient for the entire test suite: verify-browser-all, check:a11y-button-names,
* check:nav-reachable and smoke:share-all all already default to 127.0.0.1. The wide bind bought
* nothing and cost an open box.
*
* WHAT IT ASSERTS:
* 1. no script contains `--bind 0.0.0.0`, `--host 0.0.0.0`, or `--host` with no argument
* (vite's bare `--host` means "all interfaces", which is the same defect spelled differently);
* 2. any script that starts a static server names an explicit loopback bind, so adding a new
* server without thinking about its interface fails here rather than on a port scan.
*
* It deliberately does NOT inspect the machine's live sockets. A gate that reads `ss` would pass or
* fail based on whatever happens to be running at that moment, which is not a property of the repo
* and cannot be fixed by editing the repo. Kill strays operationally; keep the gate about the code.
*
* NEGATIVE-TESTED: restoring `--bind 0.0.0.0` on either serve script fails by name with exit 1.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
const scripts = pkg.scripts || {}
const PUBLIC_BIND = [
{ re: /--bind\s+0\.0\.0\.0/, why: '--bind 0.0.0.0 listens on every interface' },
{ re: /--host\s+0\.0\.0\.0/, why: '--host 0.0.0.0 listens on every interface' },
{ re: /--bind\s+\[::\]/, why: '--bind [::] listens on every IPv6 interface' },
// `vite --host` with no value is the sneaky one: it reads like "set a host" and MEANS "all of them".
{ re: /--host(?:\s|$)(?!\s*(?:127\.0\.0\.1|localhost))/, why: 'a bare --host (no address) means ALL interfaces' },
]
// Scripts that start a long-lived static server must say where they bind, explicitly.
const SERVER = /http\.server|vite preview|serve\b/
const errs = []
for (const [name, body] of Object.entries(scripts)) {
const cmd = String(body)
// Only judge the command, not the trailing `#` explanation — a comment describing the OLD bad
// value (which `serve` now carries, deliberately) must not trip the gate that the comment exists
// to explain. This is the same shape as a lint rule firing on its own documentation.
const code = cmd.split(/\s+#\s/)[0]
for (const { re, why } of PUBLIC_BIND) {
if (re.test(code)) {
errs.push(` "${name}" — ${why}\n ${code.trim().slice(0, 110)}`)
break
}
}
if (SERVER.test(code) && /http\.server/.test(code) && !/--bind\s+(127\.0\.0\.1|localhost)/.test(code)) {
errs.push(
` "${name}" — starts an http.server without an explicit loopback --bind.\n` +
' python3 -m http.server defaults to ALL interfaces when --bind is omitted, so leaving it\n' +
' off is the same exposure as writing 0.0.0.0, just less visible in review.'
)
}
}
if (errs.length) {
console.error('NO-PUBLIC-BINDS: FAIL — a repo script would open a port to the network.\n')
errs.forEach((e) => console.error(e))
console.error('\n The whole test suite reaches its server over 127.0.0.1. Nothing here needs a public')
console.error(' bind: code moves between machines through the git remote, not through an open port. Use')
console.error(' --bind 127.0.0.1, and pass BASE_URL/BASE if some caller genuinely needs another host.')
process.exit(1)
}
const servers = Object.entries(scripts).filter(([, b]) => /http\.server/.test(String(b).split(/\s+#\s/)[0]))
console.log(`NO-PUBLIC-BINDS: PASS — ${Object.keys(scripts).length} scripts checked, ${servers.length} static server(s), all loopback-bound.`)
servers.forEach(([n, b]) => {
const m = String(b).match(/--bind\s+(\S+)/)
console.log(` ${n} -> ${m ? m[1] : '(none)'}`)
})
console.log(' This checks the REPO, not the machine. A stray server someone started by hand is an')
console.log(' operational problem; only what is written here can be fixed by editing here.')