Merge pull request #830 from crazy-max/signing-manifest
sigstore: sign and verify BuildKit attestation manifests
This commit is contained in:
300
__tests__/.fixtures/cosign/sign-output1.txt
Normal file
300
__tests__/.fixtures/cosign/sign-output1.txt
Normal file
File diff suppressed because one or more lines are too long
408
__tests__/.fixtures/cosign/sign-output2.txt
Normal file
408
__tests__/.fixtures/cosign/sign-output2.txt
Normal file
File diff suppressed because one or more lines are too long
329
__tests__/.fixtures/cosign/sign-output3.txt
Normal file
329
__tests__/.fixtures/cosign/sign-output3.txt
Normal file
File diff suppressed because one or more lines are too long
96
__tests__/.fixtures/cosign/verify-output-err1.txt
Normal file
96
__tests__/.fixtures/cosign/verify-output-err1.txt
Normal 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
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user