- 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
164 lines
7.6 KiB
JavaScript
164 lines
7.6 KiB
JavaScript
#!/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 "";
|
||
}
|
||
}
|