Initial Commit
This commit is contained in:
196
scripts/generate-provenance.ts
Normal file
196
scripts/generate-provenance.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user