doxa-maps/internal/public-hygiene-check.mjs

243 lines
18 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 day the source is published, 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.
* "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'
import { TREE_NAME } from './_tree-name.mjs'
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', TREE_NAME)
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 (critical, 2026-09-24): the private entry-policy (visa) table 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. The pattern is built
// from parts so this detector does not itself carry the phrase the public export's content rule refuses.
[new RegExp('field' + '-access-visas'), 'PRIVATE data-class: the entry-policy 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 ROLE_NOUN = ['Dri', 'ver'].join('') // built from parts so this file does not itself carry the word
const LEAK_STRICT = [
[new RegExp('\\bthe ' + ROLE_NOUN + '\\b'), 'internal role noun — process vocabulary'],
[new RegExp('\\b' + ROLE_NOUN + '\\b'), 'internal role noun — process vocabulary'],
[/doxa-life\/doxa-maps/, 'private repo name'],
[/\bdoxa-life\b/, 'private org name'],
[/\bforgejo\b/i, 'self-hosted forge name'],
[new RegExp('\\b' + ['nuxt', 'base'].join('') + '\\b'), 'internal platform name'], // built from parts so this file does not itself carry the name
[/\bpersecution\b/, 'operational-context vocabulary (allowed only inside data CSVs)'],
[/\.forgejo\/workflows/, 'CI workflow path — process, never ships in a public tree'],
]
// ★ ONE PRAYER IS ALLOWED, WORD FOR WORD: the prayer is for the whole world, and every packet carries the
// same prayer. The operational-vocabulary rule
// skips EXACTLY that prayer: the text at packet-strings.json <locale>.points[0].text (the packets' prayer
// point 1, which UPG-100's Two-Cross packet shows too), read from its source in every locale, removed from
// a scanned file before the rule looks at it. Scoped by file and key, never by the word: the same word
// anywhere else still counts.
const PRAYER_ALLOWED = (() => {
try {
const src = JSON.parse(readFileSync(join(ROOT, 'library/scripts/posters/packet-strings.json'), 'utf8'))
const out = []
for (const [lc, v] of Object.entries(src)) {
const text = !lc.startsWith('_') && v?.points?.[0]?.text
if (typeof text === 'string' && text) out.push(text, JSON.stringify(text).slice(1, -1)) // raw, and as a JS/JSON string literal
}
return out
} catch { return [] }
})()
const withoutPrayer = (t) => PRAYER_ALLOWED.reduce((acc, s) => acc.split(s).join(''), t)
// ★ 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 (ruled 2026-09-21): every map may use the pk from the API, and a
// host that wants to set a Mapbox sk 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 the live test host's Referer).
// 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/.
// The ONE exception is public/README.md: the build generates it (generate-tree publicReadme) to explain
// the folder, per the decision BUILD-OUTPUT-IS-ONE-FOLDER (only the output folder + a short
// README.md). A doc anywhere else — inside the tree included — is still a finding.
if ((ext === '.md' || ext === '.markdown' || ext === '.txt') && f.rel !== 'README.md') 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)
const tNoPrayer = PRAYER_ALLOWED.length && /persecution/.test(t) ? withoutPrayer(t) : t
for (const [re, why] of LEAK_STRICT) if (re.test(re.source.includes('persecution') ? tNoPrayer : 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)
// <tree>/index.html is NOT a staging page any more: it is the Plugin Studio, the output's front page
// (decision BUILD-PLUGIN-STUDIO-IS-THE-OUTPUT-FRONT-PAGE). A <tree>/<bundle>/index.html chooser still would be.
const staging = files.filter(f => /^[^/]+\/[^/]+\/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
// The output folder is always part of the contract (it is the product); include lists the extras.
if (Array.isArray(inc)) return new Set([TREE, ...inc])
} catch { /* fall through */ }
return new Set(['README.md', 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
// ALWAYS a finding, not a strict-mode warning: public/ holding ONLY the output folder + README.md is a
// product decision (BUILD-OUTPUT-IS-ONE-FOLDER), not a style preference. MEASURED 2026-09-25: a rebuild
// under the OLD tree name re-added public/doxa-maps/ beside public/dx-maps/, and this gate only warned.
hit(findings, 'contract', label, `outside the drop-in contract (${[...CONTRACT].join(', ')} 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)