Skip to content

Commit 2e620f9

Browse files
authored
Merge pull request #932 from crazy-max/sigstore-verifyimage
sigstore: add function to verify image attestations
2 parents a3d5eee + 0162b2c commit 2e620f9

4 files changed

Lines changed: 124 additions & 76 deletions

File tree

__tests__/sigstore/sigstore.test.itg.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import {describe, expect, jest, it, beforeAll} from '@jest/globals';
17+
import {beforeAll, describe, expect, jest, it, test} from '@jest/globals';
1818
import fs from 'fs';
1919
import * as path from 'path';
2020

@@ -23,7 +23,10 @@ import {Sigstore} from '../../src/sigstore/sigstore';
2323

2424
const fixturesDir = path.join(__dirname, '..', '.fixtures');
2525

26-
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;
26+
const runTest = process.env.GITHUB_ACTIONS && process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu');
27+
28+
const maybe = runTest ? describe : describe.skip;
29+
const maybeIdToken = runTest && process.env.ACTIONS_ID_TOKEN_REQUEST_URL ? describe : describe.skip;
2730

2831
// needs current GitHub repo info
2932
jest.unmock('@actions/github');
@@ -36,7 +39,29 @@ beforeAll(async () => {
3639
await cosignInstall.install(cosignBinPath);
3740
}, 100000);
3841

