← Back to Actions Injection Check

Actions Injection Check vs. reading a workflow by hand

Reading a workflow file with both patterns already in mind -- a pull_request_target or workflow_run trigger checking out an untrusted head, and a run: step interpolating an attacker-controlled ${{ github.event.* }} value -- genuinely works, and it is the method most people already use. This page names no product or company being compared against: "reading the workflow by hand" is a generic manual habit, not a competing tool.

CaseReading the workflow by handThis tool
An untrusted checkout ref sits a few lines below the trigger, in an otherwise short workflowCorrectly caught by a reviewer who already knows the patternCorrectly caught the same way
The same ref buried many steps into a long job, under a nested with: blockEasy to skip once attention has moved past the trigger line -- nothing highlights the connectionTracks the trigger once and matches any checkout ref against it regardless of distance
Deciding whether a specific github.event.* path carries attacker contentRequires memorizing or looking up which suffixes (title, body, ref, head_ref, …) are untrusted -- getting it wrong either over-flags a safe field or misses an unsafe oneChecks every path found against the same fixed, documented suffix list every time
An untrusted expression on line eight of a twelve-line run: | blockEasy to under-review once the first few lines look ordinaryScans every line inside the block at its own indentation, not just the first
An expression embedded inside format() or a comparison rather than standing aloneEasy to miss if scanning for a bare ${{ github.event... }} onlyMatches the context path wherever it occurs in the line, not only as a standalone expression
Checking every workflow file in a repository on every dependency-bump PRManual, and the first thing skipped under time pressureCLI scans a whole checkout in one pass; --fail-on-risk blocks CI, SARIF output attaches findings to the PR diff

The gap that matters most: the trigger-to-checkout connection, at any distance

The pwn-request pattern is not one dangerous line, it is a relationship between two lines that can be far apart: a trigger declaration at the top of the file, and a checkout ref: possibly dozens of lines later inside a job's steps. A reviewer has to hold the trigger in mind while reading every subsequent step to notice the connection, and a workflow with several jobs and many steps makes that easy to lose. This tool notes the trigger once when it scans the file and then checks every subsequent checkout step's ref: against it, so the distance between the two lines never matters.

The gap that matters second: which fields are actually untrusted

Not every github.event.* path is attacker-controlled -- github.event.repository.name is set by GitHub, github.event.pull_request.title is text the PR's author wrote. GitHub's own guidance names the specific suffixes that carry untrusted content, and a reviewer without that list memorized either has to look it up every time or guess. This tool checks every matched path's suffix against that same fixed list, so the answer does not depend on what a reviewer happens to remember that day.

When to still read the workflow yourself

This tool has no opinion on whether a workflow should use pull_request_target at all -- it has legitimate uses, such as labeling a PR based on its base branch. Deciding whether the trigger is warranted, and catching variants this tool does not attempt (a hand-rolled git checkout instead of actions/checkout, or an expression built by unusual string concatenation), is still a job for reading the workflow yourself.

Common questions

What does reading a workflow file by hand actually catch?

A careful reviewer who knows GitHub's own guidance can spot both patterns this tool checks: a pull_request_target or workflow_run workflow that checks out an untrusted head, and a run: step that drops an attacker-controlled ${{ github.event.* }} value straight into a shell command. That review genuinely works when the file is short and the reviewer already has both patterns in mind -- the same two patterns this tool's classify.mjs looks for.

Why is the untrusted-checkout pattern easy to miss on a skim?

Spotting it requires remembering that exactly two triggers -- pull_request_target and workflow_run -- run with the base repository's secrets even though a stranger's fork raised the event, and then tracing forward from that trigger line to a checkout step's ref:, which can sit many lines later inside a with: block nested under a job with several other steps. A reviewer skimming for anything that looks obviously dangerous has no reason to single out an ordinary-looking actions/checkout@v4 step unless they already made that trigger-to-checkout connection at the top of the file. This tool tracks the connection mechanically: it notes the trigger once, then flags any checkout ref matching the untrusted-head pattern regardless of how many steps or lines separate the two.

Why is knowing which github.event.* fields are untrusted hard to keep in your head?

Not every github.event.* path carries attacker content -- github.event.repository.name is set by GitHub itself, while github.event.pull_request.title is text the pull request's author wrote. GitHub's own script-injection guidance names the specific suffixes that carry untrusted content (title, body, message, label, ref, head_ref, and a handful more); a reviewer has to either memorize that list or look it up every time, and getting it wrong runs in both directions -- flagging a safe field wastes review time, missing an unsafe one is the actual injection. This tool checks every github.event.* path found anywhere in a run: step, including inside format() calls and comparisons, against that same fixed suffix list every time.

Why is a multi-line run: block easy to under-review?

A run: step written as a YAML block scalar (run: |) can run many lines of shell, and an untrusted expression on line eight of a twelve-line script is easy to miss once a reviewer's attention has settled after the first few, ordinary-looking lines. This tool scans every line inside the block at the block's own indentation, not just its first line, so a path buried on the last line is caught the same as one on the first.

Does this replace understanding why a workflow needs pull_request_target at all?

No. pull_request_target has legitimate uses -- commenting on a PR with a label based on the base branch, for instance -- and this tool has no opinion on whether a given workflow should use it. It only flags the specific combination of that trigger with an untrusted checkout ref, or an untrusted value spliced into a shell command. Deciding whether the trigger itself is warranted, and catching variants this tool does not attempt (a hand-rolled git checkout instead of actions/checkout, an expression built by unusual string concatenation), is still a job for reading the workflow yourself.