diff --git a/scripts/kpi-gate.mjs b/scripts/kpi-gate.mjs index 12363af16..2ce57ef36 100644 --- a/scripts/kpi-gate.mjs +++ b/scripts/kpi-gate.mjs @@ -7,7 +7,9 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { hasUnsafeSourceId } from "./lib/source-id.mjs"; import { createKpiChildEnvironment } from "./lib/kpi-child-environment.mjs"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); const parsedArgs = parseArgs(process.argv.slice(2)); const logPath = parsedArgs.positionals[0] ?? process.env.NOEMA_KPI_LOG_PATH ?? "exchange-30d.ndjson"; const failThreshold = parsedArgs.positionals[1] ?? process.env.NOEMA_KPI_FAILURE_THRESHOLD ?? "0.02"; @@ -233,9 +235,25 @@ async function loadProductionProvenance(path, expectedLogPath) { }; } + let provenanceText; + try { + provenanceText = fatalUtf8Decoder.decode(await readFile(path)); + } catch { + return { + pass: false, + reason: `KPI provenance file is not valid UTF-8: ${path}.`, + }; + } + let parsed; try { - parsed = JSON.parse(await readFile(path, "utf8")); + if (hasDuplicateJsonObjectKeys(provenanceText)) { + return { + pass: false, + reason: `KPI provenance file contains duplicate decoded JSON object keys: ${path}.`, + }; + } + parsed = JSON.parse(provenanceText); } catch { return { pass: false, @@ -461,4 +479,4 @@ function parseArgs(args) { result.positionals.push(arg); } return result; -} +} \ No newline at end of file diff --git a/test/kpi-provenance-json-integrity.test.ts b/test/kpi-provenance-json-integrity.test.ts new file mode 100644 index 000000000..c8a072efd --- /dev/null +++ b/test/kpi-provenance-json-integrity.test.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +function writeThirtyDayExchangeLog(path: string) { + const records = [ + { + event: "http_request", + route: "/exchange", + status_code: 200, + latency_ms: 120, + timestamp: "2026-06-01T00:00:00.000Z", + }, + { + event: "http_request", + route: "/exchange", + status_code: 200, + latency_ms: 150, + timestamp: "2026-07-01T03:00:00.000Z", + }, + ]; + writeFileSync(path, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`); +} + +function logIdentity(path: string) { + const bytes = readFileSync(path); + return { + logSha256: createHash("sha256").update(bytes).digest("hex"), + logBytes: bytes.byteLength, + }; +} + +function runStrictGate(logPath: string, provenancePath: string, evidencePath: string) { + return spawnSync(process.execPath, ["scripts/kpi-gate.mjs", logPath], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NOEMA_KPI_STRICT: "1", + NOEMA_KPI_REQUIRE_WINDOW_DAYS: "30", + NOEMA_KPI_PROVENANCE_PATH: provenancePath, + NOEMA_KPI_EVIDENCE_PATH: evidencePath, + }, + }); +} + +function validProvenanceJson(logPath: string) { + const identity = logIdentity(logPath); + return JSON.stringify({ + sourceKind: "production", + sourceId: "cloudflare-logpush:noema-production", + sourceMethod: "log-url", + logPath, + records: 2, + collectedAt: "2026-07-02T00:00:00.000Z", + ...identity, + }); +} + +describe("strict KPI provenance JSON integrity", () => { + it("rejects duplicate decoded provenance keys before last-key-wins parsing", () => { + const dir = mkdtempSync(join(tmpdir(), "noema-kpi-provenance-integrity-")); + try { + const logPath = join(dir, "exchange-30d.ndjson"); + const provenancePath = join(dir, "exchange-30d.ndjson.provenance.json"); + const evidencePath = join(dir, "evidence.json"); + writeThirtyDayExchangeLog(logPath); + const { logSha256, logBytes } = logIdentity(logPath); + writeFileSync( + provenancePath, + `{"sourceKind":"staging","sourceK\\u0069nd":"production","sourceId":"cloudflare-logpush:noema-production","sourceMethod":"log-url","logPath":${JSON.stringify(logPath)},"records":2,"collectedAt":"2026-07-02T00:00:00.000Z","logSha256":"${logSha256}","logBytes":${logBytes}}`, + ); + + const result = runStrictGate(logPath, provenancePath, evidencePath); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("KPI provenance file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects malformed UTF-8 provenance bytes instead of replacement-decoding them", () => { + const dir = mkdtempSync(join(tmpdir(), "noema-kpi-provenance-integrity-")); + try { + const logPath = join(dir, "exchange-30d.ndjson"); + const provenancePath = join(dir, "exchange-30d.ndjson.provenance.json"); + const evidencePath = join(dir, "evidence.json"); + writeThirtyDayExchangeLog(logPath); + const text = validProvenanceJson(logPath); + const bytes = Buffer.from(text, "utf8"); + const marker = Buffer.from("log-url", "utf8"); + const markerOffset = bytes.indexOf(marker); + expect(markerOffset).toBeGreaterThanOrEqual(0); + bytes[markerOffset] = 0x80; + writeFileSync(provenancePath, bytes); + + const result = runStrictGate(logPath, provenancePath, evidencePath); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("KPI provenance file"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});