Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f4c34d
test(release): require canonical lowercase source SHA
seonghobae Aug 21, 2026
f95d748
fix(release): require canonical lowercase source SHA
seonghobae Aug 21, 2026
45f13ba
test(deployment): require canonical lowercase source SHA
seonghobae Aug 21, 2026
a9a4377
fix(deployment): require canonical lowercase source SHA
seonghobae Aug 21, 2026
5635792
test(deployment): require canonical lowercase evidence digests
seonghobae Aug 21, 2026
5d0802e
fix(deployment): require canonical lowercase evidence digests
seonghobae Aug 21, 2026
98dcf97
test(release): reject normalized source SHA authority
seonghobae Aug 21, 2026
1bd0e36
fix(release): reject normalized source SHA authority
seonghobae Aug 21, 2026
2a2a213
test(deployment): reject normalized evidence identity
seonghobae Aug 21, 2026
141d48b
fix(deployment): reject normalized evidence identity
seonghobae Aug 21, 2026
a112802
test(deployment): reject normalized timestamp authority
seonghobae Aug 21, 2026
6ced36e
fix(deployment): reject normalized timestamp authority
seonghobae Aug 21, 2026
44a9884
test(acquisition): require canonical deployment identities
seonghobae Aug 21, 2026
2c15605
fix(acquisition): bind canonical deployment identities
seonghobae Aug 21, 2026
ae78c1b
merge protected main into fix/release-evidence-lowercase-sha
seonghobae Aug 21, 2026
8bcb147
merge(main): converge release evidence on current protected main
seonghobae Aug 21, 2026
dba27bb
merge(main): converge release evidence on current protected main
seonghobae Aug 21, 2026
dcc7d55
fix(release): preserve protected-main truth after convergence
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions scripts/deployment-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Comment on lines 47 to 63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Timestamp validation broadened beyond commit-SHA scope

