Skip to content
Open
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
86 changes: 54 additions & 32 deletions __tests__/sigstore/sigstore.test.itg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {beforeAll, describe, expect, it, test} from 'vitest';
import fs from 'fs';
import os from 'os';
import * as path from 'path';
import * as semver from 'semver';

import {Buildx} from '../../src/buildx/buildx.js';
import {Build} from '../../src/buildx/build.js';
Expand All @@ -37,7 +38,14 @@ const maybeIdToken = runTest && process.env.ACTIONS_ID_TOKEN_REQUEST_URL ? descr

const imageName = 'ghcr.io/docker/actions-toolkit-test';
const currentCosignVersion = 'v3.1.1';
const signAttestationCosignVersions = ['v3.0.2', 'v3.0.6', currentCosignVersion] as const;
const signingCosignVersions = ['v3.0.2', 'v3.0.6', currentCosignVersion] as const;
const signingCases = signingCosignVersions.flatMap(cosignVersion => {
const cases = [{cosignVersion, rekorV2: false}];
if (semver.satisfies(cosignVersion, '>=3.1.1')) {
cases.push({cosignVersion, rekorV2: true});
}
return cases;
});
const installedCosign = new Map<string, Promise<string>>();

async function installCosign(version: string): Promise<string> {
Expand All @@ -56,8 +64,8 @@ async function installCosign(version: string): Promise<string> {
return await installedPath;
}

for (const cosignVersion of signAttestationCosignVersions) {
maybeIdToken(`signAttestationManifests with cosign ${cosignVersion}`, () => {
for (const {cosignVersion, rekorV2} of signingCases) {
maybeIdToken(`signAttestationManifests with cosign ${cosignVersion}${rekorV2 ? ' and Rekor v2' : ''}`, () => {
let sigstore: Sigstore;

beforeAll(async () => {
Expand All @@ -71,7 +79,7 @@ for (const cosignVersion of signAttestationCosignVersions) {
it('build, sign and verify', async () => {
const buildx = new Buildx();
const build = new Build({buildx: buildx});
const versionTag = cosignVersion.replace(/^v/, '').replace(/\./g, '-');
const versionTag = `${cosignVersion.replace(/^v/, '').replace(/\./g, '-')}${rekorV2 ? '-rekor-v2' : ''}`;

await expect(
(async () => {
Expand Down Expand Up @@ -107,6 +115,7 @@ for (const cosignVersion of signAttestationCosignVersions) {
const signResults = await sigstore.signAttestationManifests({
imageNames: [imageName],
imageDigest: buildDigest!,
rekorV2,
retryOnManifestUnknown: true
});
expect(Object.keys(signResults).length).toEqual(2);
Expand Down Expand Up @@ -164,37 +173,50 @@ maybe('verifyImageAttestations', () => {
});
});

maybeIdToken('signProvenanceBlobs', () => {
it('single platform', async () => {
const sigstore = new Sigstore();
const results = await sigstore.signProvenanceBlobs({
localExportDir: path.join(fixturesDir, 'sigstore', 'single')
for (const {cosignVersion, rekorV2} of signingCases) {
maybeIdToken(`signProvenanceBlobs with cosign ${cosignVersion}${rekorV2 ? ' and Rekor v2' : ''}`, () => {
let sigstore: Sigstore;

beforeAll(async () => {
sigstore = new Sigstore({
cosign: new Cosign({
binPath: await installCosign(cosignVersion)
})
});
}, 100000);

it('single platform', async () => {
const results = await sigstore.signProvenanceBlobs({
localExportDir: path.join(fixturesDir, 'sigstore', 'single'),
rekorV2
});
expect(Object.keys(results).length).toEqual(1);
const provenancePath = Object.keys(results)[0];
expect(provenancePath).toEqual(path.join(fixturesDir, 'sigstore', 'single', 'provenance.json'));
expect(fs.existsSync(results[provenancePath].bundlePath)).toBe(true);
expect(results[provenancePath].payload).toBeDefined();
expect(results[provenancePath].certificate).toBeDefined();
expect(results[provenancePath].tlogID).toBeDefined();
console.log(provenancePath, JSON.stringify(results[provenancePath].payload, null, 2));
});
expect(Object.keys(results).length).toEqual(1);
const provenancePath = Object.keys(results)[0];
expect(provenancePath).toEqual(path.join(fixturesDir, 'sigstore', 'single', 'provenance.json'));
expect(fs.existsSync(results[provenancePath].bundlePath)).toBe(true);
expect(results[provenancePath].payload).toBeDefined();
expect(results[provenancePath].certificate).toBeDefined();
expect(results[provenancePath].tlogID).toBeDefined();
console.log(provenancePath, JSON.stringify(results[provenancePath].payload, null, 2));
});
it('multi-platform', async () => {
const sigstore = new Sigstore();
const results = await sigstore.signProvenanceBlobs({
localExportDir: path.join(fixturesDir, 'sigstore', 'multi')

it('multi-platform', async () => {
const results = await sigstore.signProvenanceBlobs({
localExportDir: path.join(fixturesDir, 'sigstore', 'multi'),
rekorV2
});
expect(Object.keys(results).length).toEqual(2);
for (const [provenancePath, res] of Object.entries(results)) {
expect(provenancePath).toMatch(/linux_(amd64|arm64)\/provenance.json/);
expect(fs.existsSync(res.bundlePath)).toBe(true);
expect(res.payload).toBeDefined();
expect(res.certificate).toBeDefined();
expect(res.tlogID).toBeDefined();
console.log(provenancePath, JSON.stringify(res.payload, null, 2));
}
});
expect(Object.keys(results).length).toEqual(2);
for (const [provenancePath, res] of Object.entries(results)) {
expect(provenancePath).toMatch(/linux_(amd64|arm64)\/provenance.json/);
expect(fs.existsSync(res.bundlePath)).toBe(true);
expect(res.payload).toBeDefined();
expect(res.certificate).toBeDefined();
expect(res.tlogID).toBeDefined();
console.log(provenancePath, JSON.stringify(res.payload, null, 2));
}
});
});
}

maybeIdToken('verifySignedArtifacts', () => {
let sigstore: Sigstore;
Expand Down
56 changes: 45 additions & 11 deletions src/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export interface SigstoreOpts {
imageTools?: ImageTools;
}

interface CosignSigningConfigOpts {
noTransparencyLog?: boolean;
rekorV2?: boolean;
}

const COSIGN_PREDICATE_SLSA_PROVENANCE_V1 = 'slsaprovenance1';

export class Sigstore {
Expand All @@ -71,7 +76,11 @@ export class Sigstore {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
}

const cosignExtraArgs = await this.cosignSigningConfigArgs(opts.noTransparencyLog);
const cosignSigningConfigOpts = {
noTransparencyLog: opts.noTransparencyLog,
rekorV2: opts.rekorV2
};
const cosignExtraArgs = await this.cosignSigningConfigArgs(cosignSigningConfigOpts);

for (const imageName of opts.imageNames) {
const attestationDigests = await this.imageTools.attestationDigests({
Expand All @@ -88,7 +97,7 @@ export class Sigstore {
'--yes',
'--oidc-provider', 'github-actions',
'--registry-referrers-mode', 'oci-1-1',
'--new-bundle-format',
...(await this.bundleFormatArgs()),
...cosignExtraArgs
];
core.info(`[command]${this.cosign.binPath} ${[...cosignArgs, attestationRef].join(' ')}`);
Expand All @@ -113,7 +122,7 @@ export class Sigstore {
}
const parsedBundle = Sigstore.parseBundle(bundleFromJSON(signResult.bundle));
if (parsedBundle.tlogID) {
core.info(`Uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${parsedBundle.tlogID}`);
await this.logTransparencyUpload(parsedBundle.tlogID, cosignSigningConfigOpts);
}
core.info(`Signature manifest pushed: https://oci.dag.dev/?referrers=${attestationRef}`);
result[attestationRef] = {
Expand Down Expand Up @@ -179,7 +188,7 @@ export class Sigstore {
const cosignArgs = [
'verify',
'--experimental-oci11',
'--new-bundle-format',
...(await this.bundleFormatArgs()),
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
'--certificate-identity-regexp', opts.certificateIdentityRegexp
];
Expand Down Expand Up @@ -255,7 +264,11 @@ export class Sigstore {
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
}

const cosignExtraArgs = await this.cosignSigningConfigArgs(opts.noTransparencyLog);
const cosignSigningConfigOpts = {
noTransparencyLog: opts.noTransparencyLog,
rekorV2: opts.rekorV2
};
const cosignExtraArgs = await this.cosignSigningConfigArgs(cosignSigningConfigOpts);

const provenanceBlobs = Sigstore.getProvenanceBlobs(opts);
for (const p of Object.keys(provenanceBlobs)) {
Expand All @@ -273,7 +286,7 @@ export class Sigstore {
'attest-blob',
'--yes',
'--oidc-provider', 'github-actions',
'--new-bundle-format',
...(await this.bundleFormatArgs()),
'--statement', p,
'--type', COSIGN_PREDICATE_SLSA_PROVENANCE_V1,
'--bundle', bundlePath,
Expand Down Expand Up @@ -306,7 +319,7 @@ export class Sigstore {
core.info(` - ${subject.name} (${digestAlg}:${digestValue})`);
}
if (parsedBundle.tlogID) {
core.info(`Attestation signature uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${parsedBundle.tlogID}`);
await this.logTransparencyUpload(parsedBundle.tlogID, cosignSigningConfigOpts);
}
core.info(`Sigstore bundle written to: ${bundlePath}`);
result[p] = {
Expand Down Expand Up @@ -336,7 +349,7 @@ export class Sigstore {
// prettier-ignore
const cosignArgs = [
'verify-blob-attestation',
'--new-bundle-format',
...(await this.bundleFormatArgs()),
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
'--certificate-identity-regexp', opts.certificateIdentityRegexp,
'--type', opts.predicateType ?? COSIGN_PREDICATE_SLSA_PROVENANCE_V1
Expand Down Expand Up @@ -410,13 +423,14 @@ export class Sigstore {
return noTransparencyLog ?? GitHub.context.payload.repository?.private ?? false;
}

private async cosignSigningConfigArgs(noTransparencyLog?: boolean): Promise<string[]> {
private async cosignSigningConfigArgs(opts: CosignSigningConfigOpts): Promise<string[]> {
const cosignExtraArgs: string[] = [];

const disableTransparencyLog = Sigstore.noTransparencyLog(noTransparencyLog);
const disableTransparencyLog = Sigstore.noTransparencyLog(opts.noTransparencyLog);
core.info(`Upload to transparency log: ${disableTransparencyLog ? 'disabled' : 'enabled'}`);

if (await this.cosign.versionSatisfies('>=3.0.4')) {
const useRekorV2 = await this.useRekorV2(opts);
await core.group(`Creating Sigstore protobuf signing config`, async () => {
const signingConfig = Context.tmpName({
template: 'signing-config-XXXXXX.json',
Expand All @@ -426,7 +440,7 @@ export class Sigstore {
const createConfigArgs = [
'signing-config',
'create',
'--with-default-services=true',
useRekorV2 ? '--with-default-rekor-v2=true' : '--with-default-services=true',
`--out=${signingConfig}`
];
if (disableTransparencyLog) {
Expand All @@ -451,6 +465,18 @@ export class Sigstore {
return cosignExtraArgs;
}

private async logTransparencyUpload(tlogID: string, opts: CosignSigningConfigOpts): Promise<void> {
if (await this.useRekorV2(opts)) {
core.info(`Attestation signature uploaded to Rekor v2 transparency log with index ${tlogID}`);
return;
}
core.info(`Attestation signature uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${tlogID}`);
}

private async useRekorV2(opts: CosignSigningConfigOpts): Promise<boolean> {
return opts.rekorV2 === true && !Sigstore.noTransparencyLog(opts.noTransparencyLog) && (await this.cosign.versionSatisfies('>=3.1.1'));
}

private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> {
// For single platform build
const singleProvenance = path.join(opts.localExportDir, 'provenance.json');
Expand Down Expand Up @@ -511,4 +537,12 @@ export class Sigstore {
}
return new X509Certificate(certBytes);
}

private async bundleFormatArgs(): Promise<string[]> {
// Cosign 3.1.1 makes the new bundle format the default and deprecates this flag.
if (await this.cosign.versionSatisfies('>=3.1.1')) {
return [];
}
return ['--new-bundle-format'];
}
}
2 changes: 2 additions & 0 deletions src/types/sigstore/sigstore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface SignAttestationManifestsOpts {
imageNames: Array<string>;
imageDigest: string;
noTransparencyLog?: boolean;
rekorV2?: boolean;
retryOnManifestUnknown?: boolean;
retryLimit?: number;
}
Expand All @@ -56,6 +57,7 @@ export interface SignProvenanceBlobsOpts {
localExportDir: string;
name?: string;
noTransparencyLog?: boolean;
rekorV2?: boolean;
}

export interface SignProvenanceBlobsResult extends ParsedBundle {
Expand Down
Loading