Merge pull request #830 from crazy-max/signing-manifest

sigstore: sign and verify BuildKit attestation manifests
This commit is contained in:
CrazyMax
2025-11-03 11:42:27 +01:00
committed by GitHub
7 changed files with 1398 additions and 8 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,96 @@
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/
2025/10/31 13:57:03 GET /v2/ HTTP/1.1
Host: index.docker.io
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
Accept-Encoding: gzip
2025/10/31 13:57:03 <-- 401 https://index.docker.io/v2/ (191.948348ms)
2025/10/31 13:57:03 HTTP/2.0 401 Unauthorized
Content-Length: 87
Content-Type: application/json
Date: Fri, 31 Oct 2025 13:57:03 GMT
Docker-Distribution-Api-Version: registry/2.0
Strict-Transport-Security: max-age=31536000
Www-Authenticate: ***"https://auth.docker.io/token",service="registry.docker.io"
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
2025/10/31 13:57:03 --> GET https://auth.docker.io/token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io [body redacted: basic token response contains credentials]
2025/10/31 13:57:03 GET /token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io HTTP/1.1
Host: auth.docker.io
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
Authorization: <redacted>
Accept-Encoding: gzip
2025/10/31 13:57:03 <-- 200 https://auth.docker.io/token?scope=repository%3Acrazymax%2Fgithub-builder-test%3Apull&service=registry.docker.io (180.01561ms) [body redacted: basic token response contains credentials]
2025/10/31 13:57:03 HTTP/2.0 200 OK
Connection: close
Content-Type: application/json
Date: Fri, 31 Oct 2025 13:57:03 GMT
Strict-Transport-Security: max-age=31536000
X-Trace-Id: 8d63fbce36baf5f2a0c5f2542efa7a7a
X-Trace-Sampled: false
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 HTTP/1.1
Host: index.docker.io
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
Accept: application/vnd.oci.image.index.v1+json
Authorization: <redacted>
Accept-Encoding: gzip
2025/10/31 13:57:03 <-- 200 https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 (84.160823ms)
2025/10/31 13:57:03 HTTP/2.0 200 OK
Content-Length: 89
Content-Type: application/vnd.oci.image.index.v1+json
Date: Fri, 31 Oct 2025 13:57:03 GMT
Docker-Distribution-Api-Version: registry/2.0
Strict-Transport-Security: max-age=31536000
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 HTTP/1.1
Host: index.docker.io
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
Accept: application/vnd.oci.image.index.v1+json
Authorization: <redacted>
Accept-Encoding: gzip
2025/10/31 13:57:03 <-- 200 https://index.docker.io/v2/crazymax/github-builder-test/referrers/sha256:6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0 (95.303988ms)
2025/10/31 13:57:03 HTTP/2.0 200 OK
Content-Length: 89
Content-Type: application/vnd.oci.image.index.v1+json
Date: Fri, 31 Oct 2025 13:57:03 GMT
Docker-Distribution-Api-Version: registry/2.0
Strict-Transport-Security: max-age=31536000
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[]}
2025/10/31 13:57:03 --> GET https://index.docker.io/v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig
2025/10/31 13:57:03 GET /v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig HTTP/1.1
Host: index.docker.io
User-Agent: cosign/v3.0.2 (linux; amd64) go-containerregistry/v0.20.6
Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json
Authorization: <redacted>
Accept-Encoding: gzip
2025/10/31 13:57:03 <-- 404 https://index.docker.io/v2/crazymax/github-builder-test/manifests/sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig (66.155995ms)
2025/10/31 13:57:03 HTTP/2.0 404 Not Found
Content-Length: 169
Content-Type: application/json
Date: Fri, 31 Oct 2025 13:57:03 GMT
Docker-Distribution-Api-Version: registry/2.0
Docker-Ratelimit-Source: d2fd3209-1e2e-451f-b428-29c5bbf3b4b7
Strict-Transport-Security: max-age=31536000
{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown","detail":"unknown tag=sha256-6cc021c733ae2760b2493f449d9885b1606002962b51a9c4f0d0d1568b6dc5c0.sig"}]}
Error: no signatures found
error during command execution: no signatures found

View File

@@ -15,11 +15,15 @@
*/ */
import {describe, expect, it, jest, test} from '@jest/globals'; import {describe, expect, it, jest, test} from '@jest/globals';
import fs from 'fs';
import path from 'path';
import * as semver from 'semver'; import * as semver from 'semver';
import {Exec} from '../../src/exec'; import {Exec} from '../../src/exec';
import {Cosign} from '../../src/cosign/cosign'; import {Cosign} from '../../src/cosign/cosign';
const fixturesDir = path.join(__dirname, '..', '.fixtures');
describe('isAvailable', () => { describe('isAvailable', () => {
it('checks Cosign is available', async () => { it('checks Cosign is available', async () => {
const execSpy = jest.spyOn(Exec, 'getExecOutput'); const execSpy = jest.spyOn(Exec, 'getExecOutput');
@@ -61,3 +65,26 @@ describe('versionSatisfies', () => {
expect(await cosign.versionSatisfies(range, version)).toBe(expected); expect(await cosign.versionSatisfies(range, version)).toBe(expected);
}); });
}); });
describe('parseCommandOutput', () => {
// prettier-ignore
test.each([
[path.join(fixturesDir, 'cosign', 'sign-output1.txt')],
[path.join(fixturesDir, 'cosign', 'sign-output2.txt')],
[path.join(fixturesDir, 'cosign', 'sign-output3.txt')],
])('parsing %p', async (fixturePath: string) => {
const signResult = Cosign.parseCommandOutput(fs.readFileSync(fixturePath, 'utf-8'));
expect(signResult).toBeDefined();
expect(signResult.bundle).toBeDefined();
});
// prettier-ignore
test.each([
[path.join(fixturesDir, 'cosign', 'verify-output-err1.txt')],
])('parsing %p', async (fixturePath: string) => {
const signResult = Cosign.parseCommandOutput(fs.readFileSync(fixturePath, 'utf-8'));
expect(signResult).toBeDefined();
expect(signResult.bundle).toBeUndefined();
expect(signResult.errors).toBeDefined();
});
});

View File

@@ -15,14 +15,28 @@
*/ */
import * as core from '@actions/core'; import * as core from '@actions/core';
import {BUNDLE_V03_MEDIA_TYPE, SerializedBundle} from '@sigstore/bundle';
import {Exec} from '../exec'; import {Exec} from '../exec';
import * as semver from 'semver'; import * as semver from 'semver';
import {MEDIATYPE_EMPTY_JSON_V1} from '../types/oci/mediatype';
export interface CosignOpts { export interface CosignOpts {
binPath?: string; binPath?: string;
} }
export interface CosignCommandResult {
bundle?: SerializedBundle;
signatureManifestDigest?: string;
errors?: Array<CosignCommandError>;
}
export interface CosignCommandError {
code: string;
message: string;
detail: string;
}
export class Cosign { export class Cosign {
private readonly binPath: string; private readonly binPath: string;
private _version: string; private _version: string;
@@ -88,4 +102,59 @@ export class Cosign {
core.debug(`Cosign.versionSatisfies ${ver} statisfies ${range}: ${res}`); core.debug(`Cosign.versionSatisfies ${ver} statisfies ${range}: ${res}`);
return res; return res;
} }
public static parseCommandOutput(logs: string): CosignCommandResult {
let signatureManifestDigest: string | undefined;
let signatureManifestFallbackDigest: string | undefined;
let bundlePayload: SerializedBundle | undefined;
let errors: Array<CosignCommandError> | undefined;
for (const rawLine of logs.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.startsWith('{') || !line.endsWith('}')) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let obj: any;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj && Array.isArray(obj.errors) && obj.errors.length > 0) {
errors = obj.errors;
}
// signature manifest digest
if (!signatureManifestDigest && obj && Array.isArray(obj.manifests) && obj.manifests.length > 0) {
const m0 = obj.manifests[0];
if (m0?.artifactType === BUNDLE_V03_MEDIA_TYPE && typeof m0.digest === 'string') {
signatureManifestDigest = m0.digest;
} else if (m0?.artifactType === MEDIATYPE_EMPTY_JSON_V1 && typeof m0.digest === 'string') {
signatureManifestFallbackDigest = m0.digest;
}
}
// signature payload
if (!bundlePayload && obj && obj.mediaType === BUNDLE_V03_MEDIA_TYPE) {
bundlePayload = obj as SerializedBundle;
}
if (bundlePayload && signatureManifestDigest) {
break;
}
}
if (!errors && !bundlePayload) {
throw new Error(`Cannot find signature bundle from cosign command output: ${logs}`);
}
return {
bundle: bundlePayload,
signatureManifestDigest: signatureManifestDigest || signatureManifestFallbackDigest,
errors: errors
};
}
} }

