Last active
March 2, 2026 16:54
-
-
Save UlisesGascon/bc064336fecc194e2fa6f179b6a127df to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * GitHub Actions Vulnerability Scanner | |
| * Detects patterns exploited by hackerbot-claw and similar CI/CD attacks. | |
| * | |
| * Usage: | |
| * node scan-workflows.js <org> | |
| * node scan-workflows.js <org> --repo <single-repo> | |
| * node scan-workflows.js <org> --fix-hints | |
| * | |
| * Requirements: gh CLI installed and authenticated (gh auth login) | |
| */ | |
| import { execSync } from "child_process"; | |
| import { readFileSync } from "fs"; | |
| import path from "path"; | |
| import os from "os"; | |
| import fs from "fs"; | |
| const VERSION = "1.0.1"; | |
| // ── ANSI colors (auto-disabled when piping to a file) ───────────────────────── | |
| const TTY = process.stdout.isTTY; | |
| const c = { | |
| red: s => TTY ? `\x1b[31m${s}\x1b[0m` : s, | |
| yellow: s => TTY ? `\x1b[33m${s}\x1b[0m` : s, | |
| green: s => TTY ? `\x1b[32m${s}\x1b[0m` : s, | |
| cyan: s => TTY ? `\x1b[36m${s}\x1b[0m` : s, | |
| bold: s => TTY ? `\x1b[1m${s}\x1b[0m` : s, | |
| dim: s => TTY ? `\x1b[2m${s}\x1b[0m` : s, | |
| magenta: s => TTY ? `\x1b[35m${s}\x1b[0m` : s, | |
| }; | |
| const SEVERITY = { | |
| CRITICAL: c.red(c.bold("CRITICAL")), | |
| HIGH: c.yellow(c.bold("HIGH")), | |
| MEDIUM: c.yellow("MEDIUM"), | |
| INFO: c.cyan("INFO"), | |
| }; | |
| // ── Vulnerability patterns from hackerbot-claw campaign ────────────────────── | |
| const CHECKS = [ | |
| { | |
| id: "PWN_REQUEST", | |
| severity: "CRITICAL", | |
| title: "Pwn Request: pull_request_target + untrusted checkout + script execution", | |
| description: "Workflow runs attacker's fork code with target repo's secrets.", | |
| fix: "Use `pull_request` trigger instead, or never checkout/execute fork code in pull_request_target workflows.", | |
| // Detects all three conditions co-existing in the same file | |
| detect(content) { | |
| const hasPRT = /on:\s*[\r\n][\s\S]*?pull_request_target/.test(content); | |
| const hasCheckout = /uses:\s*actions\/checkout/.test(content); | |
| const hasForkRef = /ref:\s*\$\{\{\s*(github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref)/.test(content); | |
| const hasRun = /\brun:\s*[|>]?\s*[\r\n\s]*(go run|bash|sh|python|node|ruby|cargo|make|\.\/)/m.test(content); | |
| return hasPRT && hasCheckout && hasForkRef && hasRun; | |
| }, | |
| }, | |
| { | |
| id: "PWN_REQUEST_PARTIAL", | |
| severity: "HIGH", | |
| title: "Partial Pwn Request: pull_request_target + untrusted checkout (no explicit run)", | |
| description: "pull_request_target checks out fork code. Any subsequent step touching those files is risky.", | |
| fix: "Audit all steps. Ensure fork code is never compiled, executed, or passed unsanitized to shell commands.", | |
| detect(content) { | |
| const hasPRT = /on:\s*[\r\n][\s\S]*?pull_request_target/.test(content); | |
| const hasCheckout = /uses:\s*actions\/checkout/.test(content); | |
| const hasForkRef = /ref:\s*\$\{\{\s*(github\.event\.pull_request\.head\.(sha|ref)|github\.head_ref)/.test(content); | |
| return hasPRT && hasCheckout && hasForkRef; | |
| }, | |
| }, | |
| { | |
| id: "EXPRESSION_INJECTION", | |
| severity: "CRITICAL", | |
| title: "Expression injection: ${{ }} in run: block", | |
| description: "Attacker-controlled values (branch name, PR title, filename) are interpolated directly into shell. Exploited in Microsoft and DataDog attacks.", | |
| fix: "Move expressions to env: variables and reference $VAR_NAME in the shell script instead.", | |
| detect(content) { | |
| // Look for ${{ github.event.pull_request.* }} or ${{ github.head_ref }} inside run: blocks | |
| const runBlocks = [...content.matchAll(/run:\s*[|>]?\s*([\s\S]*?)(?=\n\s{0,8}\w|\n\s*-\s|\n\s*uses:|\n\s*if:|$)/gm)]; | |
| return runBlocks.some(([, block]) => | |
| /\$\{\{\s*(github\.event\.(pull_request\.(head\.(ref|sha)|title|body)|comment\.body)|github\.head_ref)/.test(block) | |
| ); | |
| }, | |
| }, | |
| { | |
| id: "UNAUTHED_COMMENT_TRIGGER", | |
| severity: "HIGH", | |
| title: "Comment-triggered workflow with no author_association check", | |
| description: "Any GitHub user can trigger the workflow by posting a comment. Exploited in akri attack with `/version minor`.", | |
| fix: "Add: if: github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER'", | |
| detect(content) { | |
| const hasCommentTrigger = /on:\s*[\r\n][\s\S]*?issue_comment/.test(content); | |
| const hasAuthorCheck = /author_association/.test(content); | |
| return hasCommentTrigger && !hasAuthorCheck; | |
| }, | |
| }, | |
| { | |
| id: "WRITE_PERMS_ON_PR_TARGET", | |
| severity: "HIGH", | |
| title: "Write permissions on pull_request_target workflow", | |
| description: "Combining write permissions with pull_request_target means a stolen token has write access. Enabled full repo takeover in the trivy attack.", | |
| fix: "Use contents: read unless you have a specific need. Isolate write operations to separate, protected workflows.", | |
| detect(content) { | |
| const hasPRT = /on:\s*[\r\n][\s\S]*?pull_request_target/.test(content); | |
| const hasWrite = /(contents|packages|pull-requests|id-token):\s*write/.test(content); | |
| return hasPRT && hasWrite; | |
| }, | |
| }, | |
| { | |
| id: "NO_PERMISSIONS_BLOCK", | |
| severity: "MEDIUM", | |
| title: "No explicit permissions block", | |
| description: "Without explicit permissions, the workflow inherits repo defaults (often write). Least-privilege requires explicit declaration.", | |
| fix: "Add a top-level `permissions: contents: read` block and override per-job only where needed.", | |
| detect(content) { | |
| return !/^\s*permissions:/m.test(content); | |
| }, | |
| }, | |
| { | |
| id: "UNPINNED_ACTION", | |
| severity: "MEDIUM", | |
| title: "Actions pinned to a tag instead of a commit SHA", | |
| description: "Tags can be moved. A compromised action publisher can push malicious code under the same tag.", | |
| fix: "Pin to a full commit SHA: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2", | |
| detect(content) { | |
| // Find uses: lines pinned to a version tag (v1, v2.3, etc.) but NOT to a SHA | |
| const uses = [...content.matchAll(/uses:\s*([^\s@]+)@([^\s#\n]+)/g)]; | |
| return uses.some(([,, ref]) => /^v?\d/.test(ref) && !/^[0-9a-f]{40}$/.test(ref)); | |
| }, | |
| }, | |
| { | |
| id: "SECRETS_IN_PR_TARGET", | |
| severity: "HIGH", | |
| title: "secrets.* referenced in pull_request_target workflow", | |
| description: "Secrets accessible in a pull_request_target workflow can be exfiltrated if fork code executes. This is how the GITHUB_TOKEN was stolen in awesome-go.", | |
| fix: "Do not reference secrets in pull_request_target workflows that checkout or execute fork code.", | |
| detect(content) { | |
| const hasPRT = /on:\s*[\r\n][\s\S]*?pull_request_target/.test(content); | |
| const hasSecrets = /\$\{\{\s*secrets\./.test(content); | |
| return hasPRT && hasSecrets; | |
| }, | |
| }, | |
| ]; | |
| // ── Helpers ─────────────────────────────────────────────────────────────────── | |
| function gh(cmd) { | |
| return execSync(`gh ${cmd}`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim(); | |
| } | |
| function getRepos(org, singleRepo) { | |
| if (singleRepo) return [`${org}/${singleRepo}`]; | |
| console.log(c.dim(` Fetching repos for ${org}...`)); | |
| const json = gh(`api --paginate orgs/${org}/repos --jq '.[].full_name'`); | |
| return json.split("\n").filter(Boolean); | |
| } | |
| function getWorkflows(repo) { | |
| try { | |
| const json = gh(`api repos/${repo}/contents/.github/workflows --jq '.[].path'`); | |
| return json.split("\n").filter(f => f.endsWith(".yml") || f.endsWith(".yaml")); | |
| } catch { | |
| return []; // repo has no .github/workflows | |
| } | |
| } | |
| function getFileContent(repo, filePath) { | |
| try { | |
| return gh(`api repos/${repo}/contents/${filePath} --jq '.content' | base64 -d`); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function severityOrder(s) { | |
| return { CRITICAL: 0, HIGH: 1, MEDIUM: 2, INFO: 3 }[s] ?? 4; | |
| } | |
| // ── Main ────────────────────────────────────────────────────────────────────── | |
| const args = process.argv.slice(2); | |
| const org = args[0]; | |
| const repoFlag = args.indexOf("--repo"); | |
| const singleRepo = repoFlag !== -1 ? args[repoFlag + 1] : null; | |
| const showFixHints = args.includes("--fix-hints"); | |
| if (!org) { | |
| console.error(c.red("Usage: node scan-workflows.js <org> [--repo <name>] [--fix-hints]")); | |
| process.exit(1); | |
| } | |
| console.log(c.bold(`\n🔍 GitHub Actions Vulnerability Scanner`) + c.dim(` v${VERSION}`)); | |
| console.log(c.dim(` Patterns: hackerbot-claw campaign (Feb 2026)`)); | |
| console.log(c.dim(` Org: ${org}${singleRepo ? ` / ${singleRepo}` : ""}\n`)); | |
| const repos = getRepos(org, singleRepo); | |
| console.log(c.dim(` Found ${repos.length} repos to scan\n`)); | |
| const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, total_files: 0, total_repos: 0 }; | |
| const findings = []; // { repo, file, checks[] } | |
| for (const repo of repos) { | |
| const workflows = getWorkflows(repo); | |
| if (workflows.length === 0) continue; | |
| let repoHasFindings = false; | |
| for (const wfPath of workflows) { | |
| summary.total_files++; | |
| const content = getFileContent(repo, wfPath); | |
| if (!content) continue; | |
| const triggered = CHECKS.filter(c => c.detect(content)) | |
| .sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity)); | |
| if (triggered.length === 0) continue; | |
| if (!repoHasFindings) { | |
| console.log(c.bold(`📦 ${repo}`)); | |
| summary.total_repos++; | |
| repoHasFindings = true; | |
| } | |
| console.log(` ${c.cyan(wfPath)}`); | |
| for (const chk of triggered) { | |
| summary[chk.severity] = (summary[chk.severity] || 0) + 1; | |
| const sev = SEVERITY[chk.severity] ?? chk.severity; | |
| console.log(` ${sev} ${c.bold(chk.title)}`); | |
| console.log(c.dim(` ${chk.description}`)); | |
| if (showFixHints) { | |
| console.log(c.green(` ✏ Fix: ${chk.fix}`)); | |
| } | |
| } | |
| console.log(); | |
| } | |
| } | |
| // ── Summary ─────────────────────────────────────────────────────────────────── | |
| console.log(c.bold("─".repeat(60))); | |
| console.log(c.bold("📊 Summary")); | |
| console.log(` Repos scanned: ${repos.length}`); | |
| console.log(` Repos with issues:${summary.total_repos}`); | |
| console.log(` Workflow files: ${summary.total_files}`); | |
| console.log(` ${c.red(c.bold("CRITICAL:"))} ${summary.CRITICAL || 0}`); | |
| console.log(` ${c.yellow(c.bold("HIGH:"))} ${summary.HIGH || 0}`); | |
| console.log(` ${c.yellow("MEDIUM:")} ${summary.MEDIUM || 0}`); | |
| if ((summary.CRITICAL || 0) === 0 && (summary.HIGH || 0) === 0) { | |
| console.log(c.green(c.bold("\n✅ No CRITICAL or HIGH findings. Nice work."))); | |
| } else { | |
| console.log(c.red(c.bold(`\n⚠️ Action required. Re-run with --fix-hints for remediation guidance.`))); | |
| } | |
| console.log(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment