#!/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 ""; } }