diff --git a/.changeset/calm-seals-verify.md b/.changeset/calm-seals-verify.md new file mode 100644 index 0000000000..60602ab085 --- /dev/null +++ b/.changeset/calm-seals-verify.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/registry-verification": patch +--- + +Fixes delegated-release provenance verification so verified GitHub attestations include the repository, workflow, commit, and run identity needed to enforce an exact authorized workload. diff --git a/apps/release-service/src/publisher-do/publisher-do.ts b/apps/release-service/src/publisher-do/publisher-do.ts index 103406cf1a..a48505bf45 100644 --- a/apps/release-service/src/publisher-do/publisher-do.ts +++ b/apps/release-service/src/publisher-do/publisher-do.ts @@ -6,8 +6,7 @@ import type { EncryptionRecordReplacement, } from "../operations/encryption-records.js"; import { MAX_ENCRYPTION_RECORD_PAGE } from "../operations/encryption-records.js"; -import { digestWorkloadIdentity } from "../workload/policy.js"; -import { parseStoredWorkloadIdentity } from "../workload/types.js"; +import { parseStoredWorkloadIdentity } from "../workload/stored-identity.js"; import { initializeIntentStateSchema, IntentStateStore, @@ -1108,12 +1107,17 @@ export class PublisherDurableObject extends DurableObject { ): Promise { this.#assertPublisherDid(input.publisherDid); const intent = input.phase === "creating" ? this.#intents.get(input.intentId) : null; - const identity = intent ? parseStoredWorkloadIdentity(intent.workloadIdentityJson) : null; + const identity = intent + ? await parseStoredWorkloadIdentity( + intent.workloadIdentityJson, + intent.workloadIdentityDigest, + ) + : null; const authorization = intent && identity ? { identity, - identityDigest: await digestWorkloadIdentity(identity), + identityDigest: intent.workloadIdentityDigest, identityJson: intent.workloadIdentityJson, } : null; diff --git a/apps/release-service/src/publishing/workflow.ts b/apps/release-service/src/publishing/workflow.ts index cf4cba07f6..3c053d1ea5 100644 --- a/apps/release-service/src/publishing/workflow.ts +++ b/apps/release-service/src/publishing/workflow.ts @@ -27,6 +27,7 @@ import type { StoredPublicationMaterialization, } from "../publisher-do/publisher-do.js"; import { + evaluateWorkloadAttestation, evaluateVerifiedRelease, normalizeVerifierReport, parseNormalizedVerifierReport, @@ -754,6 +755,7 @@ export async function publishVerifiedIntent( publisherDid, originalIntent, snapshot, + await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug), verifier, ); if (!evaluation.success) { @@ -814,6 +816,13 @@ export async function publishVerifiedIntent( reasonCode: finalVerification.reasonCode, }; } + if (current?.state === "expired") { + return { + intentId: originalIntent.id, + state: "expired", + reasonCode: "INTENT_EXPIRED", + }; + } if (current?.state !== "ready") { return { intentId: originalIntent.id, @@ -1087,6 +1096,7 @@ export async function publishVerifiedIntent( publisherDid, originalIntent, snapshot, + await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug), verifier, ); if ( @@ -1145,6 +1155,12 @@ export async function publishVerifiedIntent( originalIntent.requestDigest, ); if (!persistedRecord) return failBeforeWrite("MATERIALIZATION_UNAVAILABLE"); + const workload = await evaluateWorkloadAttestation( + originalIntent, + await publisher.getWorkloadPolicy(publisherDid, originalIntent.packageSlug), + verifier.value.provenance, + ); + if (!workload.ok) return failBeforeWrite(workload.reasonCode); await requireCurrentPublicationAudience(publisher, publisherDid, restored); const creatingPhase = await publisher.advancePublicationOperationPhase({ ...completionBase, diff --git a/apps/release-service/src/verification/evaluate.ts b/apps/release-service/src/verification/evaluate.ts index f613ef38e5..1aa2410199 100644 --- a/apps/release-service/src/verification/evaluate.ts +++ b/apps/release-service/src/verification/evaluate.ts @@ -21,6 +21,9 @@ import type { } from "../../../release-verifier/src/verify.js"; import type { ApprovalEvidence } from "../approvals/digest.js"; import type { StoredIntent } from "../publisher-do/publisher-do.js"; +import type { StoredWorkloadPolicy } from "../publisher-do/workload-policy.js"; +import { evaluateWorkloadPolicy } from "../workload/policy.js"; +import { parseStoredWorkloadIdentity } from "../workload/stored-identity.js"; import type { PublisherVerificationSnapshot } from "./pds.js"; const SLSA_PROVENANCE_V1 = "https://slsa.dev/provenance/v1"; @@ -31,7 +34,17 @@ export type VerificationEvaluationCode = | "BASELINE_INVALID" | "INTENT_INPUT_INVALID" | "RECORD_INVALID" - | "VERIFIER_REJECTED"; + | "VERIFIER_REJECTED" + | "WORKLOAD_IDENTITY_INVALID"; + +export interface VerifiedProvenanceIdentity { + sourceRepository: string; + builderId: string; + repositoryId: string; + workflowRef: string; + commitSha: string; + invocationId: string; +} export type VerificationEvaluation = | { @@ -66,6 +79,10 @@ export type NormalizedVerifierReport = predicateType: string; sourceRepository: string; builderId: string; + repositoryId: string; + workflowRef: string; + commitSha: string; + invocationId: string; }; }; } @@ -160,6 +177,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier predicateType: stringField(provenance["predicateType"]), sourceRepository: stringField(provenance["sourceRepository"]), builderId: stringField(provenance["builderId"]), + repositoryId: stringField(provenance["repositoryId"]), + workflowRef: stringField(provenance["workflowRef"]), + commitSha: stringField(provenance["commitSha"]), + invocationId: stringField(provenance["invocationId"]), }; if ( normalized.requestedUrl === null || @@ -176,6 +197,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier normalized.predicateType === null || normalized.sourceRepository === null || normalized.builderId === null || + normalized.repositoryId === null || + normalized.workflowRef === null || + normalized.commitSha === null || + normalized.invocationId === null || !("declaredAccess" in manifest) ) { return null; @@ -206,6 +231,10 @@ export function parseNormalizedVerifierReport(value: string): NormalizedVerifier predicateType: normalized.predicateType, sourceRepository: normalized.sourceRepository, builderId: normalized.builderId, + repositoryId: normalized.repositoryId, + workflowRef: normalized.workflowRef, + commitSha: normalized.commitSha, + invocationId: normalized.invocationId, }, }, }; @@ -319,19 +348,75 @@ function reportBackedVerifier( artifactDigest: new Uint8Array(input.artifactDigest), sourceRepository: report.provenance.sourceRepository, builderId: report.provenance.builderId, + repositoryId: report.provenance.repositoryId, + workflowRef: report.provenance.workflowRef, + commitSha: report.provenance.commitSha, + invocationId: report.provenance.invocationId, }, }; }, }; } +export async function evaluateWorkloadAttestation( + intent: Pick, + policy: StoredWorkloadPolicy | null, + provenance: VerifiedProvenanceIdentity, +): Promise<{ ok: true } | { ok: false; reasonCode: string }> { + const identity = await parseStoredWorkloadIdentity( + intent.workloadIdentityJson, + intent.workloadIdentityDigest, + ); + if (!identity) return { ok: false, reasonCode: "WORKLOAD_IDENTITY_INVALID" }; + if (!policy || policy.packageSlug !== intent.packageSlug) { + return { ok: false, reasonCode: "WORKLOAD_POLICY_UNAVAILABLE" }; + } + const policyDecision = evaluateWorkloadPolicy(identity, policy); + if (!policyDecision.ok) return { ok: false, reasonCode: policyDecision.code }; + const workflowMarker = "/.github/workflows/"; + const markerIndex = identity.workflow.ref.toLowerCase().indexOf(workflowMarker); + if (markerIndex < 1) { + return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" }; + } + const sourceRepository = `https://github.com/${identity.workflow.ref.slice(0, markerIndex)}`; + if ( + provenance.repositoryId !== identity.repository.id || + provenance.sourceRepository.toLowerCase() !== sourceRepository.toLowerCase() + ) { + return { ok: false, reasonCode: "ATTESTED_REPOSITORY_MISMATCH" }; + } + const expectedBuilderId = `${sourceRepository}${identity.workflow.ref.slice(markerIndex)}`; + if (provenance.builderId !== expectedBuilderId) { + return { ok: false, reasonCode: "ATTESTED_WORKFLOW_MISMATCH" }; + } + const workflowRef = identity.workflow.ref.slice(identity.workflow.ref.lastIndexOf("@") + 1); + if (provenance.workflowRef !== workflowRef) { + return { ok: false, reasonCode: "ATTESTED_REF_MISMATCH" }; + } + if (provenance.commitSha !== identity.run.commitSha) { + return { ok: false, reasonCode: "ATTESTED_COMMIT_MISMATCH" }; + } + const invocationId = `${sourceRepository}/actions/runs/${identity.run.id}/attempts/${identity.run.attempt}`; + if (provenance.invocationId !== invocationId) { + return { ok: false, reasonCode: "ATTESTED_INVOCATION_MISMATCH" }; + } + return { ok: true }; +} + export async function evaluateVerifiedRelease( publisherDid: string, intent: StoredIntent, snapshot: PublisherVerificationSnapshot, + workloadPolicy: StoredWorkloadPolicy | null, verifierReport: NormalizedVerifierReport, ): Promise { if (!verifierReport.success) return failed("VERIFIER_REJECTED", verifierReport.error.code); + const workload = await evaluateWorkloadAttestation( + intent, + workloadPolicy, + verifierReport.value.provenance, + ); + if (!workload.ok) return failed("WORKLOAD_IDENTITY_INVALID", workload.reasonCode); const payload = parseReleaseIntent(intent.releaseInputJson); const verifierInput = prepareVerifierInput(intent, snapshot); if (!payload || !verifierInput) return failed("INTENT_INPUT_INVALID"); diff --git a/apps/release-service/src/workflows/release-intent.ts b/apps/release-service/src/workflows/release-intent.ts index fbc08fef8e..b9b5467ec8 100644 --- a/apps/release-service/src/workflows/release-intent.ts +++ b/apps/release-service/src/workflows/release-intent.ts @@ -548,10 +548,15 @@ export class ReleaseIntentWorkflow extends WorkflowEntrypoint< ) { result = { success: false, code: "BASELINE_INVALID", reasonCode: "BASELINE_CHANGED" }; } else { + const workloadPolicy = await publisher.getWorkloadPolicy( + params.publisherDid, + intent.packageSlug, + ); const evaluated = await evaluateVerifiedRelease( params.publisherDid, intent, snapshot, + workloadPolicy, verifier, ); result = evaluated.success diff --git a/apps/release-service/src/workload/stored-identity.ts b/apps/release-service/src/workload/stored-identity.ts new file mode 100644 index 0000000000..9551eb3560 --- /dev/null +++ b/apps/release-service/src/workload/stored-identity.ts @@ -0,0 +1,146 @@ +import { digestWorkloadIdentity } from "./policy.js"; +import type { VerifiedWorkloadIdentity } from "./types.js"; + +const DECIMAL_ID_PATTERN = /^[1-9][0-9]*$/; +const REPOSITORY_PATTERN = /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/; +const LOGIN_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38})$/; +const ACTOR_PATTERN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})|[A-Za-z0-9-]{1,39}\[bot\])$/; +const SHA_PATTERN = /^[a-f0-9]{40}$/; +const REF_PATTERN = /^refs\/[A-Za-z0-9._/-]{1,507}$/; +const WORKFLOW_REF_PATTERN = + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9_./-]+\.ya?ml@refs\/[A-Za-z0-9._/-]+$/; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function boundedString(value: unknown, maximum: number, pattern?: RegExp): string | null { + return typeof value === "string" && + value.length > 0 && + value.length <= maximum && + (!pattern || pattern.test(value)) + ? value + : null; +} + +function nullableString( + value: unknown, + maximum: number, + pattern?: RegExp, +): string | null | undefined { + return value === null ? null : (boundedString(value, maximum, pattern) ?? undefined); +} + +function safeInteger(value: unknown, minimum = 0): number | null { + return Number.isSafeInteger(value) && Number(value) >= minimum ? Number(value) : null; +} + +function parseIdentity(value: unknown): VerifiedWorkloadIdentity | null { + if ( + !isRecord(value) || + !isRecord(value["repository"]) || + !isRecord(value["workflow"]) || + !isRecord(value["run"]) + ) { + return null; + } + const repository = value["repository"]; + const workflow = value["workflow"]; + const run = value["run"]; + const subject = boundedString(value["subject"], 2048); + const tokenId = boundedString(value["tokenId"], 255); + const repositoryName = boundedString(repository["name"], 256, REPOSITORY_PATTERN); + const repositoryId = boundedString(repository["id"], 32, DECIMAL_ID_PATTERN); + const repositoryOwner = boundedString(repository["owner"], 64, LOGIN_PATTERN); + const repositoryOwnerId = boundedString(repository["ownerId"], 32, DECIMAL_ID_PATTERN); + const workflowRef = boundedString(workflow["ref"], 1024, WORKFLOW_REF_PATTERN); + const workflowSha = boundedString(workflow["sha"], 40, SHA_PATTERN); + const jobRef = nullableString(workflow["jobRef"], 1024, WORKFLOW_REF_PATTERN); + const jobSha = nullableString(workflow["jobSha"], 40, SHA_PATTERN); + const runId = boundedString(run["id"], 32, DECIMAL_ID_PATTERN); + const runAttempt = safeInteger(run["attempt"], 1); + const actor = boundedString(run["actor"], 64, ACTOR_PATTERN); + const actorId = boundedString(run["actorId"], 32, DECIMAL_ID_PATTERN); + const eventName = boundedString(run["eventName"], 128); + const ref = boundedString(run["ref"], 512, REF_PATTERN); + const commitSha = boundedString(run["commitSha"], 40, SHA_PATTERN); + const environment = nullableString(run["environment"], 255); + const issuedAt = safeInteger(value["issuedAt"]); + const expiresAt = safeInteger(value["expiresAt"]); + if ( + value["issuer"] !== "github-actions" || + !subject || + !tokenId || + !repositoryName || + !repositoryId || + !repositoryOwner || + !repositoryOwnerId || + (repository["visibility"] !== "public" && + repository["visibility"] !== "private" && + repository["visibility"] !== "internal") || + !workflowRef || + !workflowSha || + jobRef === undefined || + jobSha === undefined || + (jobRef === null) !== (jobSha === null) || + !runId || + runAttempt === null || + !actor || + !actorId || + !eventName || + !ref || + (run["refType"] !== "branch" && run["refType"] !== "tag") || + !commitSha || + environment === undefined || + (run["runnerEnvironment"] !== "github-hosted" && run["runnerEnvironment"] !== "self-hosted") || + issuedAt === null || + expiresAt === null || + issuedAt > expiresAt || + repositoryOwner !== repositoryName.split("/", 1)[0] || + !workflowRef.toLowerCase().startsWith(`${repositoryName}/.github/workflows/`) + ) { + return null; + } + return { + issuer: "github-actions", + subject, + tokenId, + repository: { + name: repositoryName, + id: repositoryId, + owner: repositoryOwner, + ownerId: repositoryOwnerId, + visibility: repository["visibility"], + }, + workflow: { ref: workflowRef, sha: workflowSha, jobRef, jobSha }, + run: { + id: runId, + attempt: runAttempt, + actor, + actorId, + eventName, + ref, + refType: run["refType"], + commitSha, + environment, + runnerEnvironment: run["runnerEnvironment"], + }, + issuedAt, + expiresAt, + }; +} + +export async function parseStoredWorkloadIdentity( + json: string, + expectedDigest: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + const identity = parseIdentity(parsed); + if (!identity || JSON.stringify(identity) !== json) return null; + return (await digestWorkloadIdentity(identity)) === expectedDigest ? identity : null; +} diff --git a/apps/release-service/src/workload/types.ts b/apps/release-service/src/workload/types.ts index d2ea8eea62..bffc231557 100644 --- a/apps/release-service/src/workload/types.ts +++ b/apps/release-service/src/workload/types.ts @@ -42,163 +42,3 @@ export interface VerifiedWorkloadIdentity { issuedAt: number; expiresAt: number; } - -const DECIMAL_ID_PATTERN = /^[1-9][0-9]*$/; -const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; -const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; -const ACTOR_PATTERN = /^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})|[A-Za-z0-9-]{1,39}\[bot\])$/; -const SHA_PATTERN = /^[a-f0-9]{40}$/; -const REF_PATTERN = /^refs\/[A-Za-z0-9._/-]{1,507}$/; -const WORKFLOW_REF_PATTERN = - /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9_./-]+\.ya?ml@refs\/[A-Za-z0-9._/-]+$/; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function hasExactKeys(value: Record, expected: readonly string[]): boolean { - const keys = Object.keys(value); - return keys.length === expected.length && keys.every((key) => expected.includes(key)); -} - -export function parseStoredWorkloadIdentity(value: string): VerifiedWorkloadIdentity | null { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - return null; - } - if ( - !isRecord(parsed) || - !hasExactKeys(parsed, [ - "issuer", - "subject", - "tokenId", - "repository", - "workflow", - "run", - "issuedAt", - "expiresAt", - ]) || - parsed.issuer !== "github-actions" || - typeof parsed.subject !== "string" || - parsed.subject.length === 0 || - parsed.subject.length > 2048 || - typeof parsed.tokenId !== "string" || - parsed.tokenId.length === 0 || - parsed.tokenId.length > 255 || - !isRecord(parsed.repository) || - !hasExactKeys(parsed.repository, ["name", "id", "owner", "ownerId", "visibility"]) || - !isRecord(parsed.workflow) || - !hasExactKeys(parsed.workflow, ["ref", "sha", "jobRef", "jobSha"]) || - !isRecord(parsed.run) || - !hasExactKeys(parsed.run, [ - "id", - "attempt", - "actor", - "actorId", - "eventName", - "ref", - "refType", - "commitSha", - "environment", - "runnerEnvironment", - ]) - ) { - return null; - } - const repository = parsed.repository; - const workflow = parsed.workflow; - const run = parsed.run; - if ( - typeof repository.name !== "string" || - !REPOSITORY_PATTERN.test(repository.name) || - repository.name !== repository.name.toLowerCase() || - typeof repository.id !== "string" || - !DECIMAL_ID_PATTERN.test(repository.id) || - typeof repository.owner !== "string" || - !LOGIN_PATTERN.test(repository.owner) || - repository.owner !== repository.owner.toLowerCase() || - typeof repository.ownerId !== "string" || - !DECIMAL_ID_PATTERN.test(repository.ownerId) || - (repository.visibility !== "public" && - repository.visibility !== "private" && - repository.visibility !== "internal") || - typeof workflow.ref !== "string" || - workflow.ref.length > 1024 || - !WORKFLOW_REF_PATTERN.test(workflow.ref) || - typeof workflow.sha !== "string" || - !SHA_PATTERN.test(workflow.sha) || - (workflow.jobRef !== null && - (typeof workflow.jobRef !== "string" || - workflow.jobRef.length > 1024 || - !WORKFLOW_REF_PATTERN.test(workflow.jobRef))) || - (workflow.jobSha !== null && - (typeof workflow.jobSha !== "string" || !SHA_PATTERN.test(workflow.jobSha))) || - (workflow.jobRef === null) !== (workflow.jobSha === null) || - typeof run.id !== "string" || - !DECIMAL_ID_PATTERN.test(run.id) || - typeof run.attempt !== "number" || - !Number.isSafeInteger(run.attempt) || - run.attempt < 1 || - typeof run.actor !== "string" || - !ACTOR_PATTERN.test(run.actor) || - typeof run.actorId !== "string" || - !DECIMAL_ID_PATTERN.test(run.actorId) || - typeof run.eventName !== "string" || - run.eventName.length === 0 || - run.eventName.length > 128 || - typeof run.ref !== "string" || - !REF_PATTERN.test(run.ref) || - (run.refType !== "branch" && run.refType !== "tag") || - typeof run.commitSha !== "string" || - !SHA_PATTERN.test(run.commitSha) || - (run.environment !== null && - (typeof run.environment !== "string" || - run.environment.length === 0 || - run.environment.length > 255)) || - (run.runnerEnvironment !== "github-hosted" && run.runnerEnvironment !== "self-hosted") || - typeof parsed.issuedAt !== "number" || - !Number.isSafeInteger(parsed.issuedAt) || - typeof parsed.expiresAt !== "number" || - !Number.isSafeInteger(parsed.expiresAt) || - parsed.issuedAt > parsed.expiresAt || - repository.name.split("/", 1)[0] !== repository.owner || - !workflow.ref.toLowerCase().startsWith(`${repository.name}/.github/workflows/`) - ) { - return null; - } - const identity: VerifiedWorkloadIdentity = { - issuer: "github-actions", - subject: parsed.subject, - tokenId: parsed.tokenId, - repository: { - name: repository.name, - id: repository.id, - owner: repository.owner, - ownerId: repository.ownerId, - visibility: repository.visibility, - }, - workflow: { - ref: workflow.ref, - sha: workflow.sha, - jobRef: workflow.jobRef, - jobSha: workflow.jobSha, - }, - run: { - id: run.id, - attempt: run.attempt, - actor: run.actor, - actorId: run.actorId, - eventName: run.eventName, - ref: run.ref, - refType: run.refType, - commitSha: run.commitSha, - environment: run.environment, - runnerEnvironment: run.runnerEnvironment, - }, - issuedAt: parsed.issuedAt, - expiresAt: parsed.expiresAt, - }; - return JSON.stringify(identity) === value ? identity : null; -} diff --git a/apps/release-service/test/publication-operation.test.ts b/apps/release-service/test/publication-operation.test.ts index 0390de1505..e324040034 100644 --- a/apps/release-service/test/publication-operation.test.ts +++ b/apps/release-service/test/publication-operation.test.ts @@ -388,6 +388,7 @@ describe("publisher publication operations", () => { it.each([ ["inactive version", "UPDATE workload_policies SET active = 0, state_version = 2"], ["narrowed current rules", `UPDATE workload_policies SET allowed_refs = '["refs/tags/*"]'`], + ["stored identity digest", `UPDATE intents SET workload_identity_digest = '${"A".repeat(43)}'`], [ "canonical stored identity", `UPDATE intents SET workload_identity_json = '{"issuer":"github-actions"}'`, diff --git a/apps/release-service/test/release-intent-workflow.test.ts b/apps/release-service/test/release-intent-workflow.test.ts index 1a77bad3f0..eb0b0c0f0e 100644 --- a/apps/release-service/test/release-intent-workflow.test.ts +++ b/apps/release-service/test/release-intent-workflow.test.ts @@ -1262,6 +1262,51 @@ describe("ReleaseIntentWorkflow", () => { expect(permits).toEqual({ total: 3, distinct_ids: 3, consumed: 3 }); }); + it("rechecks the attested workload against the active policy before create", async () => { + let policyChanged = false; + let createAttempts = 0; + vi.stubGlobal( + "fetch", + workflowNetwork({ + onAuthorizationMetadata: async () => { + if (policyChanged) return; + policyChanged = true; + const publisher = env.PUBLISHER_DO.getByName(PUBLISHER_DID); + await runInDurableObject(publisher, (_instance, state) => { + state.storage.sql.exec( + `UPDATE workload_policies + SET workflow_ref = ?, state_version = state_version + 1 + WHERE package_slug = ?`, + "example/gallery/.github/workflows/restricted.yml@refs/heads/main", + "gallery", + ); + }); + }, + onCreateRecord: () => { + createAttempts += 1; + return Response.json({ uri: CREATED_URI, cid: CREATED_CID }); + }, + }), + ); + await createVerifyingIntent(); + await using introspector = await introspectWorkflowInstance( + env.RELEASE_INTENT_WORKFLOW, + INTENT_ID, + ); + await env.RELEASE_INTENT_WORKFLOW.create({ + id: INTENT_ID, + params: { publisherDid: PUBLISHER_DID, intentId: INTENT_ID }, + }); + await introspector.waitForStatus("complete"); + + await expect(introspector.getOutput()).resolves.toEqual({ + intentId: INTENT_ID, + state: "failed", + reasonCode: "WORKLOAD_WORKFLOW_MISMATCH", + }); + expect(createAttempts).toBe(0); + }); + it.each([ ["publication pause", "pause", "ready", "PUBLICATION_PAUSED"], ["publisher suspension", "suspend", "ready", "PUBLISHER_SUSPENDED"], diff --git a/apps/release-service/test/verification-evaluate.test.ts b/apps/release-service/test/verification-evaluate.test.ts index cff027c354..0f68b11d98 100644 --- a/apps/release-service/test/verification-evaluate.test.ts +++ b/apps/release-service/test/verification-evaluate.test.ts @@ -5,13 +5,17 @@ import profileFixture from "../../../packages/registry-verification/fixtures/rec import releaseFixture from "../../../packages/registry-verification/fixtures/records/release.json"; import type { ReleaseVerificationReport } from "../../release-verifier/src/verify.js"; import type { StoredIntent } from "../src/publisher-do/publisher-do.js"; +import type { StoredWorkloadPolicy } from "../src/publisher-do/workload-policy.js"; import { + evaluateWorkloadAttestation, evaluateVerifiedRelease, normalizeVerifierReport, parseNormalizedVerifierReport, prepareVerifierInput, } from "../src/verification/evaluate.js"; import type { PublisherVerificationSnapshot } from "../src/verification/pds.js"; +import { digestWorkloadIdentity } from "../src/workload/policy.js"; +import type { VerifiedWorkloadIdentity } from "../src/workload/types.js"; const PUBLISHER_DID = "did:plc:publisher"; const ARTIFACT_CHECKSUM = "bciqcz4snxjp3biyoe3udwkwfxhrj4gywdzob7j2clzzqim3csofzqja"; @@ -22,6 +26,52 @@ const PROVENANCE = { sourceRepository: "https://github.com/example/gallery", builderId: "https://github.com/example/gallery/.github/workflows/release.yml@refs/heads/main", } as const; +const WORKLOAD_IDENTITY: VerifiedWorkloadIdentity = { + issuer: "github-actions", + subject: "repo:example/gallery:ref:refs/heads/main", + tokenId: "token-100", + repository: { + name: "example/gallery", + id: "123456789", + owner: "example", + ownerId: "987654321", + visibility: "public", + }, + workflow: { + ref: "example/gallery/.github/workflows/release.yml@refs/heads/main", + sha: "a".repeat(40), + jobRef: null, + jobSha: null, + }, + run: { + id: "100", + attempt: 1, + actor: "release-bot", + actorId: "2468", + eventName: "push", + ref: "refs/heads/main", + refType: "branch", + commitSha: "b".repeat(40), + environment: null, + runnerEnvironment: "github-hosted", + }, + issuedAt: 1_800_000_000, + expiresAt: 1_800_000_300, +}; +const WORKLOAD_POLICY: StoredWorkloadPolicy = { + packageSlug: "gallery", + repository: "example/gallery", + repositoryId: "123456789", + repositoryOwnerId: "987654321", + workflowRef: "example/gallery/.github/workflows/release.yml@refs/heads/main", + allowedRefs: ["refs/heads/main"], + allowedEnvironments: [], + active: true, + stateVersion: 1, + authorizedBy: PUBLISHER_DID, + createdAt: 1_800_000_000_000, + updatedAt: 1_800_000_000_000, +}; function proposedRelease() { const release = structuredClone(releaseFixture) as PackageRelease.Main & { @@ -36,7 +86,10 @@ function proposedRelease() { return release; } -function intent(release = proposedRelease()): StoredIntent { +async function intent( + release = proposedRelease(), + identity: VerifiedWorkloadIdentity = WORKLOAD_IDENTITY, +): Promise { return { id: "01JABCDEFGHJKMNPQRSTVWXYZ0", packageSlug: "gallery", @@ -44,10 +97,10 @@ function intent(release = proposedRelease()): StoredIntent { state: "verifying", stateGeneration: 2, workloadPolicyVersion: 1, - workloadIdentityDigest: "A".repeat(43), + workloadIdentityDigest: await digestWorkloadIdentity(identity), workloadIdempotencyDigest: "I".repeat(43), requestDigest: "B".repeat(43), - workloadIdentityJson: JSON.stringify({ issuer: "github-actions" }), + workloadIdentityJson: JSON.stringify(identity), releaseInputJson: JSON.stringify({ release }), stateDataJson: "{}", workflowId: "01JABCDEFGHJKMNPQRSTVWXYZ0", @@ -93,6 +146,12 @@ function verifierReport(): ReleaseVerificationReport { predicateType: PROVENANCE.predicateType, sourceRepository: PROVENANCE.sourceRepository, builderId: PROVENANCE.builderId, + repositoryId: WORKLOAD_IDENTITY.repository.id, + workflowRef: WORKLOAD_IDENTITY.workflow.ref.slice( + WORKLOAD_IDENTITY.workflow.ref.lastIndexOf("@") + 1, + ), + commitSha: WORKLOAD_IDENTITY.run.commitSha, + invocationId: "https://github.com/example/gallery/actions/runs/100/attempts/1", artifactDigest: new Uint8Array(32), }, }, @@ -100,8 +159,8 @@ function verifierReport(): ReleaseVerificationReport { } describe("verification evaluation", () => { - it("prepares the isolated verifier request from signed inputs", () => { - expect(prepareVerifierInput(intent(), snapshot())).toEqual({ + it("prepares the isolated verifier request from signed inputs", async () => { + expect(prepareVerifierInput(await intent(), snapshot())).toEqual({ artifact: { url: releaseFixture.artifacts.package.url, checksum: ARTIFACT_CHECKSUM, @@ -116,8 +175,9 @@ describe("verification evaluation", () => { it("accepts a fully matching automatic release and creates complete approval evidence", async () => { const result = await evaluateVerifiedRelease( PUBLISHER_DID, - intent(), + await intent(), snapshot(), + WORKLOAD_POLICY, verifierReport(), ); if (!result.success) throw new Error(`${result.code}:${result.reasonCode}`); @@ -131,12 +191,50 @@ describe("verification evaluation", () => { profileCid: "bafyprofile", baselineReleaseCid: null, verificationGeneration: 4, - workloadIdentityDigest: "A".repeat(43), + workloadIdentityDigest: await digestWorkloadIdentity(WORKLOAD_IDENTITY), }, }, }); }); + it("accepts a workflow file ref that differs from the triggering run ref", async () => { + const tagIdentity = structuredClone(WORKLOAD_IDENTITY); + tagIdentity.run.ref = "refs/tags/v1.2.3"; + tagIdentity.run.refType = "tag"; + const tagPolicy = { ...WORKLOAD_POLICY, allowedRefs: ["refs/tags/*"] }; + + await expect( + evaluateVerifiedRelease( + PUBLISHER_DID, + await intent(proposedRelease(), tagIdentity), + snapshot(), + tagPolicy, + verifierReport(), + ), + ).resolves.toMatchObject({ success: true }); + }); + + it("preserves GitHub repository casing for builder and invocation identity", async () => { + const mixedCaseIdentity = structuredClone(WORKLOAD_IDENTITY); + mixedCaseIdentity.workflow.ref = + "Example/Gallery/.github/workflows/release.yml@refs/heads/main"; + const report = verifierReport(); + if (!report.success) throw new Error("Expected successful fixture"); + report.value.provenance.sourceRepository = "https://github.com/Example/Gallery"; + report.value.provenance.builderId = + "https://github.com/Example/Gallery/.github/workflows/release.yml@refs/heads/main"; + report.value.provenance.invocationId = + "https://github.com/Example/Gallery/actions/runs/100/attempts/1"; + + await expect( + evaluateWorkloadAttestation( + await intent(proposedRelease(), mixedCaseIdentity), + WORKLOAD_POLICY, + report.value.provenance, + ), + ).resolves.toEqual({ ok: true }); + }); + it("binds signed request URLs while retaining verified redirect destinations", async () => { const report = verifierReport(); if (!report.success) throw new Error("Expected successful fixture"); @@ -147,7 +245,13 @@ describe("verification evaluation", () => { if (!persisted?.success) throw new Error("Expected persisted verifier report"); await expect( - evaluateVerifiedRelease(PUBLISHER_DID, intent(), snapshot(), persisted), + evaluateVerifiedRelease( + PUBLISHER_DID, + await intent(), + snapshot(), + WORKLOAD_POLICY, + persisted, + ), ).resolves.toMatchObject({ success: true, value: { @@ -172,8 +276,9 @@ describe("verification evaluation", () => { const result = await evaluateVerifiedRelease( PUBLISHER_DID, - intent(), + await intent(), snapshot(profile), + WORKLOAD_POLICY, verifierReport(), ); if (!result.success) throw new Error(`${result.code}:${result.reasonCode}`); @@ -182,7 +287,7 @@ describe("verification evaluation", () => { it("rejects verifier, artifact-manifest, and record substitutions", async () => { await expect( - evaluateVerifiedRelease(PUBLISHER_DID, intent(), snapshot(), { + evaluateVerifiedRelease(PUBLISHER_DID, await intent(), snapshot(), WORKLOAD_POLICY, { success: false, error: { code: "CHECKSUM_MISMATCH", message: "mismatch" }, }), @@ -191,7 +296,73 @@ describe("verification evaluation", () => { if (!mismatched.success) throw new Error("Expected successful fixture"); mismatched.value.artifact.manifest.declaredAccess = { network: { request: {} } }; await expect( - evaluateVerifiedRelease(PUBLISHER_DID, intent(), snapshot(), mismatched), + evaluateVerifiedRelease( + PUBLISHER_DID, + await intent(), + snapshot(), + WORKLOAD_POLICY, + mismatched, + ), ).resolves.toMatchObject({ success: false, code: "ARTIFACT_RECORD_MISMATCH" }); }); + + it.each([ + ["repository ID", "repositoryId", "999999999", "ATTESTED_REPOSITORY_MISMATCH"], + [ + "workflow", + "builderId", + `${PROVENANCE.sourceRepository}/.github/workflows/weaker.yml@refs/heads/main`, + "ATTESTED_WORKFLOW_MISMATCH", + ], + ["ref", "workflowRef", "refs/heads/weaker", "ATTESTED_REF_MISMATCH"], + ["commit", "commitSha", "c".repeat(40), "ATTESTED_COMMIT_MISMATCH"], + [ + "invocation", + "invocationId", + `${PROVENANCE.sourceRepository}/actions/runs/999/attempts/1`, + "ATTESTED_INVOCATION_MISMATCH", + ], + ] as const)("rejects a mismatched attested %s", async (_name, field, value, reasonCode) => { + const report = verifierReport(); + if (!report.success) throw new Error("Expected successful fixture"); + report.value.provenance[field] = value; + + await expect( + evaluateVerifiedRelease(PUBLISHER_DID, await intent(), snapshot(), WORKLOAD_POLICY, report), + ).resolves.toMatchObject({ success: false, reasonCode }); + }); + + it("rejects a mismatched run identity", async () => { + const otherRun = structuredClone(WORKLOAD_IDENTITY); + otherRun.run.id = "999"; + await expect( + evaluateVerifiedRelease( + PUBLISHER_DID, + await intent(proposedRelease(), otherRun), + snapshot(), + WORKLOAD_POLICY, + verifierReport(), + ), + ).resolves.toMatchObject({ success: false, reasonCode: "ATTESTED_INVOCATION_MISMATCH" }); + }); + + it.each([ + ["malformed", '{"issuer":"github-actions"}', null], + ["non-canonical", JSON.stringify(WORKLOAD_IDENTITY, null, 2), null], + ["digest-mismatched", JSON.stringify(WORKLOAD_IDENTITY), "A".repeat(43)], + ] as const)("rejects %s stored workload identity state", async (_name, json, digestOverride) => { + const invalid = await intent(); + invalid.workloadIdentityJson = json; + invalid.workloadIdentityDigest = + digestOverride ?? (await digestWorkloadIdentity(WORKLOAD_IDENTITY)); + await expect( + evaluateVerifiedRelease( + PUBLISHER_DID, + invalid, + snapshot(), + WORKLOAD_POLICY, + verifierReport(), + ), + ).resolves.toMatchObject({ success: false, reasonCode: "WORKLOAD_IDENTITY_INVALID" }); + }); }); diff --git a/apps/release-service/vitest.config.ts b/apps/release-service/vitest.config.ts index e2c339b740..f6fe7d3069 100644 --- a/apps/release-service/vitest.config.ts +++ b/apps/release-service/vitest.config.ts @@ -49,6 +49,10 @@ export default defineConfig({ predicateType: input.provenance.predicateType, sourceRepository: input.provenance.sourceRepository, builderId: input.provenance.builderId, + repositoryId: "123456789", + workflowRef: "refs/heads/main", + commitSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + invocationId: "https://github.com/example/gallery/actions/runs/100/attempts/1", }, }, }; diff --git a/apps/release-verifier/src/verify.ts b/apps/release-verifier/src/verify.ts index 382018997c..50da042b6d 100644 --- a/apps/release-verifier/src/verify.ts +++ b/apps/release-verifier/src/verify.ts @@ -80,6 +80,10 @@ export interface VerifiedReleaseReport { predicateType: string; sourceRepository: string; builderId: string; + repositoryId: string; + workflowRef: string; + commitSha: string; + invocationId: string; artifactDigest: Uint8Array; }; } @@ -221,6 +225,18 @@ async function verifyLoadedRelease( profileRepository: input.profileRepository, }); if (!provenance.success) return provenance; + if ( + !("repositoryId" in provenance.value) || + typeof provenance.value.repositoryId !== "string" || + !("workflowRef" in provenance.value) || + typeof provenance.value.workflowRef !== "string" || + !("commitSha" in provenance.value) || + typeof provenance.value.commitSha !== "string" || + !("invocationId" in provenance.value) || + typeof provenance.value.invocationId !== "string" + ) { + return internalError("Release verification failed"); + } return { success: true, value: { @@ -233,6 +249,10 @@ async function verifyLoadedRelease( predicateType: provenance.value.predicateType, sourceRepository: provenance.value.sourceRepository, builderId: provenance.value.builderId, + repositoryId: provenance.value.repositoryId, + workflowRef: provenance.value.workflowRef, + commitSha: provenance.value.commitSha, + invocationId: provenance.value.invocationId, artifactDigest: provenance.value.artifactDigest.slice(), }, }, diff --git a/apps/release-verifier/test/verifier.test.ts b/apps/release-verifier/test/verifier.test.ts index 5026116114..d68d979799 100644 --- a/apps/release-verifier/test/verifier.test.ts +++ b/apps/release-verifier/test/verifier.test.ts @@ -18,6 +18,12 @@ import { const encoder = new TextEncoder(); const ARTIFACT_URL = "https://artifact.example.test/plugin.tgz"; +const VERIFIED_IDENTITY = { + repositoryId: "123456789", + workflowRef: "refs/heads/main", + commitSha: "b".repeat(40), + invocationId: "https://github.com/emdash-cms/gallery/actions/runs/100/attempts/1", +} as const; function file(name: string, body: string): TarEntry { const bytes = encoder.encode(body); @@ -141,6 +147,7 @@ describe("isolated release verifier", () => { artifactDigest: input.artifactDigest, sourceRepository: input.profileRepository, builderId: input.reference.builderId, + ...VERIFIED_IDENTITY, }, }; }, @@ -172,6 +179,7 @@ describe("isolated release verifier", () => { sourceRepository: "https://github.com/emdash-cms/gallery", builderId: "https://github.com/emdash-cms/gallery/.github/workflows/release.yml@refs/heads/main", + ...VERIFIED_IDENTITY, }, }; }, @@ -250,6 +258,7 @@ describe("isolated release verifier", () => { artifactDigest: candidate.artifactDigest, sourceRepository: candidate.profileRepository, builderId: candidate.reference.builderId, + ...VERIFIED_IDENTITY, }, }; }, @@ -290,6 +299,37 @@ describe("isolated release verifier", () => { }); }); + it("rejects provenance reports without verified workload identity", async () => { + const fixture = await createDelegatedReleaseConformanceFixture(); + const result = await verifyRelease(fixture.serviceInput, { + fetch: async (url) => + new Response( + url.toString() === fixture.artifactUrl + ? fixture.artifactBytes + : fixture.provenanceDocument, + ), + resolveHostname: async () => ["203.0.113.5"], + provenanceVerifier: { + async verify(input) { + return { + success: true, + value: { + predicateType: "https://slsa.dev/provenance/v1", + artifactDigest: input.artifactDigest, + sourceRepository: input.profileRepository, + builderId: input.reference.builderId, + }, + }; + }, + }, + }); + + expect(result).toEqual({ + success: false, + error: { code: "VERIFIER_INTERNAL_ERROR", message: "Release verification failed" }, + }); + }); + it("matches the shared delegated-release service output contract", async () => { const fixture = await createDelegatedReleaseConformanceFixture(); const result = await verifyRelease(fixture.serviceInput, { @@ -329,6 +369,10 @@ describe("isolated release verifier", () => { artifactDigest: input.artifactDigest, sourceRepository: fixture.expected.repository, builderId: fixture.expected.builderId, + repositoryId: fixture.expected.repositoryId, + workflowRef: fixture.expected.workflowRef, + commitSha: fixture.expected.commitSha, + invocationId: fixture.expected.invocationId, }, })); const result = await verifyRelease( diff --git a/packages/registry-verification/fixtures/conformance/delegated-release.ts b/packages/registry-verification/fixtures/conformance/delegated-release.ts index 327fc60595..c9f2355a36 100644 --- a/packages/registry-verification/fixtures/conformance/delegated-release.ts +++ b/packages/registry-verification/fixtures/conformance/delegated-release.ts @@ -53,6 +53,10 @@ export interface DelegatedReleaseConformanceFixture { repository: string; builderId: string; predicateType: string; + repositoryId: string; + workflowRef: string; + commitSha: string; + invocationId: string; }; } @@ -72,6 +76,10 @@ export async function createDelegatedReleaseConformanceFixture( const builderId = "https://github.com/emdash-cms/gallery/.github/workflows/release.yml@refs/heads/main"; const predicateType = "https://slsa.dev/provenance/v1"; + const repositoryId = "123456789"; + const workflowRef = "refs/heads/main"; + const commitSha = "b".repeat(40); + const invocationId = `${repository}/actions/runs/100/attempts/1`; const artifactUrl = "https://artifact.example.test/gallery.tgz"; const provenanceUrl = "https://provenance.example.test/gallery.sigstore.json"; const declaredAccess = options.declaredAccess ?? { content: { read: {} } }; @@ -192,6 +200,10 @@ export async function createDelegatedReleaseConformanceFixture( artifactDigest: matchedArtifactDigest, sourceRepository: repository, builderId, + repositoryId, + workflowRef, + commitSha, + invocationId, }, }; }, @@ -221,7 +233,15 @@ export async function createDelegatedReleaseConformanceFixture( provenance: reference, profileRepository: repository, }, - expected: { repository, builderId, predicateType }, + expected: { + repository, + builderId, + predicateType, + repositoryId, + workflowRef, + commitSha, + invocationId, + }, }; } diff --git a/packages/registry-verification/src/index.ts b/packages/registry-verification/src/index.ts index 04bb163433..7f5f9f5699 100644 --- a/packages/registry-verification/src/index.ts +++ b/packages/registry-verification/src/index.ts @@ -46,6 +46,7 @@ export type { ProvenanceVerificationInput, ProvenanceVerifier, ReleaseProvenance, + VerifiedGitHubProvenance, VerifiedProvenance, } from "./provenance.js"; export type { diff --git a/packages/registry-verification/src/provenance.ts b/packages/registry-verification/src/provenance.ts index 593ae8613c..fd5f8d794f 100644 --- a/packages/registry-verification/src/provenance.ts +++ b/packages/registry-verification/src/provenance.ts @@ -57,6 +57,13 @@ export interface VerifiedProvenance { builderId: string; } +export interface VerifiedGitHubProvenance extends VerifiedProvenance { + repositoryId: string; + workflowRef: string; + commitSha: string; + invocationId: string; +} + export interface ProvenanceVerifier { verify(input: ProvenanceVerificationInput): Promise>; } @@ -64,7 +71,7 @@ export interface ProvenanceVerifier { export class GitHubProvenanceVerifier implements ProvenanceVerifier { async verify( input: ProvenanceVerificationInput, - ): Promise> { + ): Promise> { try { const snapshot = snapshotInput(input); const checksum = await verifyMultihash(snapshot.document, snapshot.reference.checksum); @@ -87,6 +94,10 @@ export class GitHubProvenanceVerifier implements ProvenanceVerifier { artifactDigest: expected.artifactDigest, sourceRepository: expected.repository, builderId: expected.builderId, + repositoryId: expected.repositoryId, + workflowRef: expected.workflowRef, + commitSha: expected.commitSha, + invocationId: expected.invocationId, }, }; } catch { diff --git a/packages/registry-verification/tests/provenance-contract.ts b/packages/registry-verification/tests/provenance-contract.ts index 0a866af036..1f263104c2 100644 --- a/packages/registry-verification/tests/provenance-contract.ts +++ b/packages/registry-verification/tests/provenance-contract.ts @@ -17,6 +17,10 @@ const sourceRepository = "https://github.com/sigstore/sigstore-js"; const builderId = "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main"; const predicateType = "https://slsa.dev/provenance/v1"; +const repositoryId = "495574555"; +const workflowRef = "refs/heads/main"; +const commitSha = "d406ea60b342ca37cdeecd7afedb992cd189db92"; +const invocationId = "https://github.com/sigstore/sigstore-js/actions/runs/28204693054/attempts/1"; const algorithmVectors = [ { @@ -73,8 +77,12 @@ export function provenanceContract(): void { value: { artifactDigest, builderId, + commitSha, + invocationId, predicateType, + repositoryId, sourceRepository, + workflowRef, }, }); });