@docker/actions-toolkit
Version:
Toolkit for Docker (GitHub) Actions
459 lines • 23.1 kB
JavaScript
/**
* 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 { CIContextProvider, DSSEBundleBuilder, FulcioSigner, RekorWitness, TSAWitness } from '@sigstore/sign';
import * as tuf from '@sigstore/tuf';
import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify';
import { Context } from '../context.js';
import { Cosign } from '../cosign/cosign.js';
import { Exec } from '../exec.js';
import { GitHub } from '../github/github.js';
import { ImageTools } from '../buildx/imagetools.js';
import { MEDIATYPE_PAYLOAD as INTOTO_MEDIATYPE_PAYLOAD } from '../types/intoto/intoto.js';
import { FULCIO_URL, REKOR_URL, SEARCH_URL, TSASERVER_URL } from '../types/sigstore/sigstore.js';
export class Sigstore {
cosign;
imageTools;
constructor(opts) {
this.cosign = opts?.cosign || new Cosign();
this.imageTools = opts?.imageTools || new ImageTools();
}
async signAttestationManifests(opts) {
if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to sign attestation manifests');
}
const result = {};
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 cosignExtraArgs = [];
if (await this.cosign.versionSatisfies('>=3.0.4')) {
await core.group(`Creating Sigstore protobuf signing config`, async () => {
const signingConfig = Context.tmpName({
template: 'signing-config-XXXXXX.json',
tmpdir: Context.tmpDir()
});
// prettier-ignore
const createConfigArgs = [
'signing-config',
'create',
'--with-default-services=true',
`--out=${signingConfig}`
];
if (noTransparencyLog) {
createConfigArgs.push('--no-default-rekor=true');
}
await Exec.exec('cosign', createConfigArgs, {
env: Object.assign({}, process.env, {
COSIGN_EXPERIMENTAL: '1'
})
});
core.info(JSON.stringify(JSON.parse(fs.readFileSync(signingConfig, { encoding: 'utf-8' })), null, 2));
cosignExtraArgs.push(`--signing-config=${signingConfig}`);
});
}
else {
cosignExtraArgs.push('--use-signing-config');
if (noTransparencyLog) {
cosignExtraArgs.push('--tlog-upload=false');
}
}
for (const imageName of opts.imageNames) {
const attestationDigests = await this.imageTools.attestationDigests({
name: `${imageName}@${opts.imageDigest}`,
retryOnManifestUnknown: opts.retryOnManifestUnknown,
retryLimit: opts.retryLimit
});
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',
...cosignExtraArgs
];
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'
})
});
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 {
// prettier-ignore
throw new Error(`Cosign sign command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
}
}
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.message}`);
}
return result;
}
async verifySignedManifests(signedManifestsResult, opts) {
const result = {};
for (const [attestationRef, signedRes] of Object.entries(signedManifestsResult)) {
await core.group(`Verifying signature of ${attestationRef}`, async () => {
const verifyResult = await this.verifyImageAttestation(attestationRef, {
certificateIdentityRegexp: opts.certificateIdentityRegexp,
noTransparencyLog: opts.noTransparencyLog || !signedRes.tlogID,
retryOnManifestUnknown: opts.retryOnManifestUnknown
});
core.info(`Signature manifest verified: https://oci.dag.dev/?image=${signedRes.imageName}@${verifyResult.signatureManifestDigest}`);
result[attestationRef] = verifyResult;
});
}
return result;
}
async verifyImageAttestations(image, opts) {
const result = {};
const attestationDigests = await this.imageTools.attestationDigests({
name: image,
platform: opts.platform,
retryOnManifestUnknown: opts.retryOnManifestUnknown,
retryLimit: opts.retryLimit
});
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;
}
async verifyImageAttestation(attestationRef, opts) {
if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to verify signed manifests');
}
// prettier-ignore
const cosignArgs = [
'verify',
'--experimental-oci11',
'--new-bundle-format',
'--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com',
'--certificate-identity-regexp', opts.certificateIdentityRegexp
];
if (opts.noTransparencyLog) {
// skip tlog verification but still verify the signed timestamp
cosignArgs.push('--use-signed-timestamps', '--insecure-ignore-tlog');
}
if (!opts.retryOnManifestUnknown) {
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'
})
});
if (execRes.exitCode !== 0) {
// prettier-ignore
throw new Error(`Cosign verify command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
}
const verifyResult = Cosign.parseCommandOutput(execRes.stderr.trim());
return {
cosignArgs: cosignArgs,
signatureManifestDigest: verifyResult.signatureManifestDigest
};
}
const retries = opts.retryLimit ?? 15;
let lastError;
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'
})
});
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 {
// prettier-ignore
throw new Error(`Cosign verify command failed with: ${execRes.stderr.trim().split(/\r?\n/).filter(line => line.length > 0).pop() ?? 'unknown error'}`);
}
}
}
throw lastError;
}
async signProvenanceBlobs(opts) {
const result = {};
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.message}`);
}
return result;
}
async verifySignedArtifacts(signedArtifactsResult, opts) {
const result = {};
if (!(await this.cosign.isAvailable())) {
throw new Error('Cosign is required to verify signed artifacts');
}
for (const [provenancePath, signedRes] of Object.entries(signedArtifactsResult)) {
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 (opts.noTransparencyLog || !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;
}
async verifyArtifact(artifactPath, bundlePath, opts) {
core.info(`Verifying keyless verification bundle signature`);
const parsedBundle = JSON.parse(fs.readFileSync(bundlePath, 'utf-8'));
const bundle = bundleFromJSON(parsedBundle);
core.info(`Fetching Sigstore TUF trusted root metadata`);
const trustedRoot = await tuf.getTrustedRoot();
const trustMaterial = toTrustMaterial(trustedRoot);
try {
core.info(`Verifying artifact signature`);
const signedEntity = toSignedEntity(bundle, fs.readFileSync(artifactPath));
const signingCert = Sigstore.parseCertificate(bundle);
// collect transparency log ID if available
const tlogEntries = bundle.verificationMaterial.tlogEntries;
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined;
// TODO: remove when subjectAlternativeName check with regex is supported: https://github.com/sigstore/sigstore-js/pull/1556
if (opts?.subjectAlternativeName && opts?.subjectAlternativeName instanceof RegExp) {
const subjectAltName = signingCert.subjectAltName?.replace(/^uri:/i, '');
if (!subjectAltName) {
throw new Error('Signing certificate does not contain subjectAltName');
}
else if (!subjectAltName.match(opts.subjectAlternativeName)) {
throw new Error(`Signing certificate subjectAlternativeName "${subjectAltName}" does not match expected pattern`);
}
}
const verifier = new Verifier(trustMaterial);
const signer = verifier.verify(signedEntity, {
subjectAlternativeName: opts?.subjectAlternativeName && typeof opts.subjectAlternativeName === 'string' ? opts.subjectAlternativeName : undefined,
extensions: opts?.issuer ? { issuer: opts.issuer } : undefined
});
core.debug(`Sigstore.verifyArtifact signer: ${JSON.stringify(signer)}`);
return {
payload: parsedBundle,
certificate: signingCert.toString(),
tlogID: tlogID
};
}
catch (err) {
throw new Error(`Failed to verify artifact signature: ${err}`);
}
}
signingEndpoints(noTransparencyLog) {
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
};
}
static noTransparencyLog(noTransparencyLog) {
return noTransparencyLog ?? GitHub.context.payload.repository?.private;
}
static getProvenanceBlobs(opts) {
// 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 = {};
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}`);
}
static getProvenanceSubjects(body) {
const statement = JSON.parse(body.toString());
return statement.subject.map(s => ({
name: s.name,
digest: s.digest
}));
}
static async signPayload(artifact, endpoints, timeout, retries) {
const witnesses = [];
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);
}
static parseBundle(bundle) {
const signingCert = Sigstore.parseCertificate(bundle);
// 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
};
}
static parseCertificate(bundle) {
let certBytes;
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');
}
return new X509Certificate(certBytes);
}
}
//# sourceMappingURL=sigstore.js.map