39-
maybe('signProvenanceBlobs', () => {
42+
maybe('verifyImageAttestations', () => {
43+
test.each([
44+
['moby/buildkit:master@sha256:84014da3581b2ff2c14cb4f60029cf9caa272b79e58f2e89c651ea6966d7a505', `^https://github.com/docker/github-builder-experimental/.github/workflows/bake.yml.*$`],
45+
['docker/dockerfile-upstream:master@sha256:3e8cd5ebf48acd1a1939649ad1c62ca44c029852b22493c16a9307b654334958', `^https://github.com/docker/github-builder-experimental/.github/workflows/bake.yml.*$`]
46+
])(
47+
'given %p',
48+
async (image, certificateIdentityRegexp) => {
49+
const sigstore = new Sigstore();
50+
const verifyResults = await sigstore.verifyImageAttestations(image, {
51+
certificateIdentityRegexp: certificateIdentityRegexp
52+
});
53+
expect(Object.keys(verifyResults).length).toBeGreaterThan(0);
54+
for (const [attestationRef, res] of Object.entries(verifyResults)) {
55+
expect(attestationRef).toBeDefined();
56+
expect(res.cosignArgs).toBeDefined();
57+
expect(res.signatureManifestDigest).toBeDefined();
58+
}
59+
},
60+
60000
61+
);
62+
});
63+
64+
maybeIdToken('signProvenanceBlobs', () => {
4065
it('single platform', async () => {
4166
const sigstore = new Sigstore();
4267
const results = await sigstore.signProvenanceBlobs({
@@ -68,20 +93,17 @@ maybe('signProvenanceBlobs', () => {
6893
});
6994
});
7095

71-
maybe('verifySignedArtifacts', () => {
96+
maybeIdToken('verifySignedArtifacts', () => {
7297
it('sign and verify', async () => {
7398
const sigstore = new Sigstore();
7499
const signResults = await sigstore.signProvenanceBlobs({
75100
localExportDir: path.join(fixturesDir, 'sigstore', 'multi')
76101
});
77102
expect(Object.keys(signResults).length).toEqual(2);
78103

79-
const verifyResults = await sigstore.verifySignedArtifacts(
80-
{
81-
certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$`
82-
},
83-
signResults
84-
);
104+
const verifyResults = await sigstore.verifySignedArtifacts(signResults, {
105+
certificateIdentityRegexp: `^https://github.com/docker/actions-toolkit/.github/workflows/test.yml.*$`
106+
});
85107
expect(Object.keys(verifyResults).length).toEqual(2);
86108
for (const [artifactPath, res] of Object.entries(verifyResults)) {
87109
expect(fs.existsSync(artifactPath)).toBe(true);

src/cosign/cosign.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,12 @@ export class Cosign {
142142
bundlePayload = obj as SerializedBundle;
143143
}
144144

145-
if (bundlePayload && signatureManifestDigest) {
145+
if (bundlePayload && (signatureManifestDigest || signatureManifestFallbackDigest)) {
146+
errors = undefined; // clear errors if we have both payload and manifest digest
146147
break;
147148
}
148149
}
149150

150-
if (!errors && !bundlePayload) {
151-
throw new Error(`Cannot find signature bundle from cosign command output: ${logs}`);
152-
}
153-
154151
return {
155152
bundle: bundlePayload,
156153
signatureManifestDigest: signatureManifestDigest || signatureManifestFallbackDigest,

src/sigstore/sigstore.ts

Lines changed: 88 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,13 @@ export class Sigstore {
8080
await core.group(`Signing attestation manifest ${attestationRef}`, async () => {
8181
// prettier-ignore
8282
const cosignArgs = [
83-
'sign',
84-
'--yes',
85-
'--oidc-provider', 'github-actions',
86-
'--registry-referrers-mode', 'oci-1-1',
87-
'--new-bundle-format',
88-
'--use-signing-config'
89-
];
83+
'sign',
84+
'--yes',
85+
'--oidc-provider', 'github-actions',
86+
'--registry-referrers-mode', 'oci-1-1',
87+
'--new-bundle-format',
88+
'--use-signing-config'
89+
];
9090
if (noTransparencyLog) {
9191
cosignArgs.push('--tlog-upload=false');
9292
}
@@ -106,7 +106,8 @@ export class Sigstore {
106106
const errorMessages = signResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n');
107107
throw new Error(`Cosign sign command failed with errors:\n${errorMessages}`);
108108
} else {
109-
throw new Error(`Cosign sign command failed with exit code ${execRes.exitCode}`);
109+
// prettier-ignore
110+
throw new Error(`Cosign sign command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
110111
}
111112
}
112113
const parsedBundle = Sigstore.parseBundle(bundleFromJSON(signResult.bundle));
@@ -127,69 +128,95 @@ export class Sigstore {
127128
return result;
128129
}
129130

130-
public async verifySignedManifests(opts: VerifySignedManifestsOpts, signed: Record<string, SignAttestationManifestsResult>): Promise<Record<string, VerifySignedManifestsResult>> {
131+
public async verifySignedManifests(signedManifestsResult: Record<string, SignAttestationManifestsResult>, opts: VerifySignedManifestsOpts): Promise<Record<string, VerifySignedManifestsResult>> {
132+
const result: Record<string, VerifySignedManifestsResult> = {};
133+
for (const [attestationRef, signedRes] of Object.entries(signedManifestsResult)) {
134+
await core.group(`Verifying signature of ${attestationRef}`, async () => {
135+
const verifyResult = await this.verifyImageAttestation(attestationRef, {
136+
noTransparencyLog: opts.noTransparencyLog || !signedRes.tlogID,
137+
certificateIdentityRegexp: opts.certificateIdentityRegexp,
138+
retries: opts.retries
139+
});
140+
core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`);
141+
result[attestationRef] = verifyResult;
142+
});
143+
}
144+
return result;
145+
}
146+
147+
public async verifyImageAttestations(image: string, opts: VerifySignedManifestsOpts): Promise<Record<string, VerifySignedManifestsResult>> {
131148
const result: Record<string, VerifySignedManifestsResult> = {};
149+
150+
const attestationDigests = await this.imageTools.attestationDigests(image);
151+
if (attestationDigests.length === 0) {
152+
throw new Error(`No attestation manifests found for ${image}`);
153+
}
154+
155+
const imageName = image.split(':', 1)[0];
156+
for (const attestationDigest of attestationDigests) {
157+
const attestationRef = `${imageName}@${attestationDigest}`;
158+
const verifyResult = await this.verifyImageAttestation(attestationRef, opts);
159+
core.info(`Signature manifest verified: https://oci.dag.dev/?image=${imageName}@${verifyResult.signatureManifestDigest}`);
160+
result[attestationRef] = verifyResult;
161+
}
162+
163+
return result;
164+
}
165+
166+
public async verifyImageAttestation(attestationRef: string, opts: VerifySignedManifestsOpts): Promise<VerifySignedManifestsResult> {
132167
const retries = opts.retries ?? 15;
133168

134169
if (!(await this.cosign.isAvailable())) {
135170
throw new Error('Cosign is required to verify signed manifests');
136171
}
137172

173+
// prettier-ignore
174+
const cosignArgs = [
175+
'verify',
176+
'--experimental-oci11',
177+
'--new-bundle-format',
178+
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
179+
'--certificate-identity-regexp', opts.certificateIdentityRegexp
180+
];
181+
if (opts.noTransparencyLog) {
182+
// skip tlog verification but still verify the signed timestamp
183+
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
184+
}
185+
138186
let lastError: Error | undefined;
139-
for (const [attestationRef, signedRes] of Object.entries(signed)) {
140-
await core.group(`Verifying signature of ${attestationRef}`, async () => {
141-
// prettier-ignore
142-
const cosignArgs = [
143-
'verify',
144-
'--experimental-oci11',
145-
'--new-bundle-format',
146-
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
147-
'--certificate-identity-regexp', opts.certificateIdentityRegexp
148-
];
149-
if (!signedRes.tlogID) {
150-
// skip tlog verification but still verify the signed timestamp
151-
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
152-
}
153-
core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`);
154-
for (let attempt = 0; attempt < retries; attempt++) {
155-
const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], {
156-
ignoreReturnCode: true,
157-
silent: true,
158-
env: Object.assign({}, process.env, {
159-
COSIGN_EXPERIMENTAL: '1'
160-
}) as {[key: string]: string}
161-
});
162-
const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim());
163-
if (execRes.exitCode === 0) {
164-
result[attestationRef] = {
165-
cosignArgs: cosignArgs,
166-
signatureManifestDigest: verifyResult.signatureManifestDigest!
167-
};
168-
lastError = undefined;
169-
core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`);
170-
break;
187+
core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`);
188+
for (let attempt = 0; attempt < retries; attempt++) {
189+
const execRes = await Exec.getExecOutput('cosign', ['--verbose', ...cosignArgs, attestationRef], {
190+
ignoreReturnCode: true,
191+
silent: true,
192+
env: Object.assign({}, process.env, {
193+
COSIGN_EXPERIMENTAL: '1'
194+
}) as {[key: string]: string}
195+
});
196+
const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim());
197+
if (execRes.exitCode === 0) {
198+
return {
199+
cosignArgs: cosignArgs,
200+
signatureManifestDigest: verifyResult.signatureManifestDigest!
201+
};
202+
} else {
203+
if (verifyResult.errors && verifyResult.errors.length > 0) {
204+
const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n');
205+
lastError = new Error(`Cosign verify command failed with errors:\n${errorMessages}`);
206+
if (verifyResult.errors.some(e => e.code === 'MANIFEST_UNKNOWN')) {
207+
core.info(`Cosign verify command failed with MANIFEST_UNKNOWN, retrying attempt ${attempt + 1}/${retries}...\n${errorMessages}`);
208+
await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 100));
171209
} else {
172-
if (verifyResult.errors && verifyResult.errors.length > 0) {
173-
const errorMessages = verifyResult.errors.map(e => `- [${e.code}] ${e.message} : ${e.detail}`).join('\n');
174-
lastError = new Error(`Cosign verify command failed with errors:\n${errorMessages}`);
175-
if (verifyResult.errors.some(e => e.code === 'MANIFEST_UNKNOWN')) {
176-
core.info(`Cosign verify command failed with MANIFEST_UNKNOWN, retrying attempt ${attempt + 1}/${retries}...\n${errorMessages}`);
177-
await new Promise(res => setTimeout(res, Math.pow(2, attempt) * 100));
178-
} else {
179-
throw lastError;
180-
}
181-
} else {
182-
throw new Error(`Cosign verify command failed: ${execRes.stderr}`);
183-
}
210+
throw lastError;
184211
}
212+
} else {
213+
// prettier-ignore
214+
throw new Error(`Cosign verify command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
185215
}
186-
});
187-
}
188-
if (lastError) {
189-
throw lastError;
216+
}
190217
}
191218

192-
return result;
219+
throw lastError;
193220
}
194221

195222
public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
@@ -245,12 +272,12 @@ export class Sigstore {
245272
return result;
246273
}
247274

248-
public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record<string, SignProvenanceBlobsResult>): Promise<Record<string, VerifySignedArtifactsResult>> {
275+
public async verifySignedArtifacts(signedArtifactsResult: Record<string, SignProvenanceBlobsResult>, opts: VerifySignedArtifactsOpts): Promise<Record<string, VerifySignedArtifactsResult>> {
249276
const result: Record<string, VerifySignedArtifactsResult> = {};
250277
if (!(await this.cosign.isAvailable())) {
251278
throw new Error('Cosign is required to verify signed artifacts');
252279
}
253-
for (const [provenancePath, signedRes] of Object.entries(signed)) {
280+
for (const [provenancePath, signedRes] of Object.entries(signedArtifactsResult)) {
254281
const baseDir = path.dirname(provenancePath);
255282
await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => {
256283
for (const subject of signedRes.subjects) {
@@ -263,7 +290,7 @@ export class Sigstore {
263290
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
264291
'--certificate-identity-regexp', opts.certificateIdentityRegexp
265292
]
266-
if (!signedRes.tlogID) {
293+
if (opts.noTransparencyLog || !signedRes.tlogID) {
267294
// if there is no tlog entry, we skip tlog verification but still verify the signed timestamp
268295
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
269296
}

src/types/sigstore/sigstore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface SignAttestationManifestsResult extends ParsedBundle {
4747

4848
export interface VerifySignedManifestsOpts {
4949
certificateIdentityRegexp: string;
50+
noTransparencyLog?: boolean;
5051
retries?: number;
5152
}
5253

@@ -68,6 +69,7 @@ export interface SignProvenanceBlobsResult extends ParsedBundle {
6869

6970
export interface VerifySignedArtifactsOpts {
7071
certificateIdentityRegexp: string;
72+
noTransparencyLog?: boolean;
7173
}
7274

7375
export interface VerifySignedArtifactsResult {

0 commit comments

Comments
 (0)