Files
suaveplan/scripts/generate-provenance.ts
2026-05-27 23:47:53 +01:00

197 lines
6.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* SLSA Provenance generation script for CI/CD
*
* Generates an in-toto attestation with SLSA v1.0 provenance predicate
* for a published package. Intended to be called from the publish pipeline
* after a successful npm publish.
*
* Reads build metadata from GitLab CI environment variables and writes
* the provenance statement to a `.provenance.json` file alongside the artifact.
*
* Usage:
* bun scripts/generate-provenance.ts --package @suaveplan/utils --version 1.2.3 --digest abc123
* bun scripts/generate-provenance.ts --package @suaveplan/utils --version 1.2.3 --digest abc123 --output ./provenance/
*
* Environment variables (auto-detected in GitLab CI):
* CI_COMMIT_SHA - Git commit SHA
* CI_PROJECT_URL - Repository URL
* CI_COMMIT_REF_NAME - Branch or tag name
* CI_RUNNER_ID - Runner identifier
* CI_JOB_ID - Job identifier
* CI_JOB_STARTED_AT - Job start timestamp
* CI_PIPELINE_ID - Pipeline identifier
*/
import { createHash } from "node:crypto";
import { mkdir, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { CONFIG } from "./lib/config.js";
const ROOT_DIR = CONFIG.paths.root;
interface CliOptions {
packageName: string;
version: string;
digest: string;
outputDir?: string;
}
function parseArgs(): CliOptions {
const args = process.argv.slice(2);
let packageName = "";
let version = "";
let digest = "";
let outputDir: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--package" && i + 1 < args.length) {
packageName = args[++i];
} else if (arg.startsWith("--package=")) {
packageName = arg.slice(10);
} else if (arg === "--version" && i + 1 < args.length) {
version = args[++i];
} else if (arg.startsWith("--version=")) {
version = arg.slice(10);
} else if (arg === "--digest" && i + 1 < args.length) {
digest = args[++i];
} else if (arg.startsWith("--digest=")) {
digest = arg.slice(9);
} else if (arg === "--output" && i + 1 < args.length) {
outputDir = args[++i];
} else if (arg.startsWith("--output=")) {
outputDir = arg.slice(9);
}
}
if (!packageName || !version || !digest) {
console.error(
"Usage: bun scripts/generate-provenance.ts --package <name> --version <ver> --digest <sha256>"
);
process.exit(1);
}
return { packageName, version, digest, outputDir };
}
function getGitLabEnv() {
return {
commitSha: process.env.CI_COMMIT_SHA ?? "unknown",
projectUrl:
process.env.CI_PROJECT_URL ??
"https://gitlab.fq.io/suaveplan/monorepo",
refName: process.env.CI_COMMIT_REF_NAME ?? "unknown",
runnerId: process.env.CI_RUNNER_ID ?? "local",
jobId: process.env.CI_JOB_ID ?? `local-${Date.now()}`,
jobStartedAt: process.env.CI_JOB_STARTED_AT ?? new Date().toISOString(),
pipelineId: process.env.CI_PIPELINE_ID ?? "local",
isCI: !!(process.env.CI || process.env.GITLAB_CI),
};
}
function generateProvenanceStatement(
options: CliOptions,
env: ReturnType<typeof getGitLabEnv>
) {
const buildType = "https://suaveplan.app/slsa/build-type/bun-monorepo/v1";
const encodedName = options.packageName.replace("@", "%40");
const subjectUri = `pkg:npm/${encodedName}@${options.version}`;
const builderId = `${env.projectUrl}/-/runners/${env.runnerId}`;
const invocationId = `${env.projectUrl}/-/pipelines/${env.pipelineId}/jobs/${env.jobId}`;
return {
_type: "https://in-toto.io/Statement/v1",
subject: [
{
uri: subjectUri,
digest: { sha256: options.digest },
name: options.packageName,
},
],
predicateType: "https://slsa.dev/provenance/v1",
predicate: {
buildDefinition: {
buildType,
externalParameters: {
repository: env.projectUrl,
ref: `refs/heads/${env.refName}`,
},
internalParameters: {
environment: env.isCI ? "gitlab-ci" : "local",
resolvedDependencies: [
{
uri: `git+${env.projectUrl}@refs/heads/${env.refName}`,
digest: { sha256: env.commitSha },
name: "source",
},
],
},
resolvedDependencies: [
{
uri: `git+${env.projectUrl}@refs/heads/${env.refName}`,
digest: { sha256: env.commitSha },
name: "source",
},
],
},
runDetails: {
builder: {
id: builderId,
version: {
bun:
typeof Bun !== "undefined"
? Bun.version
: "unknown",
},
},
metadata: {
invocationId,
startedOn: env.jobStartedAt,
finishedOn: new Date().toISOString(),
},
},
},
};
}
async function main() {
const options = parseArgs();
const env = getGitLabEnv();
console.log(
`Generating SLSA provenance for ${options.packageName}@${options.version}`
);
console.log(` Commit: ${env.commitSha}`);
console.log(` Builder: ${env.projectUrl}/-/runners/${env.runnerId}`);
console.log(` CI: ${env.isCI ? "yes" : "no (local)"}`);
const statement = generateProvenanceStatement(options, env);
const json = JSON.stringify(statement, null, 2);
// Compute digest of the provenance itself
const provenanceDigest = createHash("sha256").update(json).digest("hex");
const outputDir = options.outputDir
? join(ROOT_DIR, options.outputDir)
: join(ROOT_DIR, "provenance");
try {
await stat(outputDir);
} catch {
await mkdir(outputDir, { recursive: true });
}
const safeName = options.packageName.replace(/[/@]/g, "_");
const filename = `${safeName}-${options.version}.provenance.json`;
const outputPath = join(outputDir, filename);
await writeFile(outputPath, json);
console.log(` Output: ${outputPath}`);
console.log(` Provenance digest: ${provenanceDigest}`);
}
main().catch(console.error);