diff --git a/scripts/test/privacy-audit.test.mjs b/scripts/test/privacy-audit.test.mjs new file mode 100644 index 0000000..6ea3f46 --- /dev/null +++ b/scripts/test/privacy-audit.test.mjs @@ -0,0 +1,295 @@ +#!/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(); + } +}); + +/* ————— 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 { + for (const probe of ["runKvExpiryProbe", "runAssetHeaderProbe"]) { + const r = await m[probe](); + assert.equal(r.pass, false, `${probe} without credentials is pass:false`); + assert.equal(r.notRun, true, `${probe} without credentials 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\)/, "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`);