View File

@@ -21,17 +21,38 @@ import path from 'path';
import {Endpoints} from '@actions/attest/lib/endpoints'; import {Endpoints} from '@actions/attest/lib/endpoints';
import * as core from '@actions/core'; import * as core from '@actions/core';
import {signPayload} from '@actions/attest/lib/sign'; import {signPayload} from '@actions/attest/lib/sign';
import {bundleToJSON} from '@sigstore/bundle'; import {bundleFromJSON, bundleToJSON} from '@sigstore/bundle';
import {Attestation} from '@actions/attest'; import {Attestation} from '@actions/attest';
import {Bundle} from '@sigstore/sign'; import {Bundle} from '@sigstore/sign';
import {Cosign} from '../cosign/cosign'; import {Cosign} from '../cosign/cosign';
import {Exec} from '../exec'; import {Exec} from '../exec';
import {GitHub} from '../github'; import {GitHub} from '../github';
import {ImageTools} from '../buildx/imagetools';
import {MEDIATYPE_PAYLOAD as intotoMediatypePayload, Subject} from '../types/intoto/intoto'; import {MEDIATYPE_PAYLOAD as INTOTO_MEDIATYPE_PAYLOAD, Subject} from '../types/intoto/intoto';
import {FULCIO_URL, REKOR_URL, SEARCH_URL, TSASERVER_URL} from '../types/sigstore/sigstore'; import {FULCIO_URL, REKOR_URL, SEARCH_URL, TSASERVER_URL} from '../types/sigstore/sigstore';
export interface SignAttestationManifestsOpts {
imageName: string;
imageDigest: string;
noTransparencyLog?: boolean;
}
export interface SignAttestationManifestsResult extends Attestation {
imageName: string;
}
export interface VerifySignedManifestsOpts {
certificateIdentityRegexp: string;
retries?: number;
}
export interface VerifySignedManifestsResult {
cosignArgs: Array<string>;
signatureManifestDigest: string;
}
export interface SignProvenanceBlobsOpts { export interface SignProvenanceBlobsOpts {
localExportDir: string; localExportDir: string;
name?: string; name?: string;
@@ -54,13 +75,149 @@ export interface VerifySignedArtifactsResult {
export interface SigstoreOpts { export interface SigstoreOpts {
cosign?: Cosign; cosign?: Cosign;
imageTools?: ImageTools;
} }
export class Sigstore { export class Sigstore {
private readonly cosign: Cosign; private readonly cosign: Cosign;
private readonly imageTools: ImageTools;
constructor(opts?: SigstoreOpts) { constructor(opts?: SigstoreOpts) {
this.cosign = opts?.cosign || new Cosign(); this.cosign = opts?.cosign || new Cosign();
this.imageTools = opts?.imageTools || new ImageTools();
}
public async signAttestationManifests(opts: SignAttestationManifestsOpts): Promise<Record<string, SignAttestationManifestsResult>> {
if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to sign attestation manifests');
}
const result: Record<string, SignAttestationManifestsResult> = {};
try {
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
}
const endpoints = this.signingEndpoints(opts.noTransparencyLog);
core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`);
const noTransparencyLog = Sigstore.noTransparencyLog(opts.noTransparencyLog);
const attestationDigests = await this.imageTools.attestationDigests(`${opts.imageName}@${opts.imageDigest}`);
for (const attestationDigest of attestationDigests) {
const attestationRef = `${opts.imageName}@${attestationDigest}`;
await core.group(`Signing attestation manifest ${attestationRef}`, async () => {
// prettier-ignore
const cosignArgs = [
'--verbose',
'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');
}
core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`);
const execRes = await Exec.getExecOutput('cosign', [...cosignArgs, attestationRef], {
ignoreReturnCode: true,
silent: true,
env: Object.assign({}, process.env, {
COSIGN_EXPERIMENTAL: '1'
}) as {
[key: string]: string;
}
});
const signResult = Cosign.parseCommandOutput(execRes.stderr.trim());
if (execRes.exitCode != 0) {
if (signResult.errors && signResult.errors.length > 0) {
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}`);
}
}
const attest = Sigstore.toAttestation(bundleFromJSON(signResult.bundle));
if (attest.tlogID) {
core.info(`Uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${attest.tlogID}`);
}
core.info(`Signature manifest pushed: https://oci.dag.dev/?referrers=${attestationRef}`);
result[attestationRef] = {
...attest,
imageName: opts.imageName
};
});
}
} catch (err) {
throw new Error(`Signing BuildKit attestation manifests failed: ${(err as Error).message}`);
}
return result;
}
public async verifySignedManifests(opts: VerifySignedManifestsOpts, signed: Record<string, SignAttestationManifestsResult>): Promise<Record<string, VerifySignedManifestsResult>> {
const result: Record<string, VerifySignedManifestsResult> = {};
const retries = opts.retries ?? 15;
if (!(await this.cosign.isAvailable())) {
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
const cosignArgs = [
'--verbose',
'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', [...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;
} 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}`);
}
}
}
});
}
if (lastError) {
throw lastError;
}
return result;
} }
public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> { public async signProvenanceBlobs(opts: SignProvenanceBlobsOpts): Promise<Record<string, SignProvenanceBlobsResult>> {
@@ -70,7 +227,7 @@ export class Sigstore {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'); throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
} }
const endpoints = this.signingEndpoints(opts); const endpoints = this.signingEndpoints(opts.noTransparencyLog);
core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`); core.info(`Using Sigstore signing endpoint: ${endpoints.fulcioURL}`);
const provenanceBlobs = Sigstore.getProvenanceBlobs(opts); const provenanceBlobs = Sigstore.getProvenanceBlobs(opts);
@@ -86,7 +243,7 @@ export class Sigstore {
const bundle = await signPayload( const bundle = await signPayload(
{ {
body: blob, body: blob,
type: intotoMediatypePayload type: INTOTO_MEDIATYPE_PAYLOAD
}, },
endpoints endpoints
); );
@@ -123,7 +280,7 @@ export class Sigstore {
} }
for (const [provenancePath, signedRes] of Object.entries(signed)) { for (const [provenancePath, signedRes] of Object.entries(signed)) {
const baseDir = path.dirname(provenancePath); const baseDir = path.dirname(provenancePath);
await core.group(`Verifying ${signedRes.bundlePath}`, async () => { await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => {
for (const subject of signedRes.subjects) { for (const subject of signedRes.subjects) {
const artifactPath = path.join(baseDir, subject.name); const artifactPath = path.join(baseDir, subject.name);
core.info(`Verifying signed artifact ${artifactPath}`); core.info(`Verifying signed artifact ${artifactPath}`);
@@ -134,7 +291,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.bundle.verificationMaterial || !Array.isArray(signedRes.bundle.verificationMaterial.tlogEntries) || signedRes.bundle.verificationMaterial.tlogEntries.length === 0) { if (!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');
} }
@@ -154,8 +311,8 @@ export class Sigstore {
return result; return result;
} }
private signingEndpoints(opts: SignProvenanceBlobsOpts): Endpoints { private signingEndpoints(noTransparencyLog?: boolean): Endpoints {
const noTransparencyLog = opts.noTransparencyLog ?? GitHub.context.payload.repository?.private; noTransparencyLog = Sigstore.noTransparencyLog(noTransparencyLog);
core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`); core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`);
return { return {
fulcioURL: FULCIO_URL, fulcioURL: FULCIO_URL,
@@ -164,6 +321,10 @@ export class Sigstore {
}; };
} }
private static noTransparencyLog(noTransparencyLog?: boolean): boolean {
return noTransparencyLog ?? GitHub.context.payload.repository?.private;
}
private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> { private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> {
// For single platform build // For single platform build
const singleProvenance = path.join(opts.localExportDir, 'provenance.json'); const singleProvenance = path.join(opts.localExportDir, 'provenance.json');