feat(07-03): the privacy-audit CLI + root npm script — orchestration, evidence report, 0/1/2 exit codes

- runs the five static gates in Pattern-4 order; --live adds the KV-expiry
  and asset-header probes; the no-flag skip prints '(pass --live)' and is
  never silently green
- exit 0 CLEAN / 1 FAILED (violations counted across gates) / 2 NOT RUN
  (credentials or build or deployment drift — never mistaken for a pass)
- header states the maintainer-time-tool law, the GPL fork-reuse contract,
  and D-02's zero-telemetry prove-the-absence posture
- AU1-3 CLI vectors green: CLEAN exit 0 on this tree, FAILED exit 1 via the
  PRIVACY_AUDIT_ROOT fixture, NOT RUN exit 2 for --live without credentials
This commit is contained in:
araharah-executor
2026-10-25 04:39:07 +08:00
parent b959e23eda
commit 9c55e9a879
2 changed files with 96 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/*
* privacy-audit.mjs — the ONE consolidated privacy audit (07-03, D-01/D-02).
* ---------------------------------------------------------------------------
* 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: a fork reruns
* `npm run privacy-audit` over its own trees before its FIRST driver — the
* audit doubles as the fork onboarding check.
*
* D-02: v1 carries ZERO telemetry — opt-in telemetry stays an IDEAS.md
* aspiration post-validation; there is nothing to opt into yet. This audit
* proves an ABSENCE, it does not audit a consent surface that does not exist:
* no analytics, no beacon, no fetch to any host outside the positive
* allowlist, no persistence outside the TTL-only KV families, no coordinate
* or payload in any log line.
*
* What it runs, in order (07-RESEARCH Pattern 4):
* runStorageGate() id: storage-api — the memory-only law, repo-wide
* runKvTtlGate() id: kv-ttl — the PG1 same-line TTL law, repo-wide
* runLogVocabularyGate() id: log-vocabulary — the D-14 category-only law
* runRuntimeHostGate() id: runtime-hosts — the POSITIVE fetch-host allowlist
* runBundleTelemetryGate() id: bundle-telemetry — the PRODUCTION web/dist scan
* [--live] runKvExpiryProbe() id: kv-expiry — LIVE: expiry metadata + cursor
* [--live] runAssetHeaderProbe() id: asset-headers — LIVE: cache headers VERBATIM
*
* Usage: node scripts/privacy-audit.mjs [--live] (the `privacy-audit` npm script)
*
* Exit codes (no silent passes — the theme-audit contract, T-1-07):
* 0 clean — every required gate PASSed
* 1 violations — each printed as a per-gate evidence line above
* 2 NOT RUN — environment/credential drift (a --live probe could not run
* because credentials are absent, or the build is absent); a skipped
* probe is NEVER mistaken for a pass (T-07-16)
*
* PRIVACY_AUDIT_ROOT: repo root override (test suite / fork checkouts). The
* gates are otherwise cwd-INDEPENDENT — a stray subdirectory run must not
* scan the wrong tree and false-pass.
*
* Category-only output law: this script NEVER prints a coordinate, a chat
* id, a user id, a token, or an initData payload — its evidence is
* category-only, exactly like the bot's logs (D-14).
*/
import {
runStorageGate,
runKvTtlGate,
runLogVocabularyGate,
runRuntimeHostGate,
} from "./lib/source-gates.mjs";
import { runBundleTelemetryGate } from "./lib/bundle-gates.mjs";
import { runKvExpiryProbe, runAssetHeaderProbe } from "./lib/cloudflare-probes.mjs";
const live = process.argv.includes("--live");
/** The five static gates — always required. */
const results = [
await runStorageGate(),
await runKvTtlGate(),
await runLogVocabularyGate(),
await runRuntimeHostGate(),
await runBundleTelemetryGate(),
];
if (live) {
results.push(await runKvExpiryProbe(), await runAssetHeaderProbe());
} else {
// Flag-skip placeholders — visible, honest, and NEVER green: the skip is
// the operator's explicit choice (no --live), not an environment failure,
// so it is excluded from the exit-2 class below.
results.push(
{ id: "kv-expiry", pass: false, notRun: true, flagSkip: true, evidence: "(pass --live)" },
{ id: "asset-headers", pass: false, notRun: true, flagSkip: true, evidence: "(pass --live)" },
);
}
for (const result of results) {
const status = result.pass ? "PASS" : result.notRun ? "NOT RUN" : "FAIL";
console.log(`${result.id}: ${status} — ${result.evidence}`);
}
const failed = results.filter((r) => !r.pass && !r.notRun);
const notRun = results.filter((r) => !r.pass && r.notRun && !r.flagSkip);
const passed = results.filter((r) => r.pass).length;
const totalViolations = results.reduce((sum, r) => sum + (r.violations ?? 0), 0);
if (failed.length > 0) {
console.log(`PRIVACY AUDIT FAILED — ${totalViolations} violation(s) across ${failed.length} gate(s)`);
process.exit(1); // violations — each printed above
}
if (notRun.length > 0) {
const reasons = notRun.map((r) => `${r.id}: ${r.evidence}`).join("; ");
console.log(`PRIVACY AUDIT NOT RUN — ${reasons} (environment drift; never a pass)`);
process.exit(2); // environment/credential drift — fix the environment and rerun
}
console.log(`PRIVACY AUDIT CLEAN — ${passed} gate(s) passed, 0 violations`);
process.exit(0); // clean