Cache Poisoning Poisonable Step Rule Overview #
This rule detects potential cache poisoning vulnerabilities when untrusted code is executed after checking out PR head code in privileged workflow contexts. Unlike the cache-poisoning rule which focuses on direct cache action usage, this rule focuses on code execution that could steal cache tokens or poison cache entries indirectly.
Vulnerable Example:
name: Vulnerable Workflow
on:
pull_request_target:
branches: [main]
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run tests
run: ./run_tests.sh # Executes attacker-controlled code!
Detection Output:
workflow.yaml:15:9: cache poisoning risk: executing local script './run_tests.sh' after unsafe checkout in privileged context. Attacker can steal ACTIONS_RUNTIME_TOKEN and poison cache. [cache-poisoning-poisonable-step]
15 π| - name: Run tests
Security Background #
Why This is Dangerous #
This vulnerability combines three dangerous patterns:
- Privileged Trigger:
pull_request_target,issue_comment, orworkflow_runruns with elevated permissions - Unsafe Checkout: Checking out untrusted PR code with
ref: ${{ github.event.pull_request.head.sha }} - Code Execution: Running local scripts or build commands that execute the checked-out code
Attack Scenario #
1. Attacker submits malicious PR
βββ Modified build.sh or package.json with malicious code
2. pull_request_target workflow triggers
βββ Runs with default branch permissions and cache access
3. Workflow checks out attacker's code
βββ ref: ${{ github.event.pull_request.head.sha }}
4. Malicious code executes (./build.sh, npm install, etc.)
βββ Steals ACTIONS_RUNTIME_TOKEN
βββ Poisons cache entries with backdoor
5. Future workflow runs restore poisoned cache
βββ Backdoor executes in legitimate builds
βββ Supply chain compromise achieved
OWASP and CWE Mapping #
- CWE-829: Inclusion of Functionality from Untrusted Control Sphere
- CWE-349: Acceptance of Extraneous Untrusted Data With Trusted Data
- OWASP CI/CD Security Risks:
- CICD-SEC-4: Poisoned Pipeline Execution (PPE)
- CICD-SEC-9: Improper Artifact Integrity Validation
Technical Detection Mechanism #
The rule triggers when all three conditions are met:
1. Unsafe Trigger is Used:
issue_commentpull_request_targetworkflow_run
2. Unsafe Checkout is Performed:
ref: ${{ github.event.pull_request.head.sha }}ref: ${{ github.event.pull_request.head.ref }}ref: ${{ github.head_ref }}ref: refs/pull/${{ ... }}/merge
3. Poisonable Step is Executed After Unsafe Checkout:
- Local script execution (
./build.sh,bash ./script.sh) - Build commands (
npm install,make,pip install, etc.) - Local actions (
uses: ./.github/actions/my-action) actions/github-scriptwith local file import
Detection Logic Explanation #
Poisonable Step Patterns #
Local Script Execution:
# Vulnerable patterns
- run: ./build.sh
- run: bash ./test.sh
- run: python ./setup.py
- run: node ./index.js
Build Commands:
# Vulnerable patterns - these read local config files
- run: npm install
- run: yarn
- run: pip install -r requirements.txt
- run: make
- run: go build ./...
- run: cargo build
- run: mvn package
- run: gradle build
Local Actions:
# Vulnerable pattern
- uses: ./.github/actions/build
GitHub Script with Local Import:
# Vulnerable pattern
- uses: actions/github-script@v7
with:
script: |
const script = require('./scripts/test.js')
await script()
Safe Patterns #
Pattern 1: Using Safe Trigger (pull_request)
#
The key difference is using pull_request instead of pull_request_target. This scopes the cache to the PR branch, preventing attackers from poisoning the main branch cache.
name: Secure Workflow
on:
pull_request: # Safe: Cache scoped to PR branch
branches: [main]
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run tests
run: ./run_tests.sh # Safe: Cache cannot affect main branch
Why this is safe:
pull_requestevent restricts cache scope to the PR branch- Cache is not shared with the default branch
- Attacker cannot poison the default branch cache even if they steal tokens
Pattern 2: Safe Checkout (Base Branch) #
If you must use pull_request_target, ensure actions/checkout does NOT specify a PR head reference:
name: Safe - Base Branch Checkout
on:
pull_request_target:
branches: [main]
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# No 'ref' input: defaults to base branch (main)
- run: ./build.sh
# Safe: checked out base branch code, not PR code
Why this is safe:
actions/checkoutwithoutrefdefaults to base branch- Execution happens on repository code, not untrusted PR code
- Attacker cannot influence which code gets executed
Pattern 3: External Commands Only #
Even with unsafe checkout, limiting steps to safe external commands prevents code execution vulnerabilities:
name: Safe - External Commands Only
on:
pull_request_target:
branches: [main]
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: echo "Hello World"
# Safe: doesn't execute local code
- run: node --version
# Safe: external command only
Why this is safe:
- No local scripts are executed (
./build.sh, etc.) - No build tools that read local files (
npm install,pip install, etc.) - No local actions
- No github-script with local imports
Auto-Fix Support #
The rule can automatically fix the vulnerability by removing the ref input from the checkout step:
# Preview fix
sisakulint -fix dry-run ./workflow.yaml
# Apply fix
sisakulint -fix on ./workflow.yaml
Before (Vulnerable):
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
After Auto-Fix (Safe):
- uses: actions/checkout@v4
Note: After auto-fix, the checkout defaults to base branch. Review your workflow to ensure this is the desired behavior.
Best Practices #
1. Prefer pull_request Over pull_request_target
#
# Good: Safe trigger
on: pull_request
# Dangerous: Privileged trigger
on: pull_request_target
2. Separate Untrusted and Privileged Operations #
# Workflow 1: Build (untrusted, no secrets)
name: Build PR
on: pull_request
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/
# Workflow 2: Deploy (trusted, uses secrets)
name: Deploy
on:
workflow_run:
workflows: ["Build PR"]
types: [completed]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
# No checkout of PR code!
- run: ./deploy.sh
env:
TOKEN: ${{ secrets.DEPLOY_TOKEN }}
3. Avoid Executing Local Code After Unsafe Checkout #
If you must checkout PR code in a privileged context:
# Only run safe external commands
- run: echo "PR: ${{ github.event.pull_request.number }}"
- run: git diff --stat HEAD~1
# DO NOT run:
# - run: ./script.sh
# - run: npm install
# - uses: ./.github/actions/local
Comparison with Related Rules #
| Rule | Focus | Detection |
|---|---|---|
| cache-poisoning | Cache keys with untrusted input | Flags dangerous cache key patterns |
| cache-poisoning-poisonable-step | Code execution after unsafe checkout | Flags steps that could poison cache |
| untrusted-checkout | Checkout in privileged contexts | Flags unsafe ref usage |
False Positives #
The rule may flag steps that:
- Execute external commands that don’t read local files
- Use build commands with
--ignore-scriptsor similar flags - Have other mitigations in place
Review flagged steps carefully and consider if the code execution path is actually exploitable.
Related Rules #
- cache-poisoning: Detects cache keys with untrusted input
- untrusted-checkout: Detects checkout of untrusted PR code
- code-injection-critical: Detects code injection in privileged contexts
References #
GitHub Documentation #
Security Research #
- CodeQL: actions-cache-poisoning-poisonable-step
- GitHub Actions Cache Poisoning
- GitHub Security Lab: Preventing Pwn Requests
- OWASP CICD-SEC-9: Improper Artifact Integrity Validation
Testing #
To test this rule:
# Detect poisonable steps
sisakulint .github/workflows/*.yml
# Apply auto-fix
sisakulint -fix on .github/workflows/*.yml
Configuration #
This rule is enabled by default. To disable it:
sisakulint -ignore cache-poisoning-poisonable-step
However, disabling this rule is not recommended as cache poisoning can lead to supply chain compromise.
