diff --git a/scripts/deployment-evidence.mjs b/scripts/deployment-evidence.mjs index 82189fcce..c9cd670c6 100644 --- a/scripts/deployment-evidence.mjs +++ b/scripts/deployment-evidence.mjs @@ -19,11 +19,12 @@ const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_WORKER = "noema"; const MAX_INPUT_BYTES = 16 * 1024 * 1024; const MAX_WRANGLER_RECORDS = 1_000; -const shaPattern = /^[0-9a-f]{40}$/i; -const digestPattern = /^[0-9a-f]{64}$/i; +const shaPattern = /^[0-9a-f]{40}$/; +const digestPattern = /^[0-9a-f]{64}$/; const opaqueIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const tagPattern = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; +const isoCalendarPrefixPattern = /^(\d{4})-(\d{2})-(\d{2})T/; function fail(message) { throw new Error(message); @@ -45,8 +46,18 @@ function requireString(value, label) { function requireTimestamp(value, label) { const timestamp = requireString(value, label); - if (Number.isNaN(Date.parse(timestamp))) { - fail(`${label} must be an ISO-compatible timestamp`); + const calendar = timestamp.match(isoCalendarPrefixPattern); + const timestampMilliseconds = Date.parse(timestamp); + if (timestamp !== value || !calendar || !Number.isFinite(timestampMilliseconds)) { + fail(`${label} must be a canonical ISO-compatible timestamp with a valid calendar date`); + } + const year = Number(calendar[1]); + const month = Number(calendar[2]); + const day = Number(calendar[3]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysPerMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (month < 1 || month > 12 || day < 1 || day > daysPerMonth[month - 1]) { + fail(`${label} must be a canonical ISO-compatible timestamp with a valid calendar date`); } return timestamp; } @@ -67,10 +78,10 @@ function requireHttps(value, label) { function requireDigest(value, label) { const digest = requireString(value, label); - if (!digestPattern.test(digest)) { - fail(`${label} must be a 64-character hexadecimal SHA-256 digest`); + if (digest !== value || !digestPattern.test(digest)) { + fail(`${label} must be a canonical 64-character lowercase hexadecimal SHA-256 digest`); } - return digest.toLowerCase(); + return digest; } function requireOpaqueId(value, label) { @@ -162,7 +173,8 @@ export function buildDeploymentEvidence(input) { const identity = requireObject(root.identity, "deployment identity"); const repository = requireString(identity.repository, "deployment repository"); const releaseTag = requireString(identity.releaseTag, "release tag"); - const commitSha = requireString(identity.commitSha, "deployment commit SHA"); + const commitShaSource = identity.commitSha; + const commitSha = requireString(commitShaSource, "deployment commit SHA"); const environment = requireString(identity.environment, "deployment environment"); const workflowRunUrl = requireString(identity.workflowRunUrl, "workflow run URL"); const generatedAt = requireTimestamp(identity.generatedAt, "deployment generatedAt"); @@ -174,8 +186,8 @@ export function buildDeploymentEvidence(input) { if (!tagMatch) { fail(`release tag must be semantic version tag v, received ${releaseTag}`); } - if (!shaPattern.test(commitSha)) { - fail("deployment commit SHA must be a full 40-character hexadecimal SHA"); + if (commitSha !== commitShaSource || !shaPattern.test(commitSha)) { + fail("deployment commit SHA must be a canonical 40-character lowercase hexadecimal SHA"); } if (!new Set(["production", "staging"]).has(environment)) { fail(`deployment environment must be production or staging, received ${environment}`); @@ -277,7 +289,7 @@ export function buildDeploymentEvidence(input) { releaseRef: `refs/tags/${releaseTag}`, releaseUrl, version: tagMatch[1], - commitSha: commitSha.toLowerCase(), + commitSha, releaseEvidenceSha256, }, deployment: { diff --git a/scripts/lib/acquisition-deployment-evidence.mjs b/scripts/lib/acquisition-deployment-evidence.mjs index c3d322769..beb304385 100644 --- a/scripts/lib/acquisition-deployment-evidence.mjs +++ b/scripts/lib/acquisition-deployment-evidence.mjs @@ -5,8 +5,8 @@ const EXPECTED_SIGNER_WORKFLOW = `${EXPECTED_REPOSITORY}/.github/workflows/cd.ym const EXPECTED_PREDICATE_TYPE = "https://contextualwisdomlab.org/attestations/noema-deployment/v1"; const EXPECTED_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; -const shaPattern = /^[0-9a-f]{40}$/i; -const digestPattern = /^[0-9a-f]{64}$/i; +const shaPattern = /^[0-9a-f]{40}$/; +const digestPattern = /^[0-9a-f]{64}$/; const tagPattern = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; function isObject(value) { @@ -17,6 +17,10 @@ function text(value) { return typeof value === "string" ? value.trim() : ""; } +function canonicalIdentity(value, pattern) { + return typeof value === "string" && value === value.trim() && pattern.test(value); +} + function failure(code, detail) { return { code, detail }; } @@ -65,7 +69,9 @@ export function evaluateAcquisitionDeploymentEvidence(input = {}) { const deployment = input.deploymentEvidence; const governance = input.governanceEvidence; const receipt = input.verificationReceipt; - const deploymentDigest = text(input.deploymentEvidenceSha256).toLowerCase(); + const deploymentDigest = typeof input.deploymentEvidenceSha256 === "string" + ? input.deploymentEvidenceSha256 + : ""; add( failures, @@ -125,9 +131,9 @@ export function evaluateAcquisitionDeploymentEvidence(input = {}) { ); add( failures, - shaPattern.test(text(deployment.source?.commitSha)), + canonicalIdentity(deployment.source?.commitSha, shaPattern), "deployment_commit_sha_invalid", - "Deployment source commitSha must be a full hexadecimal SHA.", + "Deployment source commitSha must be a canonical lowercase full hexadecimal SHA.", ); add( failures, @@ -226,7 +232,9 @@ export function evaluateAcquisitionDeploymentEvidence(input = {}) { } if (isObject(receipt)) { - const deploymentCommitSha = text(deployment?.source?.commitSha).toLowerCase(); + const deploymentCommitSha = typeof deployment?.source?.commitSha === "string" + ? deployment.source.commitSha + : ""; const workflowRunUrl = text(deployment?.deployment?.workflowRunUrl); add( failures, @@ -254,16 +262,19 @@ export function evaluateAcquisitionDeploymentEvidence(input = {}) { ); add( failures, - text(receipt.commitSha).toLowerCase() === deploymentCommitSha && shaPattern.test(deploymentCommitSha), + canonicalIdentity(deploymentCommitSha, shaPattern) + && canonicalIdentity(receipt.commitSha, shaPattern) + && receipt.commitSha === deploymentCommitSha, "attestation_commit_sha_mismatch", - "Attestation verification commit SHA must match deployment evidence.", + "Attestation verification commit SHA must canonically match deployment evidence.", ); add( failures, - digestPattern.test(deploymentDigest) - && text(receipt.deploymentEvidenceSha256).toLowerCase() === deploymentDigest, + canonicalIdentity(deploymentDigest, digestPattern) + && canonicalIdentity(receipt.deploymentEvidenceSha256, digestPattern) + && receipt.deploymentEvidenceSha256 === deploymentDigest, "attestation_subject_digest_mismatch", - "Attestation verification subject digest must match deployment-evidence.json.", + "Attestation verification subject digest must canonically match deployment-evidence.json.", ); add( failures, diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs index d3fdeccaa..48b678b28 100644 --- a/scripts/release-evidence.mjs +++ b/scripts/release-evidence.mjs @@ -17,7 +17,7 @@ const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_SBOM_NAME = "noema.cdx.json"; const MAX_SBOM_BYTES = 16 * 1024 * 1024; const MAX_SOURCE_BYTES = 512 * 1024 * 1024; -const shaPattern = /^[0-9a-f]{40}$/i; +const shaPattern = /^[0-9a-f]{40}$/; const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; const canonicalUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -75,8 +75,9 @@ function sha256(bytes) { function validateReleaseIdentity() { const repository = requireString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); + const commitShaSource = process.env.NOEMA_RELEASE_COMMIT_SHA || process.env.GITHUB_SHA; const commitSha = requireString( - process.env.NOEMA_RELEASE_COMMIT_SHA || process.env.GITHUB_SHA, + commitShaSource, "release commit SHA", ); const ref = requireString( @@ -93,8 +94,8 @@ function validateReleaseIdentity() { if (repository !== EXPECTED_REPOSITORY) { fail(`release repository must be ${EXPECTED_REPOSITORY}, received ${repository}`); } - if (!shaPattern.test(commitSha)) { - fail("release commit SHA must be a full 40-character hexadecimal SHA"); + if (commitSha !== commitShaSource || !shaPattern.test(commitSha)) { + fail("release commit SHA must be a canonical 40-character lowercase hexadecimal SHA"); } if (!versionPattern.test(version)) { fail(`release version is not valid SemVer: ${version}`); diff --git a/test/acquisition-deployment-evidence.test.ts b/test/acquisition-deployment-evidence.test.ts index 869c0e2d3..e77e12da2 100644 --- a/test/acquisition-deployment-evidence.test.ts +++ b/test/acquisition-deployment-evidence.test.ts @@ -136,6 +136,26 @@ describe("acquisition deployment evidence", () => { ["wrong selected tag", (input: ReturnType) => { input.expectedTag = "v0.2.0"; }, "deployment_release_tag_mismatch"], ["wrong release ref", (input: ReturnType) => { input.deploymentEvidence.source.releaseRef = "refs/heads/main"; }, "deployment_release_ref_mismatch"], ["wrong repository", (input: ReturnType) => { input.deploymentEvidence.source.repository = "outside/noema"; }, "deployment_repository_mismatch"], + ["uppercase deployment commit identity", (input: ReturnType) => { + const uppercaseSha = commitSha.toUpperCase(); + input.deploymentEvidence.source.commitSha = uppercaseSha; + input.verificationReceipt.commitSha = uppercaseSha; + }, "deployment_commit_sha_invalid"], + ["whitespace-normalized deployment commit identity", (input: ReturnType) => { + const spacedSha = ` ${commitSha}`; + input.deploymentEvidence.source.commitSha = spacedSha; + input.verificationReceipt.commitSha = spacedSha; + }, "deployment_commit_sha_invalid"], + ["uppercase deployment evidence digest identity", (input: ReturnType) => { + const uppercaseDigest = input.deploymentEvidenceSha256.toUpperCase(); + input.deploymentEvidenceSha256 = uppercaseDigest; + input.verificationReceipt.deploymentEvidenceSha256 = uppercaseDigest; + }, "attestation_subject_digest_mismatch"], + ["whitespace-normalized deployment evidence digest identity", (input: ReturnType) => { + const spacedDigest = ` ${input.deploymentEvidenceSha256}`; + input.deploymentEvidenceSha256 = spacedDigest; + input.verificationReceipt.deploymentEvidenceSha256 = spacedDigest; + }, "attestation_subject_digest_mismatch"], ["non-production deployment", (input: ReturnType) => { input.deploymentEvidence.deployment.environment = "staging"; }, "deployment_environment_mismatch"], ["wrong Worker", (input: ReturnType) => { input.deploymentEvidence.deployment.workerName = "other"; }, "deployment_worker_mismatch"], ["traffic split", (input: ReturnType) => { input.deploymentEvidence.deployment.trafficPercentage = 50; }, "deployment_traffic_not_full"], diff --git a/test/deployment-evidence.test.ts b/test/deployment-evidence.test.ts index f8f1e3936..424b995d8 100644 --- a/test/deployment-evidence.test.ts +++ b/test/deployment-evidence.test.ts @@ -160,6 +160,26 @@ describe("deployment evidence", () => { it.each([ ["mutable release", (input: ReturnType) => { input.releaseView.isImmutable = false; }, "immutable"], ["moved release tag", (input: ReturnType) => { input.releaseEvidence.source.commitSha = "b".repeat(40); }, "commit SHA"], + ["uppercase deployment commit SHA", (input: ReturnType) => { + const uppercaseSha = input.identity.commitSha.toUpperCase(); + input.identity.commitSha = uppercaseSha; + input.releaseEvidence.source.commitSha = uppercaseSha; + }, "lowercase"], + ["whitespace-normalized deployment commit SHA", (input: ReturnType) => { + input.identity.commitSha = ` ${commitSha}`; + }, "canonical"], + ["uppercase release evidence digest", (input: ReturnType) => { + input.digests.releaseEvidenceSha256 = "A".repeat(64); + }, "lowercase"], + ["whitespace-normalized release evidence digest", (input: ReturnType) => { + input.digests.releaseEvidenceSha256 = ` ${"1".repeat(64)}`; + }, "canonical"], + ["impossible calendar deployment timestamp", (input: ReturnType) => { + input.identity.generatedAt = "2026-02-30T00:00:00.000Z"; + }, "valid calendar"], + ["whitespace-normalized deployment timestamp", (input: ReturnType) => { + input.identity.generatedAt = " 2026-08-04T00:00:00.000Z"; + }, "canonical"], ["failed KPI", (input: ReturnType) => { input.kpiEvidence.status = "FAIL"; }, "KPI evidence"], ["failed smoke", (input: ReturnType) => { input.smokeEvidence.passed = false; }, "smoke evidence"], ["traffic split", (input: ReturnType) => { input.afterDeployments[0].versions[0].percentage = 50; }, "100%"], diff --git a/test/release-evidence.test.ts b/test/release-evidence.test.ts index 303b8c92e..7c68cc5c5 100644 --- a/test/release-evidence.test.ts +++ b/test/release-evidence.test.ts @@ -37,8 +37,14 @@ function validSbom() { }; } -function runEvidence(temp: string, sbom = validSbom(), sbomBytes?: Uint8Array) { - const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); +function runEvidence( + temp: string, + sbom = validSbom(), + sbomBytes?: Uint8Array, + releaseCommitSha = commitSha, + sourceCommitSha = releaseCommitSha, +) { + const sourcePath = join(temp, `noema-${sourceCommitSha}.tar.gz`); const sbomPath = join(temp, "noema.cdx.json"); const outputDir = join(temp, "release"); writeFileSync(sourcePath, "bounded-source-archive", "utf8"); @@ -64,7 +70,7 @@ function runEvidence(temp: string, sbom = validSbom(), sbomBytes?: Uint8Array) { env: { ...process.env, GITHUB_REPOSITORY: repository, - GITHUB_SHA: commitSha, + GITHUB_SHA: releaseCommitSha, GITHUB_REF: "refs/tags/v0.1.0", NOEMA_RELEASE_VERSION: "0.1.0", NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", @@ -120,6 +126,44 @@ describe("signed release evidence", () => { } }); + it("rejects an uppercase release commit SHA as non-canonical identity", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-uppercase-sha-")); + try { + const uppercaseSha = commitSha.toUpperCase(); + const { result, outputDir } = runEvidence( + temp, + validSbom(), + undefined, + uppercaseSha, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("lowercase"); + expect(() => readFileSync(join(outputDir, "release-evidence.json"))).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects whitespace-normalized release commit authority", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-spaced-sha-")); + try { + const { result, outputDir } = runEvidence( + temp, + validSbom(), + undefined, + ` ${commitSha}`, + commitSha, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("canonical"); + expect(() => readFileSync(join(outputDir, "release-evidence.json"))).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it.each([ ["wrong format", { ...validSbom(), bomFormat: "SPDX" }, "CycloneDX"], [