Files
arah2/scripts/lib/source-gates.mjs
T
araharah-executor 430993b881 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
2026-10-25 04:34:32 +08:00

324 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 };
}