Skip to content

Instantly share code, notes, and snippets.

@UlisesGascon
Last active March 2, 2026 16:54
Show Gist options
  • Select an option

  • Save UlisesGascon/bc064336fecc194e2fa6f179b6a127df to your computer and use it in GitHub Desktop.

Select an option

Save UlisesGascon/bc064336fecc194e2fa6f179b6a127df to your computer and use it in GitHub Desktop.
#!/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