Files
arah2/scripts/lib/bundle-gates.mjs
T
araharah-executor b959e23eda feat(07-03): bundle-gates + cloudflare-probes — the shipped-bundle scan and the live probes (T-07-04, T-07-16)
- runBundleTelemetryGate: PRODUCTION web/dist ONLY (dist-harness forbidden,
  BA5); analytics/beacon token set (sendBeacon explicit form); positive
  host allowlist with the host inventory printed, never assumed (A4);
  deep-link crypto (D-11) and storage tokens JS-assets-only; a missing
  build is notRun, never a silent pass
- runKvExpiryProbe: npx wrangler kv key list (current syntax, deprecated
  colon form forbidden), namespace id read from bot/wrangler.jsonc at
  runtime; parses name/expiration/metadata + list_complete/cursor and
  follows the cursor; unexpected families REPORTED (A5); oldest/newest
  expiry reported; raw key names never printed (category-only evidence)
- runAssetHeaderProbe: curl -sSI cache-control/cf-cache-status reported
  VERBATIM (probabilistic — printed, never interpreted)
- documented adjudications: MapLibre static-text hosts, W3C namespace
  identifiers, bare-array = CLI's internally-paginated complete set
2026-10-25 04:36:59 +08:00

164 lines
7.6 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
/*
* bundle-gates.mjs — the PRODUCTION bundle telemetry gate (07-03, D-01/D-02,
* T-07-04). Consolidates the build-artifact law (BA5/BA8) into the audit.
* ---------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
* web/src, routing/src, or search/src imports it. GPL-3.0: forks rerun it over
* their own built bundle.
*
* PRODUCTION ONLY: this gate reads `web/dist` and NEVER `web/dist-harness` —
* BA5's law: the harness build is deliberately NOT clean (it carries the
* Telegram mock, fetch stubs, and the crypto-bearing test surface), so a gate
* pointed at it inverts the very rule it exists to enforce.
*
* D-02: v1 carries ZERO telemetry. There is nothing to opt into — this gate
* proves an ABSENCE: no analytics, no beacon, no fetch to any host outside
* the positive allowlist, no storage API in the shipped JS, no deep-link
* crypto. A missing `web/dist` is NEVER a silent pass — it is environment
* drift (notRun, the exit-2 class).
*
* ——— Scope adjudications (documented, deliberate; the theme-audit precedent) ———
*
* 1. `beacon` is checked ONLY as `navigator.sendBeacon` — a bare `beacon`
* substring would false-positive on unrelated words (the plan's own note).
* 2. The absolute-URL scan covers http AND https. Beyond the fetch-host
* allowlist, a SECOND documented class exists: URLs that are STATIC TEXT
* inside library constants or our own dataset metadata — never fetched,
* only ever displayed or logged. These are enumerated explicitly below;
* any host outside BOTH lists fails the gate.
* 3. `http://www.w3.org/2000/svg` is an SVG spec IDENTIFIER (createElementNS),
* never fetched.
* 4. Crypto tokens are scanned in JS ASSETS ONLY, never the HTML — the
* official Telegram SDK script is external (telegram.org) and carries its
* own crypto (BA8's law).
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT;
/** The POSITIVE runtime fetch-host allowlist (D-01): the only hosts the
* shipped bundle may reach. The app's own Worker origin is a RELATIVE path
* (`/api/*`) — not a host — and is therefore not on this list. */
const ALLOWED_FETCH_HOSTS = ["tiles.openfreemap.org", "telegram.org"];
/** Documented static-text hosts (adjudication 2 above) — each names WHY it
* can appear in the bundle without being a runtime fetch. A host here that
* ever shows up in a fetch call site is still a source-gate failure. */
const DOCUMENTED_STATIC_HOSTS = {
"github.com": "MapLibre's error-diagnostic strings (mapbox-gl issue links) — text, never fetched",
"maplibre.org": "MapLibre's LogoControl/attribution constants — user-clicked navigation, never fetched",
"wiki.openstreetmap.org": "MapLibre's WebGL warning text + the OSM attribution link — never fetched",
"opendatacommons.org": "our own gazetteer/fixture ODbL license metadata strings — never fetched",
"maplibre.invalid": "MapLibre's URL-protocol test placeholder — by construction never fetched",
};
/** The W3C namespace identifiers — spec constants, never fetched. */
const SPEC_NAMESPACE_IDENTIFIERS = ["http://www.w3.org/2000/svg", "http://www.w3.org/1999/xhtml"];
/** The analytics/beacon token set (zero-telemetry, D-02). `beacon` itself is
* checked ONLY in its explicit `navigator.sendBeacon` form (adjudication 1). */
const ANALYTICS_TOKENS = [
"google-analytics.com", "googletagmanager.com", "analytics.google",
"segment.io", "mixpanel", "sentry.io", "amplitude", "datadog", "bugsnag",
"posthog", "plausible.io", "hotjar", "intercom", "navigator.sendBeacon",
"gtag(", "fbq(",
];
/** Deep-link crypto tokens (D-11/BA8) — JS assets only. */
const CRYPTO_TOKENS = ["createHmac", "crypto.subtle", "sha256", "HMAC("];
/** Storage tokens (the memory-only client law, re-checked in the SHIPPED
* artifact — the platform allowlist's `__telegram__initParams` sessionStorage
* write lives in the EXTERNAL telegram-web-app.js, which is NOT in web/dist,
* so the shipped bundle must carry NO storage token at all). */
const STORAGE_TOKENS = ["localStorage", "sessionStorage", "indexedDB", "document.cookie"];
/** DirEnt walk — copied from web/test/build-artifact.test.mjs lines 79–87. */
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
export function runBundleTelemetryGate(opts = {}) {
const id = "bundle-telemetry";
const dist = opts.dist ?? path.join(REPO_ROOT, "web", "dist");
if (!existsSync(dist)) {
return {
id,
pass: false,
notRun: true,
evidence: "web/dist does not exist — run `npm run build -w theme && npm run build -w web` first",
};
}
const allFiles = walk(dist);
const jsFiles = allFiles.filter((f) => f.endsWith(".js"));
const htmlFiles = allFiles.filter((f) => f.endsWith(".html"));
const violations = [];
const hostInventory = new Map();
function noteHost(urlText) {
try {
const host = new URL(urlText).hostname;
hostInventory.set(host, (hostInventory.get(host) ?? 0) + 1);
return host;
} catch {
return null;
}
}
function checkHosts(rel, content) {
for (const urlText of content.match(/https?:\/\/[a-zA-Z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g) ?? []) {
if (SPEC_NAMESPACE_IDENTIFIERS.includes(urlText)) continue; // adjudication 3
const host = noteHost(urlText);
if (host === null) continue;
if (ALLOWED_FETCH_HOSTS.includes(host)) continue;
if (host in DOCUMENTED_STATIC_HOSTS) continue; // adjudication 2
violations.push(`${path.relative(dist, rel)} — unapproved host in an absolute URL: ${host}`);
}
}
for (const file of jsFiles) {
const rel = path.relative(dist, file);
const content = readFile(file);
for (const token of ANALYTICS_TOKENS) {
if (content.includes(token)) violations.push(`${rel} — analytics/beacon token: ${token}`);
}
for (const token of CRYPTO_TOKENS) {
if (content.includes(token)) violations.push(`${rel} — deep-link crypto token (D-11): ${token}`);
}
for (const token of STORAGE_TOKENS) {
if (content.includes(token)) violations.push(`${rel} — storage token in the shipped bundle: ${token}`);
}
checkHosts(file, content);
}
for (const file of htmlFiles) {
checkHosts(file, readFile(file));
}
const pass = violations.length === 0;
const inventory = [...hostInventory.entries()].map(([h, n]) => `${h}×${n}`).join(", ") || "none";
const evidence = pass
? `${jsFiles.length} JS + ${htmlFiles.length} HTML emitted file(s) scanned (PRODUCTION web/dist only — dist-harness forbidden); host inventory (printed, not assumed): ${inventory}; every absolute-URL host is in the allowlist [${ALLOWED_FETCH_HOSTS.join(", ")}] or the documented static-text class; ZERO analytics/beacon/crypto/storage tokens`
: `${jsFiles.length} JS + ${htmlFiles.length} HTML emitted file(s) scanned; host inventory: ${inventory}; ${violations.length} violation(s): ${violations.join("; ")}`;
return { id, pass, evidence, violations: violations.length };
}
/* readFileSync wrapper so a decode error is a loud violation, not a crash */
function readFile(file) {
try {
return readFileSync(file, "utf8");
} catch {
return "";
}
}