How an issue title becomes a shell command in GitHub Actions, and how to stop it
GitHub Actions script injection happens when a ${{ }} expression holding attacker-controlled text, such as github.event.issue.title, is placed inside a run: block: Actions substitutes the text into the script before the
GitHub Actions script injection happens when a ${{ }} expression holding attacker-controlled text, such as github.event.issue.title, is placed inside a run: block: Actions substitutes the text into the script before the shell parses it, so a quote and a semicolon in the title become a command. The fix is to pass the value through an environment variable and reference it as a quoted shell variable.

Expressions are expanded before the shell starts. An environment variable is read after it starts, as data.
Workflows that react to issues and pull requests handle text written by anyone with a GitHub account. The moment that text reaches a shell as code rather than data, the author of an issue title runs commands in your job, with your token.
The mechanism comes first, in GitHub's own example. Then the contexts an attacker controls, the fix and its limits, actions that take untrusted input, the Nx compromise from its advisory, and exactly what actionlint, zizmor and CodeQL flag.
How a title becomes a shell command
A run: step is written to a temporary script on the runner and executed. Before that happens, every ${{ }} in it is evaluated and its value pasted into the script text. The shell never sees an expression; it sees whatever the value was, quotes and semicolons included.
GitHub's docs use this check of a pull request title. A pull request titled a"; ls $GITHUB_WORKSPACE" closes the string, ends the statement and runs ls. The log shows the substituted command and then the listing. Replace ls with a curl that sends the step's environment to another host, and the same title is an exfiltration.
- name: Check PR title
run: |
title="${{ github.event.pull_request.title }}"
if [[ $title =~ ^octocat ]]; then
echo "PR title starts with 'octocat'"
exit 0
else
echo "PR title did not start with 'octocat'"
exit 1
fi
# log, with the title a"; ls $GITHUB_WORKSPACE"
Run title="a"; ls $GITHUB_WORKSPACE""
README.md
code.yml
example.js
The contexts an attacker controls
GitHub's guidance is that untrusted contexts "typically end with body, default_branch, email, head_ref, label, message, name, page_name, ref, and title." Branch names are the easy one to miss: GitHub gives zzz";echo${IFS}"hello";# as a valid branch name, and ${IFS} stands in for spaces where a name cannot hold one.
actionlint keeps an explicit list of the properties it treats as untrusted. The table groups them by who gets to write them.
| Context | Written by |
|---|---|
github.event.issue.title, github.event.issue.body
|
Anyone who can open an issue |
github.event.pull_request.title, github.event.pull_request.body
|
The pull request author |
github.event.comment.body, github.event.review.body, github.event.review_comment.body
|
Any commenter or reviewer |
github.head_ref, github.event.pull_request.head.ref, github.event.pull_request.head.label
|
The author, through the branch name |
github.event.pull_request.head.repo.default_branch |
The fork owner |
github.event.head_commit.message, github.event.commits.*.message
|
Whoever wrote the commits |
github.event.head_commit.author.name, .author.email, github.event.commits.*.author.*
|
Whoever set the git author |
github.event.pages.*.page_name |
Wiki editors |
From docs.github.com "Script injections" and actionlint docs/checks.md "Script injection by potentially untrusted inputs", read 2026-11-12.
The fix: an intermediate environment variable
Move the expression to env: and read the variable from the shell. The value is placed in the process environment, not in the script text, so the shell treats it as data. GitHub calls this "the preferred approach to handling untrusted input". Quote the variable as well, to avoid word splitting.
Three ways to undo it. ${{ env.TITLE }} inside run: is still an expression and is still pasted in, as zizmor's docs point out; use $TITLE. Writing the value to $GITHUB_ENV lets it inject variable definitions into later steps, which CodeQL reports as "Environment variable built from user-controlled sources". And eval "$TITLE", or a value passed unquoted to a tool that runs a shell, puts you back where you started.
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$TITLE" =~ ^octocat ]]; then
echo "PR title starts with 'octocat'"
else
echo "PR title did not start with 'octocat'"
exit 1
fi
# Still injectable: the expression is expanded into the script
# run: echo "${{ env.TITLE }}"
Actions that take untrusted input
GitHub's other recommendation is to hand the value to an action through with:, because an input is not written into a shell script. That holds only if the action does not write it into one itself. A composite action whose step runs echo "${{ inputs.title }}" has the same bug one file away, which is how the Ultralytics compromise worked: the workflow passed nothing unsafe, the action it called spliced github.head_ref into git pull.
actions/github-script is the other common case. Its script input is JavaScript source, so an expression inside it is code injection in JavaScript rather than bash, and actionlint checks that input for exactly this reason. Pass values through env: and read process.env.
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TITLE: ${{ github.event.issue.title }}
with:
script: |
const title = process.env.TITLE;
if (/^\[bug\]/i.test(title)) {
await github.rest.issues.addLabels({
...context.repo, issue_number: context.issue.number, labels: ['bug'],
});
}
Nx, August 2025: one echo in a privileged workflow
The Nx advisory, GHSA-cxm3-wv7p-598c, names the root cause as a pull request validation workflow with two problems. The first was this line: echo "Validating PR title: ${{ github.event.pull_request.title }}". The maintainers write that "if a PR was opened with a title such as $(echo "You've been compromised") the code would be executed within the workflow."
The second was the trigger, pull_request_target, which gave the injected command a GITHUB_TOKEN with read/write permission on nrwl/nx. The validation workflow held no secrets, which is why the team first judged it harmless. The maintainers believe the token was used to trigger the publish.yml workflow, which does hold the npm token, against a malicious commit that sent that token to a webhook. Malicious versions of nx, including 21.5.0 and 20.9.0, followed. One ${{ }} in an echo was the whole entry point.
Catch it before merge: actionlint, zizmor and CodeQL
All three tools find the direct case. They differ in how far they follow it, and running more than one costs little.
brew install actionlint zizmor
actionlint
zizmor .
# actionlint output for an untrusted title in run:
test.yaml:10:24: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
| Tool | What it reports for injection | Also relevant here |
|---|---|---|
| actionlint 1.7.12 | The [expression] rule: an untrusted context in run: or in actions/github-script script, including github.event.*.body object filters |
Ignores untrusted values inside contains(), startsWith() and endsWith(), which return booleans |
| zizmor 1.32.0 | The template-injection audit: template expansions in code contexts such as run:
|
dangerous-triggers flags pull_request_target, workflow_run and issue_comment
|
| CodeQL for GitHub Actions | "Code injection" at critical severity in the default suite; a medium-severity variant in security-extended
|
"Environment variable built from user-controlled sources" and untrusted checkout queries |
The actionlint output is the example in its docs/checks.md; versions are the latest releases on 2026-11-12 (actionlint tag v1.7.12, zizmor 1.32.0 on PyPI). CodeQL query names are from GitHub's "Actions built-in queries" reference, read the same day.
FAQ
Is it safe to use github.event.pull_request.title in an if condition?
Yes for shell injection: an if: is evaluated by Actions and never reaches a shell, and functions like contains() return only true or false. The risk returns if the same value is later interpolated into run: or into a github-script script.
Does an environment variable fully prevent injection?
It prevents the shell from parsing the value as code, which is the injection this page is about. It does not help if you then eval the variable, write it to $GITHUB_ENV, or pass it to a tool that builds its own command. It also does nothing against prompt injection, where the model reads the value as instructions.
Which GitHub contexts are untrusted?
Anything a person outside your team can write. GitHub lists contexts ending in body, default_branch, email, head_ref, label, message, name, page_name, ref and title, and actionlint publishes the exact property list it checks. Values like github.event.issue.number or github.run_id are generated by GitHub and are safe.
Can workflows triggered by pull_request from a fork be exploited by script injection?
The command runs, but with a read-only token and no secrets, so there is little to take. The damage comes when the same pattern sits in a privileged trigger, pull_request_target, issue_comment, issues or workflow_run, which is where both Nx and Ultralytics were exposed.
References
- GitHub Docs: script injections (read 2026-11-12)
- GitHub Docs: secure use reference, mitigating script injection
- Nx advisory GHSA-cxm3-wv7p-598c: malicious versions of Nx were published
- actionlint: script injection by potentially untrusted inputs
- zizmor audits: template-injection and dangerous-triggers
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.