sigstore: add function to verify image attestations
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ export class Sigstore {
|
|||||||
await core.group(`Signing attestation manifest ${attestationRef}`, async () => {
|
await core.group(`Signing attestation manifest ${attestationRef}`, async () => {
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const cosignArgs = [
|
const cosignArgs = [
|
||||||
'sign',
|
'sign',
|
||||||
'--yes',
|
'--yes',
|
||||||
'--oidc-provider', 'github-actions',
|
'--oidc-provider', 'github-actions',
|
||||||
'--registry-referrers-mode', 'oci-1-1',
|
'--registry-referrers-mode', 'oci-1-1',
|
||||||
'--new-bundle-format',
|
'--new-bundle-format',
|
||||||
'--use-signing-config'
|
'--use-signing-config'
|
||||||
];
|
];
|
||||||
if (noTransparencyLog) {
|
if (noTransparencyLog) {
|
||||||
cosignArgs.push('--tlog-upload=false');
|
cosignArgs.push('--tlog-upload=false');
|
||||||
}
|
}
|
||||||
@@ -127,69 +127,94 @@ 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;
|
// prettier-ignore
|
||||||
for (const [attestationRef, signedRes] of Object.entries(signed)) {
|
const cosignArgs = [
|
||||||
await core.group(`Verifying signature of ${attestationRef}`, async () => {
|
'verify',
|
||||||
// prettier-ignore
|
'--experimental-oci11',
|
||||||
const cosignArgs = [
|
'--new-bundle-format',
|
||||||
'verify',
|
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
|
||||||
'--experimental-oci11',
|
'--certificate-identity-regexp', opts.certificateIdentityRegexp
|
||||||
'--new-bundle-format',
|
];
|
||||||
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
|
if (opts.noTransparencyLog) {
|
||||||
'--certificate-identity-regexp', opts.certificateIdentityRegexp
|
// skip tlog verification but still verify the signed timestamp
|
||||||
];
|
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
|
||||||
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;
|
|
||||||
} 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;
|
let lastError: Error | undefined;
|
||||||
|
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 {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Cosign verify command failed: ${execRes.stderr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user