diff --git a/scripts/acquisition-deployment-evidence-audit.mjs b/scripts/acquisition-deployment-evidence-audit.mjs old mode 100644 new mode 100755 index 8738d0fc1..ceb2b71c2 --- a/scripts/acquisition-deployment-evidence-audit.mjs +++ b/scripts/acquisition-deployment-evidence-audit.mjs @@ -1,17 +1,22 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; import { + closeSync, + constants, existsSync, + fstatSync, lstatSync, mkdirSync, - readFileSync, - statSync, + openSync, + readSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { TextDecoder } from "node:util"; import { evaluateAcquisitionDeploymentEvidence } from "./lib/acquisition-deployment-evidence.mjs"; const MAX_EVIDENCE_BYTES = 16 * 1024 * 1024; +const MAXIMUM_SIGNED_OPEN_FLAG = 0x7fff_ffff; const now = new Date().toISOString(); const releaseUnderDiligenceTag = String( process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "", @@ -34,29 +39,94 @@ function bounded(value, maximum = 4_000) { return compact.length <= maximum ? compact : `${compact.slice(0, maximum)}…`; } -function readRegularText(path, label) { +function reviewedOpenFlags(label) { + const readOnly = constants.O_RDONLY; + const noFollow = constants.O_NOFOLLOW; + if ( + !Number.isSafeInteger(readOnly) + || readOnly < 0 + || readOnly > MAXIMUM_SIGNED_OPEN_FLAG + || !Number.isSafeInteger(noFollow) + || noFollow <= 0 + || noFollow > MAXIMUM_SIGNED_OPEN_FLAG + ) { + throw new Error(`${label} cannot be opened with the required no-follow capability`); + } + return readOnly | noFollow; +} + +function sameFileIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function readRegularBytes(path, label) { if (!existsSync(path)) { throw new Error(`${label} is missing: ${path}`); } - const lstat = lstatSync(path); - const stat = statSync(path); - if (lstat.isSymbolicLink() || !stat.isFile() || stat.size <= 0 || stat.size > MAX_EVIDENCE_BYTES) { + const pathMetadata = lstatSync(path); + if ( + pathMetadata.isSymbolicLink() + || !pathMetadata.isFile() + || pathMetadata.size <= 0 + || pathMetadata.size > MAX_EVIDENCE_BYTES + ) { throw new Error(`${label} must be a non-empty regular file no larger than ${MAX_EVIDENCE_BYTES} bytes`); } - return readFileSync(path, "utf8"); + + const descriptor = openSync(path, reviewedOpenFlags(label)); + try { + const openedMetadata = fstatSync(descriptor); + if (!openedMetadata.isFile() || !sameFileIdentity(pathMetadata, openedMetadata)) { + throw new Error(`${label} changed identity before it could be read`); + } + + const bytes = Buffer.alloc(openedMetadata.size); + let offset = 0; + while (offset < bytes.length) { + const bytesRead = readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (bytesRead <= 0) { + throw new Error(`${label} changed size while it was being read`); + } + offset += bytesRead; + } + + const finalMetadata = fstatSync(descriptor); + if ( + !finalMetadata.isFile() + || !sameFileIdentity(openedMetadata, finalMetadata) + || openedMetadata.mtimeMs !== finalMetadata.mtimeMs + || openedMetadata.ctimeMs !== finalMetadata.ctimeMs + ) { + throw new Error(`${label} changed while it was being read`); + } + return bytes; + } finally { + closeSync(descriptor); + } +} + +function decodeUtf8(bytes, label) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`${label} contains invalid UTF-8`); + } } function readJson(path, label) { - const text = readRegularText(path, label); + const bytes = readRegularBytes(path, label); + const text = decodeUtf8(bytes, label); try { - return { text, value: JSON.parse(text) }; + return { bytes, text, value: JSON.parse(text) }; } catch (error) { throw new Error(`${label} is invalid JSON: ${bounded(error?.message || error)}`); } } function readBundle(path) { - const text = readRegularText(path, "deployment attestation bundle"); + const label = "deployment attestation bundle"; + const bytes = readRegularBytes(path, label); + const text = decodeUtf8(bytes, label); try { return JSON.parse(text); } catch { @@ -80,8 +150,8 @@ function readBundle(path) { } } -function sha256(text) { - return createHash("sha256").update(text).digest("hex"); +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); } function writeAudit(report) { @@ -123,7 +193,7 @@ function evaluateSelectedRelease() { const evaluation = evaluateAcquisitionDeploymentEvidence({ expectedTag: releaseUnderDiligenceTag, deploymentEvidence: deployment.value, - deploymentEvidenceSha256: sha256(deployment.text), + deploymentEvidenceSha256: sha256(deployment.bytes), governanceEvidence: governance.value, attestationBundle, verificationReceipt: receipt.value, diff --git a/test/acquisition-deployment-evidence-input-integrity.test.ts b/test/acquisition-deployment-evidence-input-integrity.test.ts new file mode 100644 index 000000000..dc5d587c6 --- /dev/null +++ b/test/acquisition-deployment-evidence-input-integrity.test.ts @@ -0,0 +1,250 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, 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"; + +const repository = "ContextualWisdomLab/noema"; +const releaseTag = "v0.1.0"; +const commitSha = "a".repeat(40); +const predicateType = "https://contextualwisdomlab.org/attestations/noema-deployment/v1"; + +function deploymentEvidence() { + return { + schemaVersion: 1, + generatedAt: "2026-08-04T00:00:00.000Z", + source: { + repository, + releaseTag, + releaseRef: `refs/tags/${releaseTag}`, + releaseUrl: `https://github.com/${repository}/releases/tag/${releaseTag}`, + version: "0.1.0", + commitSha, + releaseEvidenceSha256: "1".repeat(64), + }, + deployment: { + environment: "production", + workerName: "noema", + workerVersionId: "worker-version-one", + deploymentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + deployedAt: "2026-08-04T00:00:01.000Z", + deploymentCreatedAt: "2026-08-04T00:00:02.000Z", + trafficPercentage: 100, + targets: ["https://noema.example.workers.dev"], + workflowRunUrl: `https://github.com/${repository}/actions/runs/123`, + }, + rollback: { + previousDeploymentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + previousWorkerVersionId: "worker-version-zero", + }, + validation: { + immutableRelease: true, + strictKpi: true, + smokePassed: true, + kpiExecutedAt: "2026-08-03T23:59:50.000Z", + smokeTimestamp: "2026-08-04T00:00:04.000Z", + kpiEvidenceSha256: "2".repeat(64), + smokeEvidenceSha256: "3".repeat(64), + }, + }; +} + +function governanceEvidence() { + return { + schema_version: 1, + repository, + environment: "production", + status: "PASS", + reviewer_count: 1, + reviewers: [{ type: "Team", id: 42, identifier: "production-approvers" }], + checks: [ + { name: "required reviewers rule exists", pass: true, detail: "rule_count=1" }, + { name: "deployment initiator cannot self-approve", pass: true, detail: "prevent_self_review=true" }, + { name: "only protected branches may deploy", pass: true, detail: "protected_branches=true" }, + ], + failures: [], + }; +} + +function attestationBundle() { + return { + mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json", + verificationMaterial: { tlogEntries: [{}] }, + dsseEnvelope: { payload: "ZXZpZGVuY2U=", signatures: [{ sig: "c2ln" }] }, + }; +} + +function sha256(value: Buffer | string) { + return createHash("sha256").update(value).digest("hex"); +} + +function verificationReceipt(deploymentEvidenceSha256: string) { + return { + schemaVersion: 1, + verified: true, + repository, + releaseTag, + commitSha, + deploymentEvidenceSha256, + signerWorkflow: `${repository}/.github/workflows/cd.yml`, + predicateType, + oidcIssuer: "https://token.actions.githubusercontent.com", + denySelfHostedRunners: true, + workflowRunUrl: `https://github.com/${repository}/actions/runs/123`, + }; +} + +function writeInputs(root: string) { + const deploymentPath = join(root, "deployment-evidence.json"); + const governancePath = join(root, "production-environment-governance.json"); + const bundlePath = join(root, "deployment-evidence.sigstore.json"); + const receiptPath = join(root, "deployment-attestation-verification.json"); + + const deploymentBytes = Buffer.from(`${JSON.stringify(deploymentEvidence(), null, 2)}\n`, "utf8"); + writeFileSync(deploymentPath, deploymentBytes); + writeFileSync(governancePath, `${JSON.stringify(governanceEvidence(), null, 2)}\n`); + writeFileSync(bundlePath, `${JSON.stringify(attestationBundle())}\n`); + writeFileSync( + receiptPath, + `${JSON.stringify(verificationReceipt(sha256(deploymentBytes)), null, 2)}\n`, + ); + + return { deploymentPath, governancePath, bundlePath, receiptPath }; +} + +function runAudit( + root: string, + paths: ReturnType, + extraEnv: Record = {}, +) { + return spawnSync(process.execPath, ["scripts/acquisition-deployment-evidence-audit.mjs"], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: releaseTag, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: root, + NOEMA_DEPLOYMENT_EVIDENCE_PATH: paths.deploymentPath, + NOEMA_DEPLOYMENT_ATTESTATION_PATH: paths.bundlePath, + NOEMA_DEPLOYMENT_ATTESTATION_VERIFICATION_PATH: paths.receiptPath, + NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH: paths.governancePath, + ...extraEnv, + }, + }); +} + +function malformedJsonBytes(value: object) { + const text = JSON.stringify({ ...value, note: "MALFORMED_SENTINEL" }, null, 2); + const [prefix, suffix] = text.split("MALFORMED_SENTINEL"); + return Buffer.concat([ + Buffer.from(prefix, "utf8"), + Buffer.from([0xff]), + Buffer.from(`${suffix}\n`, "utf8"), + ]); +} + +function writePathSwapPreload(root: string) { + const preloadPath = join(root, "swap-after-lstat.cjs"); + writeFileSync(preloadPath, [ + 'const fs = require("node:fs");', + 'const { syncBuiltinESMExports } = require("node:module");', + 'const originalLstatSync = fs.lstatSync;', + 'let swapped = false;', + 'fs.lstatSync = function patchedLstatSync(path, ...args) {', + ' const metadata = originalLstatSync.call(this, path, ...args);', + ' if (!swapped && String(path) === process.env.NOEMA_TEST_SWAP_PATH) {', + ' swapped = true;', + ' fs.unlinkSync(path);', + ' fs.symlinkSync(process.env.NOEMA_TEST_SWAP_TARGET, path);', + ' }', + ' return metadata;', + '};', + 'syncBuiltinESMExports();', + '', + ].join("\n")); + return preloadPath; +} + +describe("acquisition deployment evidence byte integrity", () => { + it("keeps the valid exact-byte fixture passing", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-deployment-bytes-")); + try { + const paths = writeInputs(root); + const result = runAudit(root, paths); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("acquisition-deployment-evidence-audit: PASS"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects malformed UTF-8 deployment evidence even when the receipt matches replacement-decoded text", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-deployment-bytes-")); + try { + const paths = writeInputs(root); + const malformed = malformedJsonBytes(deploymentEvidence()); + writeFileSync(paths.deploymentPath, malformed); + + const replacementDecodedDigest = sha256(malformed.toString("utf8")); + writeFileSync( + paths.receiptPath, + `${JSON.stringify(verificationReceipt(replacementDecodedDigest), null, 2)}\n`, + ); + + const result = runAudit(root, paths); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("deployment_evidence_collection_failed"); + expect(result.stdout).toContain("invalid UTF-8"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects malformed UTF-8 in the retained attestation bundle", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-deployment-bytes-")); + try { + const paths = writeInputs(root); + writeFileSync(paths.bundlePath, malformedJsonBytes(attestationBundle())); + + const result = runAudit(root, paths); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("deployment_evidence_collection_failed"); + expect(result.stdout).toContain("invalid UTF-8"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a deployment evidence path swapped to a symlink after metadata validation", () => { + const root = mkdtempSync(join(tmpdir(), "noema-acquisition-deployment-bytes-")); + try { + const paths = writeInputs(root); + const replacementPath = join(root, "replacement-deployment-evidence.json"); + const replacementBytes = Buffer.from( + `${JSON.stringify({ ...deploymentEvidence(), note: "replacement" }, null, 2)}\n`, + "utf8", + ); + writeFileSync(replacementPath, replacementBytes); + writeFileSync( + paths.receiptPath, + `${JSON.stringify(verificationReceipt(sha256(replacementBytes)), null, 2)}\n`, + ); + const preloadPath = writePathSwapPreload(root); + + const result = runAudit(root, paths, { + NODE_OPTIONS: `--require=${preloadPath}`, + NOEMA_TEST_SWAP_PATH: paths.deploymentPath, + NOEMA_TEST_SWAP_TARGET: replacementPath, + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("deployment_evidence_collection_failed"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});