Some checks are pending
build-gate / build (push) Waiting to run
The field-access-visas table (580 KB -- how workers enter closed countries) was found PUBLIC in three copies and LIVE-SERVED at 200: the key-cities bundle data dir, the research out/ dir, and a stale staging tree inside the served catalog. Root cause: emit() enforced PRIVATE_TABLES but the ROUTE bundle-copy loop did not -- a privacy rule enforced at one of two writers is not a rule. The loop now skips PRIVATE_TABLES; the redundant source copies are deleted (data-private/ keeps the one canonical file); and the hygiene gate flags the filename and visa_ fields as HARD findings in every mode -- this data class never rides the strict-tier softening. All three public copies and the live URL were purged first and verified 404 by anonymous fetch before this commit.
212 lines
16 KiB
JavaScript
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 (see data-private/README.md)'],
|
|
[/"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)
|