From b959e23edab06f32d317313c26628a3a87d5dc92 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 04:36:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(07-03):=20bundle-gates=20+=20cloudflare-pr?= =?UTF-8?q?obes=20=E2=80=94=20the=20shipped-bundle=20scan=20and=20the=20li?= =?UTF-8?q?ve=20probes=20(T-07-04,=20T-07-16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/lib/bundle-gates.mjs | 163 +++++++++++++++++++++ scripts/lib/cloudflare-probes.mjs | 233 ++++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 scripts/lib/bundle-gates.mjs create mode 100644 scripts/lib/cloudflare-probes.mjs diff --git a/scripts/lib/bundle-gates.mjs b/scripts/lib/bundle-gates.mjs new file mode 100644 index 0000000..bd47719 --- /dev/null +++ b/scripts/lib/bundle-gates.mjs @@ -0,0 +1,163 @@ +#!/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 ""; + } +} diff --git a/scripts/lib/cloudflare-probes.mjs b/scripts/lib/cloudflare-probes.mjs new file mode 100644 index 0000000..984b0ba --- /dev/null +++ b/scripts/lib/cloudflare-probes.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/* + * cloudflare-probes.mjs — the LIVE probes (kv-expiry, asset-headers) + * (07-03, D-01, T-07-01/T-07-16). No in-repo analog existed — the only live-KV + * precedent is the manual command recorded in 06-DEVICE-GATE.md step 7. + * --------------------------------------------------------------------------- + * 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 the + * probes against their OWN namespace (read from their own bot/wrangler.jsonc). + * + * LIVE ONLY: both probes need credentials + a deployed origin. Without them + * each probe degrades to `probeUnavailable(...)` — { pass:false, notRun:true } + * — and the CLI maps notRun to exit code 2 (environment drift). A skipped + * probe is NEVER mistaken for a pass (the no-silent-passes contract). + * + * Category-only output law: the probe's evidence NEVER prints a raw KV key + * name (session: / loc: carry identifiers) — only family + * counts, expiry bounds, and header lines. + * + * Pagination (07-RESEARCH Pitfall 7 — key presence alone does not prove + * TTL-only): the wrangler envelope shape carries `list_complete` + `cursor` + * and the cursor is FOLLOWED until list_complete; a bare-array shape means + * the CLI paginated internally and returned the complete set (wrangler's + * documented behavior for `kv key list`), and the evidence says which shape + * was seen. + */ +import { execFileSync } from "node:child_process"; +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; +const WRANGLER_JSONC = path.join(REPO_ROOT, "bot", "wrangler.jsonc"); + +/** The four TTL-only key families (the KV storage contract). An unexpected + * family is REPORTED, never assumed absent (07-RESEARCH A5). */ +export const KEY_FAMILIES = ["session:", "chat:", "loc:", "guidance:"]; + +/** A missing environment is the exit-2 class: pass:false + notRun:true. */ +export function probeUnavailable(reason) { + return { id: "probe", pass: false, notRun: true, evidence: `NOT RUN — ${reason}` }; +} + +function notRun(id, reason) { + return Object.assign(probeUnavailable(reason), { id }); +} + +/** Read the KV namespace id from bot/wrangler.jsonc AT RUNTIME (never + * hard-coded twice). jsonc: strip line comments without eating `https://` + * inside values — the `(^|[^:])//` guard (the repo stripper's line rule). */ +function readNamespaceId() { + if (!existsSync(WRANGLER_JSONC)) return null; + const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); + return stripped.match(/kv_namespaces[\s\S]*?"id"\s*:\s*"([0-9a-f]+)"/)?.[1] ?? null; +} + +/** Read the deployed Mini App origin from bot/wrangler.jsonc vars (public + * configuration — never a secret). */ +function readMiniAppUrl() { + if (!existsSync(WRANGLER_JSONC)) return null; + const stripped = readFileSync(WRANGLER_JSONC, "utf8").replace(/(^|[^:])\/\/[^\n]*/g, "$1"); + return stripped.match(/"MINI_APP_URL"\s*:\s*"([^"]+)"/)?.[1] ?? null; +} + +function wranglerAvailable() { + if (existsSync(path.join(REPO_ROOT, "bot", "node_modules", ".bin", "wrangler"))) return true; + try { + execFileSync("wrangler", ["--version"], { encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] }); + return true; + } catch { + return false; + } +} + +/** + * The live KV listing probe: `npx wrangler kv key list --namespace-id=` + * (the CURRENT wrangler syntax — the deprecated `kv:key` colon form is + * forbidden). Parses each key's `name`, optional `expiration`, optional + * `metadata`, the `list_complete` flag and the `cursor`; follows the cursor + * until list_complete; asserts (1) every key's family prefix is one of the + * four TTL-only families, (2) every listed key carries an `expiration`, + * (3) reports the OLDEST and NEWEST expiry it saw. + */ +export function runKvExpiryProbe(opts = {}) { + const id = "kv-expiry"; + const missing = []; + if (!process.env.CLOUDFLARE_API_TOKEN || !process.env.CLOUDFLARE_ACCOUNT_ID) missing.push("Cloudflare credentials"); + const namespaceId = opts.namespaceId ?? readNamespaceId(); + if (!namespaceId) missing.push("KV namespace id (bot/wrangler.jsonc)"); + if (!wranglerAvailable()) missing.push("wrangler CLI"); + if (missing.length > 0) return notRun(id, `missing ${missing.join(" + ")}`); + + /** @type {Array<{name: string, expiration?: number, metadata?: unknown}>} */ + const keys = []; + let cursor; + let listComplete = null; // null = the shape carried no list_complete metadata + try { + do { + const args = ["wrangler", "kv", "key", "list", `--namespace-id=${namespaceId}`]; + if (cursor !== undefined) args.push(`--cursor=${cursor}`); + const out = execFileSync("npx", args, { + cwd: path.join(REPO_ROOT, "bot"), + encoding: "utf8", + timeout: 120000, + maxBuffer: 1 << 26, + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const parsed = JSON.parse(extractJson(out)); + if (Array.isArray(parsed)) { + keys.push(...parsed); // bare array — wrangler paginated internally (documented CLI behavior) + } else if (Array.isArray(parsed?.keys)) { + keys.push(...parsed.keys); + listComplete = parsed.list_complete ?? null; + cursor = parsed.cursor ?? undefined; + } else { + return notRun(id, "unrecognized wrangler kv key list output shape"); + } + } while (cursor !== undefined && listComplete === false); + } catch (err) { + return notRun(id, `wrangler kv key list failed (${categoryOf(err)})`); + } + + const familyCounts = new Map(); + const violations = []; + const expirations = []; + for (const key of keys) { + const name = typeof key?.name === "string" ? key.name : ""; + const family = name.slice(0, name.indexOf(":") + 1); + const familyLabel = family || "(no family prefix)"; + if (!KEY_FAMILIES.includes(family)) { + // A5: report the unexpected FAMILY (the prefix only — never the raw + // key name, which carries a token or a chat id). + if (!familyCounts.has(familyLabel)) familyCounts.set(familyLabel, 0); + familyCounts.set(familyLabel, familyCounts.get(familyLabel) + 1); + violations.push(`unexpected key family: ${familyLabel}`); + continue; + } + familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1); + if (typeof key.expiration !== "number" || key.expiration <= 0) { + violations.push(`a key carries NO expiration (TTL-less live key)`); + } else { + expirations.push(key.expiration); + } + } + + const oldest = expirations.length > 0 ? new Date(Math.min(...expirations) * 1000).toISOString() : "n/a"; + const newest = expirations.length > 0 ? new Date(Math.max(...expirations) * 1000).toISOString() : "n/a"; + const shape = + listComplete === null + ? "wrangler returned the complete set (it paginates internally; list_complete/cursor metadata not exposed by this CLI shape)" + : `list_complete=${listComplete}${cursor !== undefined ? " (cursor followed)" : ""}`; + const counts = [...familyCounts.entries()].map(([f, n]) => `${f}${n}`).join(", ") || "no keys"; + const pass = violations.length === 0; + const evidence = pass + ? `${keys.length} live key(s) [${counts}]; every key carries an expiration; oldest expiry ${oldest}, newest ${newest}; ${shape}` + : `${keys.length} live key(s) [${counts}]; ${shape}; ${violations.length} violation(s): ${violations.join("; ")}`; + return { id, pass, evidence, violations: violations.length }; +} + +/** Pull the outermost JSON value out of wrangler's stdout (it may interleave + * non-JSON banner lines around the payload). */ +function extractJson(out) { + const start = out.search(/[[{]/); + const end = Math.max(out.lastIndexOf("]"), out.lastIndexOf("}")); + return out.slice(start, end + 1); +} + +function categoryOf(err) { + if (err?.code === "ETIMEDOUT" || /timed? ?out/i.test(String(err?.message))) return "timed out"; + return "non-zero exit / network error"; // category only — never err text (it can echo env/paths) +} + +/** + * The live static-asset header probe: `curl -sSI` the HTML entry and one + * hashed /assets/* file, reporting the observed `cache-control` and + * `cf-cache-status` headers VERBATIM. CF-Cache-Status is probabilistic per + * Cloudflare's own footnote — the evidence prints it, it does not interpret + * it as a guarantee (07-RESEARCH Pitfall 10). + */ +export function runAssetHeaderProbe(opts = {}) { + const id = "asset-headers"; + const baseUrl = opts.baseUrl ?? process.env.MINI_APP_URL ?? readMiniAppUrl(); + if (!baseUrl) return notRun(id, "no deployed origin (MINI_APP_URL / bot/wrangler.jsonc vars)"); + if (!curlAvailable()) return notRun(id, "curl CLI not available"); + + const dist = path.join(REPO_ROOT, "web", "dist"); + let assetPath = null; + if (existsSync(path.join(dist, "assets"))) { + const asset = readdirSync(path.join(dist, "assets")).find((f) => f.endsWith(".js")); + if (asset) assetPath = `/assets/${asset}`; + } + + const lines = []; + let violations = 0; + const targets = [["HTML entry /", `${baseUrl}/`]]; + if (assetPath !== null) targets.push([`hashed asset ${assetPath}`, `${baseUrl}${assetPath}`]); + try { + for (const [label, url] of targets) { + const out = execFileSync("curl", ["-sSI", "--max-time", "20", url], { + encoding: "utf8", + timeout: 30000, + stdio: ["ignore", "pipe", "pipe"], + }); + const cacheControl = out.match(/^cache-control:.*$/im)?.[0]?.trim() ?? null; + const cfStatus = out.match(/^cf-cache-status:.*$/im)?.[0]?.trim() ?? null; + if (cacheControl === null) { + violations += 1; + lines.push(`${label}: no cache-control header observed`); + } else { + lines.push(`${label}: ${cacheControl}${cfStatus !== null ? ` | ${cfStatus}` : " | cf-cache-status: not reported by this response"}`); + } + } + } catch (err) { + return notRun(id, `origin unreachable (${categoryOf(err)})`); + } + + const pass = violations === 0; + const evidence = pass + ? `${targets.length} response header(s), VERBATIM (cf-cache-status is probabilistic — reported, not interpreted as a guarantee): ${lines.join(" || ")}` + : `${violations.length} violation(s): ${lines.join(" || ")}`; + return { id, pass, evidence, violations }; +} + +function curlAvailable() { + try { + execFileSync("curl", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }); + return true; + } catch { + return false; + } +}