321 lines
16 KiB
JavaScript
321 lines
16 KiB
JavaScript
#!/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": "<script src=\"https://telegram.org/js/telegram-web-app.js\"></script>\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`);
|