Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 32 additions & 10 deletions __tests__/sigstore/sigstore.test.itg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand All @@ -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({
Expand Down Expand Up @@ -68,20 +93,17 @@ maybe('signProvenanceBlobs', () => {
});
});

maybe('verifySignedArtifacts', () => {
maybeIdToken('verifySignedArtifacts', () => {
it('sign and verify', async () => {
const sigstore = new Sigstore();
const signResults = await sigstore.signProvenanceBlobs({
localExportDir: path.join(fixturesDir, 'sigstore', 'multi')
});
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);
Expand Down
7 changes: 2 additions & 5 deletions src/cosign/cosign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
149 changes: 88 additions & 61 deletions src/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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));
Expand All @@ -127,69 +128,95 @@ export class Sigstore {
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> = {};
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;

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 {
// prettier-ignore
throw new Error(`Cosign verify command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
}
});
}
if (lastError) {
throw lastError;
}
}

return result;
throw lastError;
}

public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
Expand Down Expand Up @@ -245,12 +272,12 @@ export class Sigstore {
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> = {};
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) {
Expand All @@ -263,7 +290,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');
}
Expand Down
2 changes: 2 additions & 0 deletions src/types/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface SignAttestationManifestsResult extends ParsedBundle {

export interface VerifySignedManifestsOpts {
certificateIdentityRegexp: string;
noTransparencyLog?: boolean;
retries?: number;
}

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

export interface VerifySignedArtifactsOpts {
certificateIdentityRegexp: string;
noTransparencyLog?: boolean;
}

export interface VerifySignedArtifactsResult {
Expand Down
Loading