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.
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
maketarget a few files deep that runs the PR’s Python - an
npm installfiring a lifecycle script from the PR’spackage.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.