392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
/**
|
|
* Copyright 2025 actions-toolkit authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import {X509Certificate} from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import * as core from '@actions/core';
|
|
import {bundleFromJSON, bundleToJSON} from '@sigstore/bundle';
|
|
import {Artifact, Bundle, CIContextProvider, DSSEBundleBuilder, FulcioSigner, RekorWitness, TSAWitness, Witness} from '@sigstore/sign';
|
|
|
|
import {Cosign} from '../cosign/cosign';
|
|
import {Exec} from '../exec';
|
|
import {GitHub} from '../github';
|
|
import {ImageTools} from '../buildx/imagetools';
|
|
|
|
import {MEDIATYPE_PAYLOAD as INTOTO_MEDIATYPE_PAYLOAD, Subject} from '../types/intoto/intoto';
|
|
import {
|
|
Endpoints,
|
|
FULCIO_URL,
|
|
ParsedBundle,
|
|
REKOR_URL,
|
|
SEARCH_URL,
|
|
SignAttestationManifestsOpts,
|
|
SignAttestationManifestsResult,
|
|
SignProvenanceBlobsOpts,
|
|
SignProvenanceBlobsResult,
|
|
TSASERVER_URL,
|
|
VerifySignedArtifactsOpts,
|
|
VerifySignedArtifactsResult,
|
|
VerifySignedManifestsOpts,
|
|
VerifySignedManifestsResult
|
|
} from '../types/sigstore/sigstore';
|
|
|
|
export interface SigstoreOpts {
|
|
cosign?: Cosign;
|
|
imageTools?: ImageTools;
|
|
}
|
|
|
|
export class Sigstore {
|
|
private readonly cosign: Cosign;
|
|
private readonly imageTools: ImageTools;
|
|
|
|
constructor(opts?: SigstoreOpts) {
|
|
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);
|
|
|
|
for (const imageName of opts.imageNames) {
|
|
const attestationDigests = await this.imageTools.attestationDigests(`${imageName}@${opts.imageDigest}`);
|
|
for (const attestationDigest of attestationDigests) {
|
|
const attestationRef = `${imageName}@${attestationDigest}`;
|
|
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'
|
|
];
|
|
if (noTransparencyLog) {
|
|
cosignArgs.push('--tlog-upload=false');
|
|
}
|
|
core.info(`[command]cosign ${[...cosignArgs, attestationRef].join(' ')}`);
|
|
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 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 parsedBundle = Sigstore.parseBundle(bundleFromJSON(signResult.bundle));
|
|
if (parsedBundle.tlogID) {
|
|
core.info(`Uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${parsedBundle.tlogID}`);
|
|
}
|
|
core.info(`Signature manifest pushed: https://oci.dag.dev/?referrers=${attestationRef}`);
|
|
result[attestationRef] = {
|
|
...parsedBundle,
|
|
imageName: 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 = [
|
|
'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;
|
|
} 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>> {
|
|
const result: Record<string, SignProvenanceBlobsResult> = {};
|
|
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 provenanceBlobs = Sigstore.getProvenanceBlobs(opts);
|
|
for (const p of Object.keys(provenanceBlobs)) {
|
|
await core.group(`Signing ${p}`, async () => {
|
|
const blob = provenanceBlobs[p];
|
|
const bundlePath = path.join(path.dirname(p), `${opts.name ?? 'provenance'}.sigstore.json`);
|
|
const subjects = Sigstore.getProvenanceSubjects(blob);
|
|
if (subjects.length === 0) {
|
|
core.warning(`No subjects found in provenance ${p}, skip signing.`);
|
|
return;
|
|
}
|
|
const bundle = await Sigstore.signPayload(
|
|
{
|
|
data: blob,
|
|
type: INTOTO_MEDIATYPE_PAYLOAD
|
|
},
|
|
endpoints
|
|
);
|
|
const parsedBundle = Sigstore.parseBundle(bundle);
|
|
core.info(`Provenance blob signed for:`);
|
|
for (const subject of subjects) {
|
|
const [digestAlg, digestValue] = Object.entries(subject.digest)[0] || [];
|
|
core.info(` - ${subject.name} (${digestAlg}:${digestValue})`);
|
|
}
|
|
if (parsedBundle.tlogID) {
|
|
core.info(`Attestation signature uploaded to Rekor transparency log: ${SEARCH_URL}?logIndex=${parsedBundle.tlogID}`);
|
|
}
|
|
core.info(`Writing Sigstore bundle to: ${bundlePath}`);
|
|
fs.writeFileSync(bundlePath, JSON.stringify(parsedBundle.payload, null, 2), {
|
|
encoding: 'utf-8'
|
|
});
|
|
result[p] = {
|
|
...parsedBundle,
|
|
bundlePath: bundlePath,
|
|
subjects: subjects
|
|
};
|
|
});
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`Signing BuildKit provenance blobs failed: ${(err as Error).message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public async verifySignedArtifacts(opts: VerifySignedArtifactsOpts, signed: Record<string, SignProvenanceBlobsResult>): 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)) {
|
|
const baseDir = path.dirname(provenancePath);
|
|
await core.group(`Verifying signature bundle ${signedRes.bundlePath}`, async () => {
|
|
for (const subject of signedRes.subjects) {
|
|
const artifactPath = path.join(baseDir, subject.name);
|
|
core.info(`Verifying signed artifact ${artifactPath}`);
|
|
// prettier-ignore
|
|
const cosignArgs = [
|
|
'verify-blob-attestation',
|
|
'--new-bundle-format',
|
|
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
|
|
'--certificate-identity-regexp', opts.certificateIdentityRegexp
|
|
]
|
|
if (!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');
|
|
}
|
|
const execRes = await Exec.getExecOutput('cosign', [...cosignArgs, '--bundle', signedRes.bundlePath, artifactPath], {
|
|
ignoreReturnCode: true
|
|
});
|
|
if (execRes.stderr.length > 0 && execRes.exitCode != 0) {
|
|
throw new Error(execRes.stderr);
|
|
}
|
|
result[artifactPath] = {
|
|
bundlePath: signedRes.bundlePath,
|
|
cosignArgs: cosignArgs
|
|
};
|
|
}
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private signingEndpoints(noTransparencyLog?: boolean): Endpoints {
|
|
noTransparencyLog = Sigstore.noTransparencyLog(noTransparencyLog);
|
|
core.info(`Upload to transparency log: ${noTransparencyLog ? 'disabled' : 'enabled'}`);
|
|
return {
|
|
fulcioURL: FULCIO_URL,
|
|
rekorURL: noTransparencyLog ? undefined : REKOR_URL,
|
|
tsaServerURL: TSASERVER_URL
|
|
};
|
|
}
|
|
|
|
private static noTransparencyLog(noTransparencyLog?: boolean): boolean {
|
|
return noTransparencyLog ?? GitHub.context.payload.repository?.private;
|
|
}
|
|
|
|
private static getProvenanceBlobs(opts: SignProvenanceBlobsOpts): Record<string, Buffer> {
|
|
// For single platform build
|
|
const singleProvenance = path.join(opts.localExportDir, 'provenance.json');
|
|
if (fs.existsSync(singleProvenance)) {
|
|
return {[singleProvenance]: fs.readFileSync(singleProvenance)};
|
|
}
|
|
|
|
// For multi-platform build
|
|
const dirents = fs.readdirSync(opts.localExportDir, {withFileTypes: true});
|
|
const platformFolders = dirents.filter(dirent => dirent.isDirectory());
|
|
if (platformFolders.length > 0 && platformFolders.length === dirents.length && platformFolders.every(platformFolder => fs.existsSync(path.join(opts.localExportDir, platformFolder.name, 'provenance.json')))) {
|
|
const result: Record<string, Buffer> = {};
|
|
for (const platformFolder of platformFolders) {
|
|
const p = path.join(opts.localExportDir, platformFolder.name, 'provenance.json');
|
|
result[p] = fs.readFileSync(p);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
throw new Error(`No valid provenance.json found in ${opts.localExportDir}`);
|
|
}
|
|
|
|
private static getProvenanceSubjects(body: Buffer): Array<Subject> {
|
|
const statement = JSON.parse(body.toString()) as {
|
|
subject: Array<{name: string; digest: Record<string, string>}>;
|
|
};
|
|
return statement.subject.map(s => ({
|
|
name: s.name,
|
|
digest: s.digest
|
|
}));
|
|
}
|
|
|
|
private static async signPayload(artifact: Artifact, endpoints: Endpoints, timeout?: number, retries?: number): Promise<Bundle> {
|
|
const witnesses: Witness[] = [];
|
|
|
|
const signer = new FulcioSigner({
|
|
identityProvider: new CIContextProvider('sigstore'),
|
|
fulcioBaseURL: endpoints.fulcioURL,
|
|
timeout: timeout,
|
|
retry: retries
|
|
});
|
|
|
|
if (endpoints.rekorURL) {
|
|
witnesses.push(
|
|
new RekorWitness({
|
|
rekorBaseURL: endpoints.rekorURL,
|
|
fetchOnConflict: true,
|
|
timeout: timeout,
|
|
retry: retries
|
|
})
|
|
);
|
|
}
|
|
|
|
if (endpoints.tsaServerURL) {
|
|
witnesses.push(
|
|
new TSAWitness({
|
|
tsaBaseURL: endpoints.tsaServerURL,
|
|
timeout: timeout,
|
|
retry: retries
|
|
})
|
|
);
|
|
}
|
|
|
|
return new DSSEBundleBuilder({signer, witnesses}).create(artifact);
|
|
}
|
|
|
|
private static parseBundle(bundle: Bundle): ParsedBundle {
|
|
let certBytes: Buffer;
|
|
switch (bundle.verificationMaterial.content.$case) {
|
|
case 'x509CertificateChain':
|
|
certBytes = bundle.verificationMaterial.content.x509CertificateChain.certificates[0].rawBytes;
|
|
break;
|
|
case 'certificate':
|
|
certBytes = bundle.verificationMaterial.content.certificate.rawBytes;
|
|
break;
|
|
default:
|
|
throw new Error('Bundle must contain an x509 certificate');
|
|
}
|
|
|
|
const signingCert = new X509Certificate(certBytes);
|
|
|
|
// collect transparency log ID if available
|
|
const tlogEntries = bundle.verificationMaterial.tlogEntries;
|
|
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined;
|
|
|
|
return {
|
|
payload: bundleToJSON(bundle),
|
|
certificate: signingCert.toString(),
|
|
tlogID: tlogID
|
|
};
|
|
}
|
|
}
|