From 7397cfe37ccd50b3d4bfd9958d97fe023077980d Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:01:07 +0100 Subject: [PATCH 1/2] sigstore: add function to verify image attestations Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/sigstore/sigstore.test.itg.ts | 42 +++++-- src/sigstore/sigstore.ts | 145 ++++++++++++++---------- src/types/sigstore/sigstore.ts | 2 + 3 files changed, 119 insertions(+), 70 deletions(-) diff --git a/__tests__/sigstore/sigstore.test.itg.ts b/__tests__/sigstore/sigstore.test.itg.ts index 6d4b3304..4d549fc4 100644 --- a/__tests__/sigstore/sigstore.test.itg.ts +++ b/__tests__/sigstore/sigstore.test.itg.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {describe, expect, jest, it, beforeAll} from '@jest/globals'; +import {beforeAll, describe, expect, jest, it, test} from '@jest/globals'; import fs from 'fs'; import * as path from 'path'; @@ -23,7 +23,10 @@ import {Sigstore} from '../../src/sigstore/sigstore'; const fixturesDir = path.join(__dirname, '..', '.fixtures'); -const maybe = process.env.GITHUB_ACTIONS && process.env.GITHUB_ACTIONS === 'true' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu') ? describe : describe.skip; +const runTest = process.env.GITHUB_ACTIONS && process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu'); + +const maybe = runTest ? describe : describe.skip; +const maybeIdToken = runTest && process.env.ACTIONS_ID_TOKEN_REQUEST_URL ? describe : describe.skip; // needs current GitHub repo info jest.unmock('@actions/github'); @@ -36,7 +39,29 @@ beforeAll(async () => { await cosignInstall.install(cosignBinPath); }, 100000); -maybe('signProvenanceBlobs', () => { +maybe('verifyImageAttestations', () => { + test.each([ + ['moby/buildkit:master@sha256:84014da3581b2ff2c14cb4f60029cf9caa272b79e58f2e89c651ea6966d7a505', `^https://github.com/docker/github-builder-experimental/.github/workflows/bake.yml.*$`], + ['docker/dockerfile-upstream:master@sha256:3e8cd5ebf48acd1a1939649ad1c62ca44c029852b22493c16a9307b654334958', `^https://github.com/docker/github-builder-experimental/.github/workflows/bake.yml.*$`] + ])( + 'given %p', + async (image, certificateIdentityRegexp) => { + const sigstore = new Sigstore(); + const verifyResults = await sigstore.verifyImageAttestations(image, { + certificateIdentityRegexp: certificateIdentityRegexp + }); + expect(Object.keys(verifyResults).length).toBeGreaterThan(0); + for (const [attestationRef, res] of Object.entries(verifyResults)) { + expect(attestationRef).toBeDefined(); + expect(res.cosignArgs).toBeDefined(); + expect(res.signatureManifestDigest).toBeDefined(); + } + }, + 60000 + ); +}); + +maybeIdToken('signProvenanceBlobs', () => { it('single platform', async () => { const sigstore = new Sigstore(); const results = await sigstore.signProvenanceBlobs({ @@ -68,7 +93,7 @@ maybe('signProvenanceBlobs', () => { }); }); -maybe('verifySignedArtifacts', () => { +maybeIdToken('verifySignedArtifacts', () => { it('sign and verify', async () => { const sigstore = new Sigstore(); const signResults = await sigstore.signProvenanceBlobs({ @@ -76,12 +101,9 @@ maybe('verifySignedArtifacts', () => { }); expect(Object.keys(signResults).length).toEqual(2); - const verifyResults = await sigstore.verifySignedArtifacts( - { - certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$` - }, - signResults - ); + const verifyResults = await sigstore.verifySignedArtifacts(signResults, { + certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$` + }); expect(Object.keys(verifyResults).length).toEqual(2); for (const [artifactPath, res] of Object.entries(verifyResults)) { expect(fs.existsSync(artifactPath)).toBe(true); diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index f2529313..6c57afeb 100644 --- a/src/sigstore/sigstore.ts +++ b/src/sigstore/sigstore.ts @@ -80,13 +80,13 @@ export class Sigstore { await core.group(`Signing attestation manifest ${attestationRef}`, async () => { // prettier-ignore const cosignArgs = [ - 'sign', - '--yes', - '--oidc-provider', 'github-actions', - '--registry-referrers-mode', 'oci-1-1', - '--new-bundle-format', - '--use-signing-config' - ]; + 'sign', + '--yes', + '--oidc-provider', 'github-actions', + '--registry-referrers-mode', 'oci-1-1', + '--new-bundle-format', + '--use-signing-config' + ]; if (noTransparencyLog) { cosignArgs.push('--tlog-upload=false'); } @@ -127,69 +127,94 @@ export class Sigstore { return result; } - public async verifySignedManifests(opts: VerifySignedManifestsOpts, signed: Record): Promise> { + public async verifySignedManifests(signedManifestsResult: Record, opts: VerifySignedManifestsOpts): Promise> { + const result: Record = {}; + for (const [attestationRef, signedRes] of Object.entries(signedManifestsResult)) { + await core.group(`Verifying signature of ${attestationRef}`, async () => { + const verifyResult = await this.verifyImageAttestation(attestationRef, { + noTransparencyLog: opts.noTransparencyLog || !signedRes.tlogID, + certificateIdentityRegexp: opts.certificateIdentityRegexp, + retries: opts.retries + }); + core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`); + result[attestationRef] = verifyResult; + }); + } + return result; + } + + public async verifyImageAttestations(image: string, opts: VerifySignedManifestsOpts): Promise> { const result: Record = {}; + + const attestationDigests = await this.imageTools.attestationDigests(image); + if (attestationDigests.length === 0) { + throw new Error(`No attestation manifests found for ${image}`); + } + + const imageName = image.split(':', 1)[0]; + for (const attestationDigest of attestationDigests) { + const attestationRef = `${imageName}@${attestationDigest}`; + const verifyResult = await this.verifyImageAttestation(attestationRef, opts); + core.info(`Signature manifest verified: https://oci.dag.dev/?image=${imageName}@${verifyResult.signatureManifestDigest}`); + result[attestationRef] = verifyResult; + } + + return result; + } + + public async verifyImageAttestation(attestationRef: string, opts: VerifySignedManifestsOpts): Promise { const retries = opts.retries ?? 15; if (!(await this.cosign.isAvailable())) { throw new Error('Cosign is required to verify signed manifests'); } + // prettier-ignore + const cosignArgs = [ + 'verify', + '--experimental-oci11', + '--new-bundle-format', + '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', + '--certificate-identity-regexp', opts.certificateIdentityRegexp + ]; + if (opts.noTransparencyLog) { + // skip tlog verification but still verify the signed timestamp + cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); + } + let lastError: Error | undefined; - for (const [attestationRef, signedRes] of Object.entries(signed)) { - await core.group(`Verifying signature of ${attestationRef}`, async () => { - // prettier-ignore - const cosignArgs = [ - 'verify', - '--experimental-oci11', - '--new-bundle-format', - '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', - '--certificate-identity-regexp', opts.certificateIdentityRegexp - ]; - if (!signedRes.tlogID) { - // skip tlog verification but still verify the signed timestamp - cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); - } - core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`); - for (let attempt = 0; attempt < retries; attempt++) { - const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], { - ignoreReturnCode: true, - silent: true, - env: Object.assign({}, process.env, { - COSIGN_EXPERIMENTAL: '1' - }) as {[key: string]: string} - }); - const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim()); - if (execRes.exitCode === 0) { - result[attestationRef] = { - cosignArgs: cosignArgs, - signatureManifestDigest: verifyResult.signatureManifestDigest! - }; - lastError = undefined; - core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`); - break; + core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`); + for (let attempt = 0; attempt < retries; attempt++) { + const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], { + ignoreReturnCode: true, + silent: true, + env: Object.assign({}, process.env, { + COSIGN_EXPERIMENTAL: '1' + }) as {[key: string]: string} + }); + const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim()); + if (execRes.exitCode === 0) { + return { + cosignArgs: cosignArgs, + signatureManifestDigest: verifyResult.signatureManifestDigest! + }; + } else { + if (verifyResult.errors && verifyResult.errors.length > 0) { + const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n'); + lastError = new Error(`Cosign verify command failed with errors:\n${errorMessages}`); + if (verifyResult.errors.some(e => e.code === 'MANIFEST_UNKNOWN')) { + core.info(`Cosign verify command failed with MANIFEST_UNKNOWN, retrying attempt ${attempt + 1}/${retries}...\n${errorMessages}`); + await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 100)); } else { - if (verifyResult.errors && verifyResult.errors.length > 0) { - const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n'); - lastError = new Error(`Cosign verify command failed with errors:\n${errorMessages}`); - if (verifyResult.errors.some(e => e.code === 'MANIFEST_UNKNOWN')) { - core.info(`Cosign verify command failed with MANIFEST_UNKNOWN, retrying attempt ${attempt + 1}/${retries}...\n${errorMessages}`); - await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 100)); - } else { - throw lastError; - } - } else { - throw new Error(`Cosign verify command failed: ${execRes.stderr}`); - } + throw lastError; } + } else { + throw new Error(`Cosign verify command failed: ${execRes.stderr}`); } - }); - } - if (lastError) { - throw lastError; + } } - return result; + throw lastError; } public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise> { @@ -245,12 +270,12 @@ export class Sigstore { return result; } - public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record): Promise> { + public async verifySignedArtifacts(signedArtifactsResult: Record, opts: VerifySignedArtifactsOpts): Promise> { const result: Record = {}; if (!(await this.cosign.isAvailable())) { throw new Error('Cosign is required to verify signed artifacts'); } - for (const [provenancePath, signedRes] of Object.entries(signed)) { + for (const [provenancePath, signedRes] of Object.entries(signedArtifactsResult)) { const baseDir = path.dirname(provenancePath); await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => { for (const subject of signedRes.subjects) { @@ -263,7 +288,7 @@ export class Sigstore { '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', '--certificate-identity-regexp', opts.certificateIdentityRegexp ] - if (!signedRes.tlogID) { + if (opts.noTransparencyLog || !signedRes.tlogID) { // if there is no tlog entry, we skip tlog verification but still verify the signed timestamp cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); } diff --git a/src/types/sigstore/sigstore.ts b/src/types/sigstore/sigstore.ts index 4a57dce0..bacce353 100644 --- a/src/types/sigstore/sigstore.ts +++ b/src/types/sigstore/sigstore.ts @@ -47,6 +47,7 @@ export interface SignAttestationManifestsResult extends ParsedBundle { export interface VerifySignedManifestsOpts { certificateIdentityRegexp: string; + noTransparencyLog?: boolean; retries?: number; } @@ -68,6 +69,7 @@ export interface SignProvenanceBlobsResult extends ParsedBundle { export interface VerifySignedArtifactsOpts { certificateIdentityRegexp: string; + noTransparencyLog?: boolean; } export interface VerifySignedArtifactsResult { From 0162b2cf8b8493d249268cca130ef01905c35166 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:17:40 +0100 Subject: [PATCH 2/2] cosign: clear errors if manifest or bundle payload found Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- src/cosign/cosign.ts | 7 ++----- src/sigstore/sigstore.ts | 6 ++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/cosign/cosign.ts b/src/cosign/cosign.ts index 0e4e4308..f280caf3 100644 --- a/src/cosign/cosign.ts +++ b/src/cosign/cosign.ts @@ -142,15 +142,12 @@ export class Cosign { bundlePayload = obj as SerializedBundle; } - if (bundlePayload && signatureManifestDigest) { + if (bundlePayload && (signatureManifestDigest || signatureManifestFallbackDigest)) { + errors = undefined; // clear errors if we have both payload and manifest digest break; } } - if (!errors && !bundlePayload) { - throw new Error(`Cannot find signature bundle from cosign command output: ${logs}`); - } - return { bundle: bundlePayload, signatureManifestDigest: signatureManifestDigest || signatureManifestFallbackDigest, diff --git a/src/sigstore/sigstore.ts b/src/sigstore/sigstore.ts index 6c57afeb..8c2b3e55 100644 --- a/src/sigstore/sigstore.ts +++ b/src/sigstore/sigstore.ts @@ -106,7 +106,8 @@ export class Sigstore { const errorMessages = signResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n'); throw new Error(`Cosign sign command failed with errors:\n${errorMessages}`); } else { - throw new Error(`Cosign sign command failed with exit code ${execRes.exitCode}`); + // prettier-ignore + throw new Error(`Cosign sign command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`); } } const parsedBundle = Sigstore.parseBundle(bundleFromJSON(signResult.bundle)); @@ -209,7 +210,8 @@ export class Sigstore { throw lastError; } } else { - throw new Error(`Cosign verify command failed: ${execRes.stderr}`); + // prettier-ignore + throw new Error(`Cosign verify command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`); } } }