sigstore: add function to verify image attestations

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2026-01-13 12:01:07 +01:00
parent a3d5eee63a
commit 7397cfe37c
3 changed files with 123 additions and 74 deletions

View File

@@ -14,7 +14,7 @@
* limitations under the License. * 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 fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@@ -23,7 +23,10 @@ import {Sigstore} from '../../src/sigstore/sigstore';
const fixturesDir = path.join(__dirname, '..', '.fixtures'); 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 // needs current GitHub repo info
jest.unmock('@actions/github'); jest.unmock('@actions/github');
@@ -36,7 +39,29 @@ beforeAll(async () => {
await cosignInstall.install(cosignBinPath); await cosignInstall.install(cosignBinPath);
}, 100000); }, 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 () => { it('single platform', async () => {
const sigstore = new Sigstore(); const sigstore = new Sigstore();
const results = await sigstore.signProvenanceBlobs({ const results = await sigstore.signProvenanceBlobs({
@@ -68,7 +93,7 @@ maybe('signProvenanceBlobs', () => {
}); });
}); });
maybe('verifySignedArtifacts', () => { maybeIdToken('verifySignedArtifacts', () => {
it('sign and verify', async () => { it('sign and verify', async () => {
const sigstore = new Sigstore(); const sigstore = new Sigstore();
const signResults = await sigstore.signProvenanceBlobs({ const signResults = await sigstore.signProvenanceBlobs({
@@ -76,12 +101,9 @@ maybe('verifySignedArtifacts', () => {
}); });
expect(Object.keys(signResults).length).toEqual(2); expect(Object.keys(signResults).length).toEqual(2);
const verifyResults = await sigstore.verifySignedArtifacts( const verifyResults = await sigstore.verifySignedArtifacts(signResults, {
{
certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$` certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$`
}, });
signResults
);
expect(Object.keys(verifyResults).length).toEqual(2); expect(Object.keys(verifyResults).length).toEqual(2);
for (const [artifactPath, res] of Object.entries(verifyResults)) { for (const [artifactPath, res] of Object.entries(verifyResults)) {
expect(fs.existsSync(artifactPath)).toBe(true); expect(fs.existsSync(artifactPath)).toBe(true);

View File

@@ -127,17 +127,48 @@ export class Sigstore {
return result; return result;
} }
public async verifySignedManifests(opts: VerifySignedManifestsOpts, signed: Record<string, SignAttestationManifestsResult>): Promise<Record<string, VerifySignedManifestsResult>> { public async verifySignedManifests(signedManifestsResult: Record<string, SignAttestationManifestsResult>, opts: VerifySignedManifestsOpts): Promise<Record<string, VerifySignedManifestsResult>> {
const result: Record<string, VerifySignedManifestsResult> = {}; const result: Record<string, VerifySignedManifestsResult> = {};
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<Record<string, VerifySignedManifestsResult>> {
const result: Record<string, VerifySignedManifestsResult> = {};
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<VerifySignedManifestsResult> {
const retries = opts.retries ?? 15; const retries = opts.retries ?? 15;
if (!(await this.cosign.isAvailable())) { if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to verify signed manifests'); throw new Error('Cosign is required to verify signed manifests');
} }
let lastError: Error | undefined;
for (const [attestationRef, signedRes] of Object.entries(signed)) {
await core.group(`Verifying signature of ${attestationRef}`, async () => {
// prettier-ignore // prettier-ignore
const cosignArgs = [ const cosignArgs = [
'verify', 'verify',
@@ -146,10 +177,12 @@ export class Sigstore {
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
'--certificate-identity-regexp', opts.certificateIdentityRegexp '--certificate-identity-regexp', opts.certificateIdentityRegexp
]; ];
if (!signedRes.tlogID) { if (opts.noTransparencyLog) {
// skip tlog verification but still verify the signed timestamp // skip tlog verification but still verify the signed timestamp
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
} }
let lastError: Error | undefined;
core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`); core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`);
for (let attempt = 0; attempt < retries; attempt++) { for (let attempt = 0; attempt < retries; attempt++) {
const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], { const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], {
@@ -161,13 +194,10 @@ export class Sigstore {
}); });
const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim()); const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim());
if (execRes.exitCode === 0) { if (execRes.exitCode === 0) {
result[attestationRef] = { return {
cosignArgs: cosignArgs, cosignArgs: cosignArgs,
signatureManifestDigest: verifyResult.signatureManifestDigest! signatureManifestDigest: verifyResult.signatureManifestDigest!
}; };
lastError = undefined;
core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`);
break;
} else { } else {
if (verifyResult.errors && verifyResult.errors.length > 0) { if (verifyResult.errors && verifyResult.errors.length > 0) {
const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n'); const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n');
@@ -183,13 +213,8 @@ export class Sigstore {
} }
} }
} }
});
}
if (lastError) {
throw lastError;
}
return result; throw lastError;
} }
public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> { public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
@@ -245,12 +270,12 @@ export class Sigstore {
return result; return result;
} }
public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record<string, SignProvenanceBlobsResult>): Promise<Record<string, VerifySignedArtifactsResult>> { public async verifySignedArtifacts(signedArtifactsResult: Record<string, SignProvenanceBlobsResult>, opts: VerifySignedArtifactsOpts): Promise<Record<string, VerifySignedArtifactsResult>> {
const result: Record<string, VerifySignedArtifactsResult> = {}; const result: Record<string, VerifySignedArtifactsResult> = {};
if (!(await this.cosign.isAvailable())) { if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to verify signed artifacts'); 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); const baseDir = path.dirname(provenancePath);
await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => { await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => {
for (const subject of signedRes.subjects) { for (const subject of signedRes.subjects) {
@@ -263,7 +288,7 @@ export class Sigstore {
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
'--certificate-identity-regexp', opts.certificateIdentityRegexp '--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 // if there is no tlog entry, we skip tlog verification but still verify the signed timestamp
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog'); cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
} }

View File

@@ -47,6 +47,7 @@ export interface SignAttestationManifestsResult extends ParsedBundle {
export interface VerifySignedManifestsOpts { export interface VerifySignedManifestsOpts {
certificateIdentityRegexp: string; certificateIdentityRegexp: string;
noTransparencyLog?: boolean;
retries?: number; retries?: number;
} }
@@ -68,6 +69,7 @@ export interface SignProvenanceBlobsResult extends ParsedBundle {
export interface VerifySignedArtifactsOpts { export interface VerifySignedArtifactsOpts {
certificateIdentityRegexp: string; certificateIdentityRegexp: string;
noTransparencyLog?: boolean;
} }
export interface VerifySignedArtifactsResult { export interface VerifySignedArtifactsResult {