197 lines
6.7 KiB
TypeScript
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);
|