# How an issue title becomes a shell command in GitHub Actions, and how to stop it

> GitHub Actions script injection turns an issue title into a shell command. Which contexts an attacker writes, the fix that holds, what checkers flag.

Source: https://latchkey.dev/learn/ci-security/github-actions-script-injection  
Updated: 2026-11-12

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.

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.

```.github/workflows/check-title.yml
- 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.

```.github/workflows/check-title.yml
- 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](/learn/ci-security/pull-request-target-security) 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`.

```.github/workflows/label-issues.yml
- 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.

```Terminal
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](/learn/ci-security/prompt-injection-in-ci-agents), 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)](https://docs.github.com/en/actions/concepts/security/script-injections)
- [GitHub Docs: secure use reference, mitigating script injection](https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks)
- [Nx advisory GHSA-cxm3-wv7p-598c: malicious versions of Nx were published](https://github.com/nrwl/nx/security/advisories/GHSA-cxm3-wv7p-598c)
- [actionlint: script injection by potentially untrusted inputs](https://github.com/rhysd/actionlint/blob/main/docs/checks.md#untrusted-inputs)
- [zizmor audits: template-injection and dangerous-triggers](https://docs.zizmor.sh/audits/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
