agents /

Red-Teaming GitHub Actions with Claude Skills

A Claude skill that audits GitHub Actions workflows for reachability instead of patterns, and the two-stage pipeline that runs it across tens of thousands of repos.

A linter tells you a workflow uses pull_request_target. It can’t tell you whether the code that workflow runs ever reaches a credential. That gap is most of the work in triaging a GitHub Actions finding, and it’s the part a language model is good at.

This is the system behind A Fork PR With Google Cloud Credentials Attached where a Claude or Codex agent audits workflows by reasoning about what’s reachable and can scale to run across tens of thousands of repos. The skill is going public soon; the method is written up here so you can rebuild it yourself in the meantime.

The pipeline

At a 10,000 foot view the system is two stages with a work queue between them.

GitHub Actions audit pipeline from indexing through static gating, queueing, agent audit, and fork proof

A scheduled indexer enumerates ~30k repos from an org allow-list and tracks what matters: default-branch HEAD, which workflows exist, which triggers they declare. It diffs against the last run. A new repo or new commit is the event that creates a candidate. Static analysis (zizmor plus a trigger inventory) keeps only changes shaped like a pwn request: an untrusted trigger near a checkout near a privileged step. Most repos never get past this.

Survivors land on the queue. A pool of agent workers pulls one each and audits that repo in isolation, with the queue buffering bursts instead of dropping them. Reconciliation is cheap, static analysis is cheap, the model costs money, exploiting costs the most. Each stage exists to keep the next from running on anything it doesn’t have to.

Reasoning about reachability

A GitHub Actions bug is rarely one line. It’s a reachability problem: can untrusted input flow to something dangerous?

A linter flags and a reviewer discards:

  • PR code is checked out but only read as data, never run
  • the build runs off the base branch, so the PR is input, not code
  • a token exists, but the only step using it runs before the untrusted code or behind an approval gate

A linter misses and a reviewer chases:

  • a make target a few files deep that runs the PR’s Python
  • an npm install firing a lifecycle script from the PR’s package.json
  • a token that leaks because a later step hands it to actions/github-script

The skill runs that judgment one workflow at a time.

Severity

Each finding scores on two axes. Trigger tier is how an attacker reaches the workflow: issue_comment or a pull_request_target that runs PR code sit at the top because they need zero privileges. Impact tier is what the code can touch: cloud OIDC and org-scoped tokens top that one. Severity is the lower of the two.

The exception is when the untrusted trigger runs fork code in the same job holding the credential. There’s no gap to take the minimum across, so the finding takes the full impact. That’s the Google case: pull_request_target ran the fork’s code in the very job that federates to GCP, so a HIGH trigger and a CRITICAL impact resolved to CRITICAL.

Data versus code

The most common mistake is calling a data operation code execution. Before the skill says injection, it works out where the input lands.

A ${{ }} expression dropped into a run: block is code: it’s source before the shell parses it, so shell metacharacters run. The same value placed in an env var and read as $VAR is data, because the shell reads it after parsing, so ; or $() is just text. Quote it anyway, since an unquoted $VAR word-splits and can smuggle arguments. It becomes code again if a tool re-parses it, like eval "$VAR" or fields that turn into flags for curl or git. Env-var data is where naive scanners over-report. Tracing every $VAR to a real sink is the biggest reason this is precise.

Verification

The audit reasons about code it only read, so a second skill rechecks each finding cheapest-first: is the workflow still there; is the job behind a real environment gate (a named environment with no reviewers doesn’t count); does the PR’s code actually run.

Whatever survives gets a real fork PR with a read-only payload. For the Google finding that was one call:

import google.auth
creds, project = google.auth.default()
print(project)  # came back with the target's service account

That PR runs on the target’s CI, not yours, so the payload stays read-only and visible in the diff.

Isolation

Each repo is audited in a disposable and locked down container with a read-only clone. Egress is limited to the model API and the target. Code that actually runs locally goes in a microVM with its own kernel. The fork-chain proof is the exception, since it runs on live CI infra which is why it stays read-only.

Open Sourcing Soon

The skill isn’t public yet. It ships as a drop-in skill directory for Claude Code and Codex, with install instructions and a plugin for auto-updates, once I’ve cleaned it up. When it lands: point it at a repo you own, ask it to audit the workflows, and prove any pull_request_target finding with a fork PR on a repo you control before you trust it.

Disclaimer

Run it only against repositories you own or are explicitly authorized to test. You’re responsible for what the agent does on your behalf. Provided as is with no warranty or liability for misuse.


← all agents