feat(07-03): source-gates library — storage/TTL/log-vocab/runtime-host gates over ALL five src trees (D-01, T-07-01..03)

- runStorageGate: the canonical comment-stripped BANNED-persistence +
  service-worker scan, extended repo-wide (web/bot/routing/search/theme src)
- runKvTtlGate: PG1's same-line expirationTtl law with the named-constant
  ^[A-Z_]{2,}$ requirement, repo-wide (T-07-01)
- runLogVocabularyGate: the D-14 category-only law machine-verified —
  bracketed surface tokens, carrier ban (chat_id/user_id whole-word),
  web state-token vocabulary, JSON.stringify-in-log forbidden (T-07-03)
- runRuntimeHostGate: POSITIVE host allowlist; inventory printed, never
  assumed (A4); cwd-independent root + documented SVG-namespace adjudication
This commit is contained in:
araharah-executor
2026-10-25 04:34:32 +08:00
parent 87fcb2833e
commit 430993b881
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env node
/*
* source-gates.mjs — the repo-level source privacy gates (07-03, D-01).
* ---------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
* web/src, routing/src, or search/src imports it (the law from
* routing/scripts/measure-route-cpu.mjs lines 3–9). GPL-3.0: a fork reruns
* these gates over ITS OWN trees before its first driver.
*
* The gate lineage this consolidates (extended repo-wide, not rewritten):
* storage-api ← web/test/no-storage.test.mjs GEO3 (the memory-only
* client law) + the service-worker ban
* kv-ttl ← bot/test/privacy.test.mjs PG1 (same-line
* expirationTtl referencing a NAMED constant)
* log-vocabulary ← bot/src/webhook.ts D-14 (category-only logs; the
* web-side ARAHARAH-* state tokens)
* runtime-hosts ← D-01's POSITIVE fetch-host allowlist (the host
* inventory is PRINTED, never assumed — A4)
*
* Every gate returns { id, pass, evidence } (07-RESEARCH Pattern 4) and is
* safe to call with no arguments in production. `opts` exists ONLY for the
* test suite (fixture-root injection); production callers pass nothing.
*
* THE COMMENT STRIPPER IS COPIED VERBATIM from web/test/no-storage.test.mjs
* lines 29–42 (the canonical twin also lives in bot/test/route-privacy.test.mjs
* lines 42–59). Its over-stripping is DELIBERATE — "prefer-fail reading": a
* prose mention that survives stripping fails the gate LOUDLY; do NOT write a
* smarter stripper (07-RESEARCH Pitfall 6). The line-numbered gates
* (kv-ttl, log-vocabulary) scan raw lines with only the `// …` tail removed
* per line, so evidence line numbers stay honest; prose inside a block
* comment that mentions a scanned token fails prefer-fail too.
*
* PRIVACY_AUDIT_ROOT: the repo root is derived from THIS module's URL — the
* gates are cwd-INDEPENDENT (a stray subdirectory run must not scan the wrong
* tree and false-pass). The env override exists for the test suite and for a
* fork auditing a checkout elsewhere.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT;
/* ————— the canonical helpers (copied verbatim — see header) ————— */
export function listFiles(dir, exts, acc = []) {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) listFiles(full, exts, acc);
else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
}
return acc;
}
export function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading)
}
/** Line-preserving variant for line-numbered evidence: removes ONLY the
* `// …` tail of a single raw line (the same guard as the stripper, so
* `https://` URLs inside strings stay alive). Block-comment prose mentioning
* a scanned token fails prefer-fail — documented in the header. */
function stripLineComment(line) {
return line.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
}
/* ————— the scan roots: EVERY production source tree, repo-wide ————— */
const ALL_TREES = ["web/src", "bot/src", "routing/src", "search/src", "theme/src"].map((name) => ({
name,
dir: path.join(REPO_ROOT, ...name.split("/")),
}));
const CODE_EXTS = [".ts", ".tsx", ".js", ".mjs", ".html"];
function scanRoots(roots, exts) {
const files = [];
const skipped = [];
for (const { name, dir } of roots) {
if (!existsSync(dir)) {
skipped.push(name);
continue;
}
for (const file of listFiles(dir, exts)) files.push({ name, file });
}
return { files, skipped };
}
/* ————— storage-api — the memory-only client law, extended repo-wide ————— */
/** The BANNED persistence vocabulary (web/test/no-storage.test.mjs lines 22–25)
* plus the service-worker family (the GEO3 "and none was added" half). */
const BANNED_PERSISTENCE = [
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
];
const BANNED_SERVICE_WORKER = ["serviceWorker.register", "navigator.serviceWorker", "workbox"];
export function runStorageGate(opts = {}) {
const id = "storage-api";
const roots = opts.roots ?? ALL_TREES;
const { files, skipped } = scanRoots(roots, CODE_EXTS);
const violations = [];
for (const { file } of files) {
const code = stripComments(readFileSync(file, "utf8"));
for (const banned of [...BANNED_PERSISTENCE, ...BANNED_SERVICE_WORKER]) {
if (code.includes(banned)) violations.push(`${path.relative(REPO_ROOT, file)}: ${banned}`);
}
}
const pass = violations.length === 0;
const skippedNote = skipped.length > 0 ? ` (skipped, absent: ${skipped.join(", ")})` : "";
const evidence = pass
? `${files.length} file(s) across ${roots.length - skipped.length} src tree(s) — no persistence API outside the platform allowlist${skippedNote}`
: `${files.length} file(s) scanned${skippedNote}; ${violations.length} violation(s): ${violations.join("; ")}`;
return { id, pass, evidence, violations: violations.length };
}
/* ————— kv-ttl — the PG1 same-line law, extended repo-wide (T-07-01) ————— */
export function runKvTtlGate(opts = {}) {
const id = "kv-ttl";
const roots = opts.roots ?? ALL_TREES;
const { files, skipped } = scanRoots(roots, CODE_EXTS);
const violations = [];
let writes = 0;
for (const { file } of files) {
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (line.includes(".put(")) {
writes += 1;
const ok =
line.includes("expirationTtl") &&
/^[A-Z_]{2,}$/.test(line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1] ?? "");
if (!ok) violations.push(`${path.relative(REPO_ROOT, file)}:${i + 1} — KV write without a named expirationTtl: ${line.trim()}`);
}
});
}
const pass = violations.length === 0;
const skippedNote = skipped.length > 0 ? ` (skipped, absent: ${skipped.join(", ")})` : "";
const evidence = pass
? `${writes} KV write(s) across ${files.length} file(s) — every .put( line carries expirationTtl referencing a named ^[A-Z_]{2,}$ constant${skippedNote}`
: `${writes} KV write(s) scanned${skippedNote}; ${violations.length} violation(s): ${violations.join("; ")}`;
return { id, pass, evidence, violations: violations.length };
}
/* ————— log-vocabulary — the category-only law, machine-verified (T-07-03) ————— */
/** Coordinate/identifier carriers FORBIDDEN on any bot/src log line. `lat`,
* `lng`, `token`, `initData`, `geometry`, `polyline`, `payload` are checked
* as case-sensitive substrings; `chat_id`/`user_id` as WHOLE WORDS so the
* `[location]` surface token never trips the `chat` substring. Comments are
* stripped first (the "never the token" prose on the call lines is not code). */
const BANNED_CARRIERS = ["lat", "lng", "token", "initData", "geometry", "polyline", "payload"];
const BANNED_CARRIER_WORDS = ["chat_id", "user_id"];
/** bot/src shape: `console.log(` + a quote + a bracketed surface token. */
const BOT_LOG_SHAPE = /console\.log\(\s*["'`]\[([a-z-]+)\]/;
/** web/src vocabulary: the ARAHARAH-* state tokens / the basemap signal. */
const WEB_LOG_ALLOWED = [
/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/,
/ARAHARAH-LIVE\s+fix=/,
/RAMS-BASEMAP-READY/,
];
function violationsInTree(dir, isBot, violations) {
if (!existsSync(dir)) return 0;
let logs = 0;
for (const file of listFiles(dir, [".ts", ".tsx", ".js", ".mjs"])) {
const rel = path.relative(REPO_ROOT, file);
const lines = readFileSync(file, "utf8").split("\n");
lines.forEach((rawLine, i) => {
const line = stripLineComment(rawLine);
if (!/console\.log\(/.test(line)) return;
logs += 1;
const from = line.indexOf("console.log(");
const args = line.slice(from);
if (args.includes("JSON.stringify(")) {
violations.push(`${rel}:${i + 1} — a serialized payload inside a log argument: ${line.trim()}`);
return;
}
if (isBot) {
if (!BOT_LOG_SHAPE.test(line)) {
violations.push(`${rel}:${i + 1} — bot log without a bracketed surface token: ${line.trim()}`);
return;
}
for (const carrier of BANNED_CARRIERS) {
if (line.includes(carrier)) {
violations.push(`${rel}:${i + 1} — bot log carries the "${carrier}" carrier: ${line.trim()}`);
return;
}
}
for (const word of BANNED_CARRIER_WORDS) {
if (new RegExp(`\\b${word}\\b`).test(line)) {
violations.push(`${rel}:${i + 1} — bot log carries the "${word}" identifier: ${line.trim()}`);
return;
}
}
} else {
if (!WEB_LOG_ALLOWED.some((rx) => rx.test(line))) {
violations.push(`${rel}:${i + 1} — web log outside the state-token vocabulary: ${line.trim()}`);
}
}
});
}
return logs;
}
export function runLogVocabularyGate(opts = {}) {
const id = "log-vocabulary";
const botRoot = opts.botRoot ?? path.join(REPO_ROOT, "bot", "src");
const webRoot = opts.webRoot ?? path.join(REPO_ROOT, "web", "src");
const violations = [];
const botLogs = violationsInTree(botRoot, true, violations);
const webLogs = violationsInTree(webRoot, false, violations);
const pass = violations.length === 0;
const evidence = pass
? `${botLogs} bot + ${webLogs} web console.log call site(s) — every bot log opens with a bracketed surface token and carries NO lat/lng/chat_id/user_id/token/initData/geometry/polyline/payload carrier, every web log is an ARAHARAH-* state token or the RAMS-BASEMAP-READY signal, and no log serializes a payload`
: `${botLogs + webLogs} log call site(s) scanned; ${violations.length} violation(s): ${violations.join("; ")}`;
return { id, pass, evidence, violations: violations.length };
}
/* ————— runtime-hosts — the POSITIVE allowlist, inventory printed (A4) ————— */
/** The ONLY hosts a fetch target may name (D-01). A relative path like
* `/api/guidance` is the app's own Worker origin — not a host — and is
* allowed. */
const ALLOWED_FETCH_HOSTS = ["tiles.openfreemap.org", "telegram.org"];
/** Documented, deliberate scope adjudication (the theme-audit precedent):
* the SVG namespace URIs (`http://www.w3.org/2000/svg`) are spec
* IDENTIFIERS, never fetched. Any other http:// host fails prefer-fail. */
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
/** Extract the hostname of an absolute URL string, or null. */
function hostOf(urlText) {
try {
return new URL(urlText).hostname;
} catch {
return null;
}
}
export function runRuntimeHostGate(opts = {}) {
const id = "runtime-hosts";
const roots = opts.roots ?? [
...ALL_TREES.filter((t) => t.name !== "theme/src"),
{ name: "theme/src", dir: path.join(REPO_ROOT, "theme", "src") },
{ name: "web/index.html", dir: path.join(REPO_ROOT, "web", "index.html"), singleFile: true },
];
const violations = [];
const hostInventory = new Map(); // host → count
const fetchTargets = [];
let fetchDeclarations = 0;
function noteHost(urlText) {
const host = hostOf(urlText);
if (host === null) return;
hostInventory.set(host, (hostInventory.get(host) ?? 0) + 1);
}
function checkUrl(rel, urlText, where) {
noteHost(urlText);
const host = hostOf(urlText);
if (host === null) return; // not a parseable URL — leave it to the bundle scan / live probe
if (urlText === SVG_NAMESPACE) return; // documented adjudication — a spec identifier, never fetched
if (ALLOWED_FETCH_HOSTS.includes(host)) return;
violations.push(`${rel} — absolute URL to an unapproved host (${where}): ${host}`);
}
const { files, skipped } = scanRoots(roots.filter((r) => !r.singleFile), CODE_EXTS);
for (const { file } of files) {
const rel = path.relative(REPO_ROOT, file);
const code = stripComments(readFileSync(file, "utf8"));
for (const urlText of code.match(/https?:\/\/[a-zA-Z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g) ?? []) {
checkUrl(rel, urlText, "URL literal");
}
// fetch( call sites: the FIRST argument must be relative (or an approved
// host) — never an absolute URL to an unapproved host. TS method
// DECLARATIONS (`fetch(req: Request, …)`) are skipped — the annotation
// colon identifies them.
for (const m of code.matchAll(/\bfetch\s*\(/g)) {
const rest = code.slice(m.index + m[0].length, code.indexOf("\n", m.index));
const argText = rest.split(/(?<![^,(]\()[,)]/)[0]?.trim() ?? rest.trim();
if (argText.includes(":") || argText === "") {
fetchDeclarations += 1;
continue;
}
const literal = argText.match(/^["'`]([^"'`]*)/);
if (literal === null) {
violations.push(`${rel} — fetch( first argument is dynamic, not statically verifiable: ${argText}`);
continue;
}
const target = literal[1];
fetchTargets.push(`${rel} — ${target}`);
if (target.startsWith("/")) continue; // relative — the app's own Worker origin
if (/^https?:\/\//.test(target)) {
checkUrl(rel, target, "fetch target");
continue;
}
violations.push(`${rel} — fetch( target is neither relative nor an approved absolute URL: ${target}`);
}
}
// web/index.html — the single entry shared by both builds (the Telegram SDK script tag).
const indexHtml = path.join(REPO_ROOT, "web", "index.html");
if (existsSync(indexHtml)) {
const code = stripComments(readFileSync(indexHtml, "utf8"));
for (const urlText of code.match(/https?:\/\/[a-zA-Z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g) ?? []) {
checkUrl("web/index.html", urlText, "URL literal");
}
}
const pass = violations.length === 0;
const inventory = [...hostInventory.entries()].map(([h, n]) => `${h}×${n}`).join(", ") || "none";
const skippedNote = skipped.length > 0 ? ` (skipped, absent: ${skipped.join(", ")})` : "";
const evidence = pass
? `host inventory (printed, not assumed): ${inventory}; ${fetchTargets.length} relative fetch target(s) to the app's own Worker, ${fetchDeclarations} fetch declaration(s) skipped; every absolute URL host is in the positive allowlist [${ALLOWED_FETCH_HOSTS.join(", ")}]${skippedNote}`
: `host inventory: ${inventory}; ${violations.length} violation(s): ${violations.join("; ")}`;
return { id, pass, evidence, violations: violations.length };
}