diff --git a/scripts/lib/cloudflare-probes.mjs b/scripts/lib/cloudflare-probes.mjs index 984b0ba..d2e8015 100644 --- a/scripts/lib/cloudflare-probes.mjs +++ b/scripts/lib/cloudflare-probes.mjs @@ -37,9 +37,10 @@ const WRANGLER_JSONC = path.join(REPO_ROOT, "bot", "wrangler.jsonc"); * 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. */ +/** A missing environment is the exit-2 class: pass:false + notRun:true. The + * evidence carries the REASON only — the CLI prints the NOT RUN status. */ export function probeUnavailable(reason) { - return { id: "probe", pass: false, notRun: true, evidence: `NOT RUN — ${reason}` }; + return { id: "probe", pass: false, notRun: true, evidence: reason }; } function notRun(id, reason) { @@ -178,6 +179,12 @@ function categoryOf(err) { * `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). + * + * Honesty laws: every leg's HTTP status is read BEFORE any header is + * interpreted — a non-2xx is deployment/environment drift (the notRun + * class), never a cache-header violation. The immutable `/assets/*` policy + * itself is `web/public/_headers`' claim (07-04) — this probe quotes, it + * does not assert the policy. */ export function runAssetHeaderProbe(opts = {}) { const id = "asset-headers"; @@ -193,7 +200,7 @@ export function runAssetHeaderProbe(opts = {}) { } const lines = []; - let violations = 0; + const observed = []; const targets = [["HTML entry /", `${baseUrl}/`]]; if (assetPath !== null) targets.push([`hashed asset ${assetPath}`, `${baseUrl}${assetPath}`]); try { @@ -203,23 +210,31 @@ export function runAssetHeaderProbe(opts = {}) { timeout: 30000, stdio: ["ignore", "pipe", "pipe"], }); + const status = Number(out.match(/^HTTP\/[\d.]+\s+(\d+)/m)?.[1] ?? 0); 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"}`); + observed.push( + `${label}: HTTP ${status || "unknown"}${cacheControl !== null ? ` | ${cacheControl}` : " | no cache-control"}${cfStatus !== null ? ` | ${cfStatus}` : ""}`, + ); + if (!Number.isInteger(status) || status < 200 || status >= 400) { + return notRun( + id, + `deployment drift — ${label} answered HTTP ${status || "unknown"} ` + + `(the deployed origin does not match web/dist); observed: ${observed.join(" || ")}`, + ); } + if (cacheControl === null) lines.push(`${label}: NO cache-control header observed`); + else lines.push(`${label}: ${cacheControl} | ${cfStatus ?? "cf-cache-status not reported"}`); } } catch (err) { return notRun(id, `origin unreachable (${categoryOf(err)})`); } + const violations = lines.filter((l) => l.includes("NO cache-control")).length; 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(" || ")}`; + ? `${targets.length} response header(s), VERBATIM (cf-cache-status is probabilistic — quoted, never interpreted as a guarantee): ${lines.join(" || ")}` + : `${violations} violation(s): ${lines.join(" || ")}`; return { id, pass, evidence, violations }; } diff --git a/scripts/lib/source-gates.mjs b/scripts/lib/source-gates.mjs index 2424b9e..a855801 100644 --- a/scripts/lib/source-gates.mjs +++ b/scripts/lib/source-gates.mjs @@ -231,9 +231,10 @@ export function runLogVocabularyGate(opts = {}) { const ALLOWED_FETCH_HOSTS = ["tiles.openfreemap.org", "telegram.org"]; /** Documented, deliberate scope adjudication (the theme-audit precedent): - * the SVG namespace URIs (`http://www.w3.org/2000/svg`) are spec - * IDENTIFIERS, never fetched. Any other http:// host fails prefer-fail. */ -const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + * the SVG and XHTML namespace URIs are W3C spec IDENTIFIERS + * (createElementNS / innerHTML parsing), never fetched. Any other http:// + * host fails prefer-fail. */ +const SPEC_NAMESPACE_IDENTIFIERS = ["http://www.w3.org/2000/svg", "http://www.w3.org/1999/xhtml"]; /** Extract the hostname of an absolute URL string, or null. */ function hostOf(urlText) { @@ -266,7 +267,7 @@ export function runRuntimeHostGate(opts = {}) { noteHost(urlText); const host = hostOf(urlText); if (host === null) return; // not a parseable URL — leave it to the bundle scan / live probe - if (urlText === SVG_NAMESPACE) return; // documented adjudication — a spec identifier, never fetched + if (SPEC_NAMESPACE_IDENTIFIERS.includes(urlText)) return; // documented adjudication — a spec identifier, never fetched if (ALLOWED_FETCH_HOSTS.includes(host)) return; violations.push(`${rel} — absolute URL to an unapproved host (${where}): ${host}`); } diff --git a/scripts/test/privacy-audit.test.mjs b/scripts/test/privacy-audit.test.mjs index 6ea3f46..21062b7 100644 --- a/scripts/test/privacy-audit.test.mjs +++ b/scripts/test/privacy-audit.test.mjs @@ -228,11 +228,14 @@ await test("CP2 both live probes degrade to notRun (never throw) without Cloudfl 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`); - } + 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; } @@ -249,7 +252,7 @@ await test("AU1 the CLI prints per-gate evidence and exits 0 with PRIVACY AUDIT 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, /^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"); });