requireTimestamp (scripts/deployment-evidence.mjs:47-63) now rejects any timestamp lacking the YYYY-MM-DDT prefix, differing from its trimmed form, or naming an impossible calendar date. It runs on externally sourced values: the Wrangler deploy timestamp (scripts/deployment-evidence.mjs:237) and Cloudflare created_on (scripts/deployment-evidence.mjs:256). Both emit RFC3339/ISO today, so no regression is expected, but a space-separated or offset variant would now fail closed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Expand All @@ -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) {
Expand Down Expand Up @@ -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");
Expand All @@ -174,8 +186,8 @@ export function buildDeploymentEvidence(input) {
if (!tagMatch) {
fail(`release tag must be semantic version tag v<version>, 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}`);
Expand Down Expand Up @@ -277,7 +289,7 @@ export function buildDeploymentEvidence(input) {
releaseRef: `refs/tags/${releaseTag}`,
releaseUrl,
version: tagMatch[1],
commitSha: commitSha.toLowerCase(),
commitSha,
releaseEvidenceSha256,
},
deployment: {
Expand Down
33 changes: 22 additions & 11 deletions scripts/lib/acquisition-deployment-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions scripts/release-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;

Expand Down Expand Up @@ -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(
Expand All @@ -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}`);
Expand Down
20 changes: 20 additions & 0 deletions test/acquisition-deployment-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ describe("acquisition deployment evidence", () => {
["wrong selected tag", (input: ReturnType<typeof fixture>) => { input.expectedTag = "v0.2.0"; }, "deployment_release_tag_mismatch"],
["wrong release ref", (input: ReturnType<typeof fixture>) => { input.deploymentEvidence.source.releaseRef = "refs/heads/main"; }, "deployment_release_ref_mismatch"],
["wrong repository", (input: ReturnType<typeof fixture>) => { input.deploymentEvidence.source.repository = "outside/noema"; }, "deployment_repository_mismatch"],
["uppercase deployment commit identity", (input: ReturnType<typeof fixture>) => {
const uppercaseSha = commitSha.toUpperCase();
input.deploymentEvidence.source.commitSha = uppercaseSha;
input.verificationReceipt.commitSha = uppercaseSha;
}, "deployment_commit_sha_invalid"],
["whitespace-normalized deployment commit identity", (input: ReturnType<typeof fixture>) => {
const spacedSha = ` ${commitSha}`;
input.deploymentEvidence.source.commitSha = spacedSha;
input.verificationReceipt.commitSha = spacedSha;
}, "deployment_commit_sha_invalid"],
["uppercase deployment evidence digest identity", (input: ReturnType<typeof fixture>) => {
const uppercaseDigest = input.deploymentEvidenceSha256.toUpperCase();
input.deploymentEvidenceSha256 = uppercaseDigest;
input.verificationReceipt.deploymentEvidenceSha256 = uppercaseDigest;
}, "attestation_subject_digest_mismatch"],
["whitespace-normalized deployment evidence digest identity", (input: ReturnType<typeof fixture>) => {
const spacedDigest = ` ${input.deploymentEvidenceSha256}`;
input.deploymentEvidenceSha256 = spacedDigest;
input.verificationReceipt.deploymentEvidenceSha256 = spacedDigest;
}, "attestation_subject_digest_mismatch"],
["non-production deployment", (input: ReturnType<typeof fixture>) => { input.deploymentEvidence.deployment.environment = "staging"; }, "deployment_environment_mismatch"],
["wrong Worker", (input: ReturnType<typeof fixture>) => { input.deploymentEvidence.deployment.workerName = "other"; }, "deployment_worker_mismatch"],
["traffic split", (input: ReturnType<typeof fixture>) => { input.deploymentEvidence.deployment.trafficPercentage = 50; }, "deployment_traffic_not_full"],
Expand Down
20 changes: 20 additions & 0 deletions test/deployment-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ describe("deployment evidence", () => {
it.each([
["mutable release", (input: ReturnType<typeof validInput>) => { input.releaseView.isImmutable = false; }, "immutable"],
["moved release tag", (input: ReturnType<typeof validInput>) => { input.releaseEvidence.source.commitSha = "b".repeat(40); }, "commit SHA"],
["uppercase deployment commit SHA", (input: ReturnType<typeof validInput>) => {
const uppercaseSha = input.identity.commitSha.toUpperCase();
input.identity.commitSha = uppercaseSha;
input.releaseEvidence.source.commitSha = uppercaseSha;
}, "lowercase"],
["whitespace-normalized deployment commit SHA", (input: ReturnType<typeof validInput>) => {
input.identity.commitSha = ` ${commitSha}`;
}, "canonical"],
["uppercase release evidence digest", (input: ReturnType<typeof validInput>) => {
input.digests.releaseEvidenceSha256 = "A".repeat(64);
}, "lowercase"],
["whitespace-normalized release evidence digest", (input: ReturnType<typeof validInput>) => {
input.digests.releaseEvidenceSha256 = ` ${"1".repeat(64)}`;
}, "canonical"],
["impossible calendar deployment timestamp", (input: ReturnType<typeof validInput>) => {
input.identity.generatedAt = "2026-02-30T00:00:00.000Z";
}, "valid calendar"],
["whitespace-normalized deployment timestamp", (input: ReturnType<typeof validInput>) => {
input.identity.generatedAt = " 2026-08-04T00:00:00.000Z";
}, "canonical"],
["failed KPI", (input: ReturnType<typeof validInput>) => { input.kpiEvidence.status = "FAIL"; }, "KPI evidence"],
["failed smoke", (input: ReturnType<typeof validInput>) => { input.smokeEvidence.passed = false; }, "smoke evidence"],
["traffic split", (input: ReturnType<typeof validInput>) => { input.afterDeployments[0].versions[0].percentage = 50; }, "100%"],
Expand Down
50 changes: 47 additions & 3 deletions test/release-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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",
Expand Down Expand Up @@ -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"],
[
Expand Down
Loading