doxa-maps/internal/public-hygiene-check.mjs
doxa-maps-88655c70 3fd1fbcaea Move the private field-access dataset out of the repo
data-private/ was a TRACKED folder, so field-access-visas.json would ship
with the repo the moment it goes public. A tracked folder is not private.

The table generator no longer writes inside the checkout: private tables go
to DOXA_PRIVATE_DIR (a folder outside the repo) or are skipped. data-private/
is gitignored so it cannot be re-created by accident.

The file is removed from HEAD only; it is still in history (5 commits).
Publish from fresh history or scrub it before the repo goes public.
2026-09-24 18:34:01 +00:00

212 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* public-hygiene-check.mjs — prove a drop-in (or this repo's public/) is fit to be PUBLISHED:
* only minified bundles, the leaf pages that host them, and the data/assets they fetch.
*
* WHY: the website is private on Forgejo today; the day it goes to GitHub the served folder
* must carry no internal data — no local paths, no internal repo names, no tokens, no source
* maps, no docs, no preview/staging surfaces (Driver rule, 2026-09-16). "Looks clean" is not a
* gate; this scan is, and it can FAIL and say why.
*
* WHAT IT CHECKS (each finding prints file + rule):
* leaks — absolute local paths (/home/…, /Users/…, C:\…), internal repo/folder names
* (eco-dx, qa-cycle, _archive), dev-server URLs (localhost:5174 / :3001 / :3033)
* secrets — JWT-shaped Mapbox tokens (pk./sk./tk. + eyJ… ≥ 20 chars; the 9-char docs
* placeholder "pk.eyJ..." is allowed), .env files, token.json / token.local.js
* debug — *.map files and sourceMappingURL comments, console.log/debug in bundles
* docs — *.md / *.txt anywhere in the tree
* staging — preview surfaces: <tree>/index.html and <tree>/<bundle>/index.html (the internal
* staging pages; leaf pages live one level deeper) — reported as WARN unless
* --strict, because the shell root may be product for a host site
* contract — the drop-in contract: a served root holds ONLY index.html (the generated root
* page), assets/ (data) and <tree>/ (the bundle folders + manifest.json). Every other
* root entry — a shell's nav.js, external/, _headers … — is a FINDING under --strict
* ("outside the drop-in contract"); without --strict it is a warning
* meta — working notes shipped as data: a JSON under assets/ whose top-level "meta" carries
* a key named like a to-do or an internal rule (unchecked, blocked, todo, HARD_LIMIT,
* THE_BOUNDARY, robots, NEVER) or a value quoting one ("Brief:", "not yet", "TODO",
* "NEVER-DO"). A served data file carries provenance, not notes — a FINDING under
* --strict, a warning otherwise
* Also prints an inventory (bundles, leaf pages, staging pages, assets by folder, total size).
*
* Usage: node internal/public-hygiene-check.mjs [<dir> | --root <dir>] [--tree doxa-maps] [--strict] [--json out.json]
* <dir> defaults to this repo's public/; for the website pass its drop-in folder.
* Exit: 0 clean · 1 findings (or warnings with --strict) · 2 bad arguments
*/
import { readdirSync, statSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
import { join, relative, resolve, dirname, extname, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const args = process.argv.slice(2)
const argOf = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d }
const VALUE_FLAGS = new Set(['--tree', '--json', '--root'])
const DIR = resolve(argOf('--root', '') || args.find((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(args[i - 1])) || join(ROOT, 'public'))
const TREE = argOf('--tree', 'doxa-maps')
const STRICT = args.includes('--strict')
const JSON_OUT = argOf('--json', '')
if (!existsSync(DIR) || !statSync(DIR).isDirectory()) { console.error(`[hygiene] not a directory: ${DIR}`); process.exit(2) }
const TEXT_EXT = new Set(['.js', '.mjs', '.html', '.htm', '.css', '.json', '.geojson', '.txt', '.md', '.svg', '.xml', '_headers',
// ★ EXTENSIONLESS but TEXT: the map client fetches /api/maps/token (no suffix) and
// write-token.mjs emits it, so its CONTENTS must be scanned for a JWT-shaped token.
// Without this the filename rule flagged it while the content scan skipped it entirely.
'token'])
const norm = p => p.split(sep).join('/')
const files = []
;(function walk(d) { for (const n of readdirSync(d)) { const p = join(d, n); const st = statSync(p); if (st.isDirectory()) walk(p); else files.push({ rel: norm(relative(DIR, p)), abs: p, size: st.size }) } })(DIR)
const findings = [], warnings = []
const hit = (list, rule, rel, detail = '') => list.push({ rule, file: rel, detail })
const LEAK = [
[/\/home\/[a-z0-9_-]+\//i, 'local path (/home/…)'], [/\/Users\/[A-Za-z0-9_-]+\//, 'local path (/Users/…)'], [/[A-Z]:\\Users\\/, 'local path (C:\\Users\\…)'],
[/\beco-dx\b/, 'internal folder name (eco-dx)'], [/qa-cycle/, 'internal folder name (qa-cycle)'], [/\b_archive\b/, 'internal folder name (_archive)'],
[/localhost:(5174|3001|3033)\b/, 'dev-server URL'],
// ★ HARD in every mode (Driver-critical, 2026-09-24): field-access / visa data must never
// ship in ANY public tree -- it maps how workers enter closed countries. Found live-served
// before this rule existed. Filename AND content patterns, no strict-tier softening.
[/field-access-visas/, 'PRIVATE data-class: field-access/visa table (private tables live outside the repo, see DOXA_PRIVATE_DIR)'],
[/"visa_/, 'PRIVATE data-class: visa field in shipped data'],
]
// ★ ADDED 2026-09-24 (public-exposure audit, policies P3/P4/P7). A public tree carries
// ARTIFACTS, never PROCESS or operational context — each of these was found live in a public
// mirror by anonymous clone before this rule existed. They are WARNINGS in the normal build
// (the source tree's own public/ still carries them, and failing build:unlocked for every
// writer overnight would get this gate disabled) and FINDINGS under --strict, which the
// publish/export flow MUST run (policy P5: publishing is a gated action, not a habit).
const LEAK_STRICT = [
[/\bthe Driver\b/, 'internal role noun (the Driver) — process vocabulary'],
[/\bDriver\b/, 'internal role noun (Driver) — process vocabulary'],
[/doxa-life\/doxa-maps/, 'private repo name'],
[/\bdoxa-life\b/, 'private org name'],
[/\bforgejo\b/i, 'self-hosted forge name'],
[/\bnuxtbase\b/, 'internal platform name'],
[/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'],
[/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'],
]
// ★ WIDENED 2026-09-21. This held ONE pattern (the Mapbox JWT), on the reasoning that public/
// is a tree we GENERATE, so the only realistic leak is the token the build itself writes.
// THAT REASONING WAS WRONG: 855 files under public/ are TRACKED, 95 of them text — boundaries,
// people-group data, brand assets, leaf pages — hand-curated and committed by a human. A
// hand-committed file is exactly where a pasted credential lands, and this tree is rsynced to a
// PUBLIC catalog. Measured before fixing: a planted `ghp_…` PAT and an `AKIA…` AWS key both
// passed as "clean — 0 findings".
// Each entry identifies a KNOWN credential FORMAT rather than guessing from entropy: an
// entropy rule over this tree would fire on every SRI hash and base64 image and be ignored.
// ★ pk. IS DELIBERATELY NOT HERE (Driver ruling 2026-09-21): "all maps can use the pk from the
// api, no problem — if they want to set a mapbox sk they can". A Mapbox `pk.` is a PUBLIC key: it
// is designed to ship in client code and is protected by URL RESTRICTIONS, not by secrecy
// (measured: the same pk. serves tiles 200 with a doxa.life Referer and 403s with nuxtbase.app).
// Flagging it made the token ladder's rung-3 baked token UNBUILDABLE — this gate runs inside
// build:unlocked, so `pk.` in public/ failed the build that is supposed to produce it.
// `sk.` (SECRET key) and `tk.` (temporary) stay flagged: those are real credentials.
const SECRET = [
[/\b(sk|tk)\.eyJ[A-Za-z0-9_-]{20,}/, 'JWT-shaped Mapbox SECRET/temporary token (sk./tk.)'],
[/\bgh[pousr]_[A-Za-z0-9]{36,}/, 'GitHub personal-access token'],
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/, 'Slack token'],
[/\bAKIA[0-9A-Z]{16}\b/, 'AWS access-key id'],
[/-----BEGIN (RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/, 'private-key block'],
[/\bBearer\s+[A-Za-z0-9_\-.]{30,}/, 'literal Bearer token'],
// NOTE the trailing /i — JavaScript has no inline (?i:…) group (that is Python/PCRE);
// written that way it throws "Invalid regular expression" and takes the whole gate down.
[/coolify[^\n]{0,40}?[=:]\s*["']?[A-Za-z0-9_-]{32,}/i, 'Coolify API token'],
]
for (const f of files) {
const ext = extname(f.rel).toLowerCase(), base = f.rel.split('/').pop()
if (base === '.env' || /^\.env\./.test(base)) hit(findings, 'secrets', f.rel, '.env file')
// ★ EXTENSIONLESS `token` COUNTS TOO — the map client requests /api/maps/token (never .json),
// so write-token.mjs emits BOTH paths. The old rule required a .js/.json suffix and so MISSED
// the bare file entirely: the gate flagged token.json while the same secret sat beside it,
// unflagged, under a name the consumer actually fetches. Measured 2026-09-20.
if (/^token(\.local)?(\.js(on)?)?$/.test(base)) hit(findings, 'secrets', f.rel, 'token file')
if (ext === '.map') hit(findings, 'debug', f.rel, 'source map file')
// NOTE: 'docs' here is the FINDING-CATEGORY name documented in this file's header, not a
// folder path. It deliberately did NOT change when docs/ was renamed to documentation/.
if (ext === '.md' || ext === '.markdown' || ext === '.txt') hit(findings, 'docs', f.rel, 'documentation in a served folder')
const isText = TEXT_EXT.has(ext) || TEXT_EXT.has(base)
if (!isText || f.size > 40 * 1024 * 1024) continue
const t = readFileSync(f.abs, 'utf8')
for (const [re, why] of LEAK) if (re.test(t)) hit(findings, 'leaks', f.rel, why)
for (const [re, why] of LEAK_STRICT) if (re.test(t) && !/\.csv$/.test(f.rel)) hit(STRICT ? findings : warnings, 'leaks', f.rel, why)
// ★ P2 (2026-09-24): a credential is a DOCUMENT, not a string. Every JWT-shaped value gets
// its payload DECODED and its claims reviewed: 'it is a public token' is not sufficient —
// the pk. accepted by the 2026-09-21 ruling turned out to carry a `u` claim naming a real,
// searchable ACCOUNT in base64 anyone can decode. The ruling covered the token's scope,
// not the identity inside it. Flag any claim value that looks like a meaningful name.
for (const m of t.matchAll(/\b(?:pk|sk|tk)\.(eyJ[A-Za-z0-9_-]{8,})/g)) {
try {
const pad = m[1] + '='.repeat((4 - (m[1].length % 4)) % 4)
const claims = JSON.parse(Buffer.from(pad, 'base64').toString('utf8'))
for (const [k, v] of Object.entries(claims)) {
if (typeof v === 'string' && /^[a-z][a-z0-9_-]{3,}$/i.test(v) && !/^[0-9a-f]+$/i.test(v)) {
hit(STRICT ? findings : warnings, 'secrets', f.rel, `JWT claim "${k}" carries a name-like value (${v.length} chars) — decode + review before publishing`)
break
}
}
} catch { /* not decodable — not a JWT payload */ }
}
for (const [re, why] of SECRET) if (re.test(t)) hit(findings, 'secrets', f.rel, why)
if (/sourceMappingURL=/.test(t)) hit(warnings, 'debug', f.rel, 'sourceMappingURL comment (map file absent → harmless, but points at a missing map)')
if (ext === '.js' && /console\.(log|debug)\(/.test(t)) hit(warnings, 'debug', f.rel, 'console.log/debug in a shipped bundle')
}
// working notes in data: a JSON under assets/ whose top-level "meta" carries a key named like a
// to-do or an internal rule, or a value that quotes one. Provenance ships; notes do not.
const META_KEY = /unchecked|blocked|todo|hard_limit|the_boundary|robots|never/i
const META_VALUE = [[/Brief:/, '"Brief:"'], [/\bnot yet\b/i, '"not yet"'], [/\bTODO\b/, '"TODO"'], [/NEVER-DO/, '"NEVER-DO"']]
for (const f of files) {
if (!f.rel.startsWith('assets/') || extname(f.rel).toLowerCase() !== '.json' || f.size > 40 * 1024 * 1024) continue
let doc; try { doc = JSON.parse(readFileSync(f.abs, 'utf8')) } catch { continue }
const meta = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.meta : null
if (!meta || typeof meta !== 'object') continue
const notes = new Set()
;(function scan(node, path) {
if (Array.isArray(node)) { node.forEach((v, i) => scan(v, `${path}[${i}]`)); return }
if (node && typeof node === 'object') { for (const [k, v] of Object.entries(node)) { const p = `${path}.${k}`; if (META_KEY.test(k)) notes.add(`key ${p} reads as a working note`); scan(v, p) }; return }
if (typeof node === 'string') for (const [re, why] of META_VALUE) if (re.test(node)) notes.add(`${path} contains ${why}`)
})(meta, 'meta')
for (const d of notes) hit(STRICT ? findings : warnings, 'meta', f.rel, d)
}
// staging surfaces: <tree>/index.html and <tree>/<bundle>/index.html (leaf pages are deeper)
const staging = files.filter(f => f.rel === `${TREE}/index.html` || /^[^/]+\/[^/]+\/index\.html$/.test(f.rel) && f.rel.startsWith(`${TREE}/`)).map(f => f.rel)
for (const s of staging) hit(STRICT ? findings : warnings, 'staging', s, 'preview/staging page (internal surface) — exclude from a public mirror or accept knowingly')
// The drop-in contract: only the entries vite.doxa-maps-build-config.json `dropin.include` declares
// may sit at the root. Strict = every other root entry is a finding; otherwise a warning.
//
// ★ READ FROM THE CONFIG, DO NOT RESTATE IT. This was a hardcoded
// `new Set(['index.html', 'assets', TREE])` while its own comment said "the same three entries
// dropin.include declares" — two copies of one fact with nothing forcing agreement, which is how
// this repo has been bitten before (a doc that QUOTES package.json drifted twice; one capability
// number lived in a gate and the README and disagreed). Adding __gallery/ to the config in 2026-09
// would have left this gate failing the build on a directory the contract explicitly allows.
// The literal is kept only as the fallback for an unreadable config, so the gate still works
// standalone rather than crashing.
const CONTRACT = (() => {
try {
const cfg = JSON.parse(readFileSync(join(ROOT, 'vite.doxa-maps-build-config.json'), 'utf8'))
const inc = cfg?.dropin?.include
if (Array.isArray(inc) && inc.length) return new Set(inc)
} catch { /* fall through */ }
return new Set(['index.html', 'assets', TREE])
})()
const rootEntries = [...new Set(files.map(f => f.rel.split('/')[0]))].sort()
for (const e of rootEntries) {
if (CONTRACT.has(e)) continue
const label = files.some(f => f.rel.startsWith(`${e}/`)) ? `${e}/` : e
hit(STRICT ? findings : warnings, 'contract', label, `outside the drop-in contract (index.html, assets/, ${TREE}/ only)`)
}
// inventory
const inv = {
dir: DIR, files: files.length, totalMB: +(files.reduce((n, f) => n + f.size, 0) / 1048576).toFixed(1),
bundles: files.filter(f => f.rel.startsWith(`${TREE}/`) && f.rel.endsWith('.js')).length,
leafPages: files.filter(f => /^[^/]+\/[^/]+\/[^/]+(\/[^/]+)?\/index\.html$/.test(f.rel) && f.rel.startsWith(`${TREE}/`)).length,
stagingPages: staging.length,
assetsMB: Object.fromEntries([...new Set(files.filter(f => f.rel.startsWith('assets/')).map(f => f.rel.split('/')[1]))].map(k => [k, +(files.filter(f => f.rel.startsWith(`assets/${k}/`)).reduce((n, f) => n + f.size, 0) / 1048576).toFixed(1)])),
}
const fmt = x => `${x.rule.padEnd(8)} ${x.file}${x.detail ? ' — ' + x.detail : ''}`
console.log(`[hygiene] ${DIR}\n[hygiene] ${inv.files} files · ${inv.totalMB} MB · bundles ${inv.bundles} · leaf pages ${inv.leafPages} · staging pages ${inv.stagingPages} · assets MB ${JSON.stringify(inv.assetsMB)}`)
if (findings.length) { console.log(`\nFINDINGS (${findings.length}):`); findings.forEach(x => console.log(' ✗ ' + fmt(x))) }
if (warnings.length) { console.log(`\nWARNINGS (${warnings.length})${STRICT ? ' — strict: counted as findings' : ''}:`); warnings.slice(0, 20).forEach(x => console.log(' ! ' + fmt(x))); if (warnings.length > 20) console.log(` … +${warnings.length - 20} more`) }
console.log(`\n[hygiene] ${findings.length ? 'NOT CLEAN' : 'clean'} — ${findings.length} finding(s), ${warnings.length} warning(s)`)
if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify({ inventory: inv, findings, warnings, strict: STRICT }, null, 2))
process.exit(findings.length ? 1 : 0)