Do not place expressions such as a pull request title directly inside a run script. Assign the value to an environment variable and read it as a normal shell variable, or pass it to an action as an input. This keeps attacker controlled text from being turned into shell commands.
Why the direct form is dangerous
In a workflow, an expression is replaced with its value before the shell sees the script. If that value comes from someone else, such as a pull request title, a branch name, an issue body or a commit message, the text becomes part of the script. A title like a"; ls $GITHUB_WORKSPACE" is the example GitHub uses to show how a quote and semicolon can end your command and start theirs.
Replacing the value first, and running the shell second, is what makes this class of bug. The fix is to change what the shell receives.
Before and after
Risky:
- name: Check title
run: |
echo "Title is ${{ github.event.pull_request.title }}"
Safer, using an intermediate environment variable, the approach GitHub calls preferred for inline scripts:
- name: Check title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Title is $TITLE"
GitHub explains why this works: the expression value is stored in memory and used as a variable, so it does not interact with script generation. In its example, the attempted injection fails, and the log simply shows the odd text as data. The docs also suggest double quoting shell variables to avoid word splitting, which is a general shell rule.
A second option from the docs: use an action instead of an inline script. A JavaScript action receives the context value as an argument, and is not vulnerable to this injection because the value is never used to generate a shell script.
Which values to treat as untrusted
Treat anything a contributor can influence as untrusted, even when the contributor is "internal":
- Pull request title, body and branch names.
- Issue titles and bodies, and comment text.
- Commit messages and author names.
- Anything from an artifact or a previous workflow run.
Values that your own configuration sets, like the repository name or a fixed string, are lower risk, but the habit of using the environment variable form for everything is simpler than deciding case by case.
A five minute audit
- Search workflow files for expressions used inside
run:blocks. - For each match, ask whether the value could be influenced by someone outside the team.
- Rewrite the match using an environment variable.
- Reduce token permissions on the job, so an injection that does land has less to reach.
- Turn on scanning for workflows. GitHub's docs say code scanning, including CodeQL, can detect potentially vulnerable workflows, and OpenSSF Scorecards can flag script injection and token permission issues.
Try it yourself
Static analysis catches this pattern in application code too. The demo shows a code scan finding an injection style flaw. Click a finding and read how the data flows from source to sink.
Click through it at your own pace, or open it full screen.
Where Safeguard fits
Safeguard offers code and dependency scanning, and policy gates for pull requests. For workflow files specifically, use the platform tools GitHub names above and review changes with a code owner. See protecting workflow files with CODEOWNERS.
Quick check
Is the environment variable form completely safe?
It stops the value changing the script. Your script must still handle the value safely, for example by quoting it and not passing it to eval or another interpreter.
Does this apply to GitLab CI?
Similar issues exist wherever user text is substituted into a script. The syntax differs, so check your platform's documentation.
Sources
Last reviewed: 19 October 2026.