Cache Poisoning Rule Overview #
This rule detects potential cache poisoning vulnerabilities in GitHub Actions workflows. It identifies two types of cache poisoning attacks:
Security Impact #
Severity: High (8/10)
Cache poisoning vulnerabilities pose significant risks to CI/CD pipeline integrity:
- Supply Chain Compromise: Attackers can inject malicious code into cached dependencies
- Persistent Attacks: Poisoned caches affect all subsequent builds until evicted
- Cross-Repository Impact: Shared caches can spread compromise across multiple repositories
- Difficult Detection: Cache-based attacks are often invisible in code review
This vulnerability aligns with OWASP CI/CD Security Risk CICD-SEC-9: Improper Artifact Integrity Validation.
- Indirect Cache Poisoning: Dangerous combinations of untrusted triggers with unsafe checkout and cache operations
- Direct Cache Poisoning: Untrusted input in cache configuration (key, restore-keys, path) that can be exploited regardless of trigger type
The rule detects three types of cache poisoning attacks:
- Indirect Cache Poisoning: Untrusted triggers + unsafe checkout + cache actions
- Cache Hierarchy Exploitation: Workflows that can write to default branch cache via external triggers
- Cache Eviction Risk: Multiple cache actions that could enable cache flooding attacks
Key Features #
- Dual Detection Mode: Detects both indirect (trigger-based) and direct (input-based) cache poisoning
- Multiple Trigger Detection: Identifies
issue_comment,pull_request_target, andworkflow_runtriggers - Comprehensive Cache Detection: Detects both
actions/cacheand setup-* actions with cache enabled - Direct Cache Input Validation: Checks for untrusted expressions in
key,restore-keys, andpathinputs - Job Isolation: Correctly scopes detection to individual jobs
- Smart Checkout Tracking: Resets unsafe state when a safe checkout follows an unsafe one
- Conservative Pattern Matching: Detects direct, indirect, and unknown expression patterns
- CodeQL Compatible: Based on CodeQL’s query with enhanced detection capabilities
- Auto-fix Support: Removes unsafe
refinput from checkout steps or replaces untrusted cache keys withgithub.sha - Cache Hierarchy Exploitation Detection: Identifies workflows with external triggers that can poison default branch cache
- Cache Eviction Risk Detection: Warns when workflows use excessive cache actions (5+)
Detection Conditions #
Indirect Cache Poisoning (Trigger-Based) #
The rule triggers when all three conditions are met
Untrusted Trigger is used:
issue_commentpull_request_targetworkflow_run
Unsafe Checkout with PR head reference
- Direct patterns:
ref: ${{ github.event.pull_request.head.sha }}ref: ${{ github.event.pull_request.head.ref }}ref: ${{ github.head_ref }}ref: refs/pull/*/merge
- Indirect patterns (from step outputs):
ref: ${{ steps.*.outputs.head_sha }}ref: ${{ steps.*.outputs.head_ref }}ref: ${{ steps.*.outputs.head-sha }}
- Conservative detection: Any unknown expression in
refwith untrusted triggers is treated as potentially unsafe
- Direct patterns:
Cache Action is used
actions/cacheactions/setup-nodewithcacheinputactions/setup-pythonwithcacheinputactions/setup-gowithcacheinputactions/setup-javawithcacheinput
Direct Cache Poisoning (Input-Based) #
The rule triggers when untrusted input is used in cache configuration, regardless of trigger type:
Untrusted input in
key:key: npm-${{ github.event.pull_request.head.ref }}key: ${{ github.event.pull_request.title }}key: ${{ github.head_ref }}
Untrusted input in
restore-keys:restore-keys: ${{ github.head_ref }}-restore-keys: ${{ github.event.comment.body }}
Untrusted input in
path:path: ${{ github.event.pull_request.title }}path: ${{ github.event.issue.body }}
Untrusted inputs include:
github.event.pull_request.head.refgithub.event.pull_request.head.shagithub.event.pull_request.titlegithub.event.pull_request.bodygithub.event.issue.titlegithub.event.issue.bodygithub.event.comment.bodygithub.head_ref- And other user-controllable values
Example Vulnerable Workflows #
Example 1: Indirect Cache Poisoning (Trigger-Based) #
name: PR Build
on:
pull_request_target:
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }} # Checks out untrusted PR code
- uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm' # Cache can be poisoned
- uses: actions/cache@v3
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
Example 2: Indirect Cache Poisoning via Step Output (CodeQL Pattern) #
name: Comment Build
on:
issue_comment:
types: [created]
jobs:
pr-comment:
runs-on: ubuntu-latest
steps:
- uses: xt0rted/pull-request-comment-branch@v2
id: comment-branch
- uses: actions/checkout@v3
with:
ref: ${{ steps.comment-branch.outputs.head_sha }} # Indirect untrusted reference
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip' # Cache can be poisoned
Example 3: Direct Cache Poisoning (Input-Based) #
name: PR Build with Unsafe Cache Key
on:
pull_request: # Safe trigger, but cache key is still vulnerable
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# VULNERABLE: Untrusted input in cache key
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ github.event.pull_request.head.ref }}-${{ hashFiles('**/package-lock.json') }}
# VULNERABLE: Untrusted input in restore-keys
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ github.sha }}
restore-keys: |
pip-${{ github.head_ref }}-
Example Output #
Indirect Cache Poisoning Output #
$ sisakulint ./vulnerable-workflow.yaml
./vulnerable-workflow.yaml:15:9: cache poisoning risk: 'actions/setup-node@v4' used after checking out untrusted PR code (triggers: pull_request_target). Validate cached content or scope cache to PR level [cache-poisoning]
15 👈| - uses: actions/setup-node@v4
./vulnerable-workflow.yaml:20:9: cache poisoning risk: 'actions/cache@v3' used after checking out untrusted PR code (triggers: pull_request_target). Validate cached content or scope cache to PR level [cache-poisoning]
20 👈| - uses: actions/cache@v3
Direct Cache Poisoning Output #
$ sisakulint ./cache-poisoning-direct.yaml
./cache-poisoning-direct.yaml:11:14: cache poisoning via untrusted input: 'github.event.pull_request.head.ref' in cache key is potentially untrusted. An attacker can control the cache key to poison the cache. Use trusted inputs like github.sha, hashFiles(), or static values instead [cache-poisoning]
11 👈| key: npm-${{ github.event.pull_request.head.ref }}-${{ hashFiles('**/package-lock.json') }}
./cache-poisoning-direct.yaml:18:22: cache poisoning via untrusted input: 'github.head_ref' in cache restore-keys is potentially untrusted. An attacker can control the cache key to poison the cache. Use trusted inputs like github.sha, hashFiles(), or static values instead [cache-poisoning]
18 👈| pip-${{ github.head_ref }}-
Safe Patterns #
The following patterns do NOT trigger warnings
- Safe Trigger (pull_request)
on:
pull_request: # Safe: runs in PR context, not default branch
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v3 # Safe: no cache poisoning risk
- No Unsafe Checkout
on:
pull_request_target:
jobs:
build:
steps:
- uses: actions/checkout@v4 # Safe: checks out base branch (default)
- uses: actions/cache@v3 # Safe: base branch code is trusted
- Cache in Separate Job
on:
pull_request_target:
jobs:
checkout-pr:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }} # Unsafe checkout, but no cache
build:
steps:
- uses: actions/cache@v3 # Safe: different job, no unsafe checkout here
- Safe Checkout After Unsafe Checkout
on:
pull_request_target:
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }} # Unsafe checkout (for testing PR code)
- name: Test PR code
run: npm test
- uses: actions/checkout@v4 # Safe: checks out base branch (resets state)
- uses: actions/cache@v3 # Safe: cache operates on base branch code
Auto-fix Support #
The cache-poisoning rule supports auto-fixing for both types of vulnerabilities:
# Preview changes without applying
sisakulint -fix dry-run
# Apply fixes
sisakulint -fix on
Auto-fix for Indirect Cache Poisoning #
The auto-fix removes the ref input that checks out untrusted PR code, causing the workflow to checkout the base branch instead. This ensures the cached content is based on trusted code.
Before fix
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }} # Unsafe: checks out PR code
After fix
- uses: actions/checkout@v4
Auto-fix for Direct Cache Poisoning #
The auto-fix replaces untrusted expressions in cache key and restore-keys with github.sha, which is immutable and trusted.
Before fix
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ github.event.pull_request.head.ref }}-${{ hashFiles('**/package-lock.json') }}
After fix
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ github.sha }}-${{ hashFiles('**/package-lock.json') }}
Note: Auto-fix for path input is not supported because the appropriate path depends on project structure. Users should manually replace untrusted paths with static or trusted values.
Mitigation Strategies #
For Indirect Cache Poisoning #
- Validate Cached Content: Verify integrity of restored cache before use
- Scope Cache to PR: Use PR-specific cache keys to isolate caches
- Isolate Workflows: Separate untrusted code execution from privileged operations
- Use Safe Checkout: Avoid checking out PR code in workflows with untrusted triggers and caching
For Direct Cache Poisoning #
- Use Immutable Identifiers: Use
github.shainstead of branch names or other mutable references - Use Content Hashing: Use
hashFiles()for content-based cache keys - Avoid User-Controllable Values: Never use values from PR titles, bodies, comments, or labels in cache keys
- Use Static Paths: Use fixed paths for cache storage, not user-provided values
Safe cache key patterns:
# Good: Using github.sha (immutable)
key: cache-${{ github.sha }}
# Good: Using hashFiles for content-based caching
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Good: Using static values with trusted contexts
key: build-${{ runner.os }}-${{ runner.arch }}
Unsafe cache key patterns (avoid):
# Bad: Using branch ref (attacker can create malicious branch)
key: cache-${{ github.head_ref }}
# Bad: Using PR title (attacker controls this)
key: cache-${{ github.event.pull_request.title }}
# Bad: Using any user-provided input
key: cache-${{ github.event.comment.body }}
Cache Hierarchy Exploitation #
GitHub Actions caches are scoped by branch - PRs can read caches from their base branch. This creates a risk where attackers can poison the default branch cache, affecting all downstream PRs.
Attack Scenario #
- Attacker triggers workflow_dispatch: Manually triggers a workflow on the default branch
- Poisoned cache is written: Malicious content is cached under the default branch scope
- PRs read poisoned cache: All subsequent PRs inherit the poisoned cache from the base branch
- Supply chain compromise: Malicious code executes in PR builds
Detection Conditions #
The rule detects two patterns:
Pattern 1: External trigger + push to default branch
on:
workflow_dispatch: # External trigger - can be triggered by attackers
push:
branches: [main] # Writes to default branch cache
jobs:
build:
steps:
- uses: actions/cache@v3 # WARNING: Cache hierarchy exploitation risk
Pattern 2: External trigger only (no push filter)
on:
schedule:
- cron: '0 0 * * *' # Runs on default branch
jobs:
build:
steps:
- uses: actions/cache@v3 # WARNING: Writes to default branch cache
Example Output #
$ sisakulint ./workflow.yaml
./workflow.yaml:10:9: cache hierarchy exploitation risk: workflow with external triggers
(workflow_dispatch, push) and push to default branch can be exploited to poison caches.
Attacker can trigger workflow_dispatch/schedule to write malicious cache that all PRs will read.
Consider using PR-scoped cache keys or separate workflows [cache-poisoning]
Mitigation Strategies for Cache Hierarchy Exploitation #
Use immutable cache keys: Include
github.shain cache keyskey: build-${{ runner.os }}-${{ github.sha }}Separate workflows: Use different workflows for external triggers and PR builds
Restrict workflow_dispatch: Limit who can trigger workflows manually
Use PR-scoped cache keys: Include PR number in cache keys for PR builds
key: build-${{ runner.os }}-pr-${{ github.event.pull_request.number }}
Cache Eviction Risk #
GitHub repositories have a 10GB cache limit. When this limit is exceeded, older caches are evicted using LRU (Least Recently Used) policy. Attackers can exploit this by flooding the cache to evict legitimate caches.
Attack Scenario #
- Attacker identifies cache-heavy workflow: Finds workflows using multiple cache actions
- Floods cache storage: Creates many cache entries to fill the 10GB limit
- Legitimate caches evicted: Important build caches are removed
- Build performance degraded: CI/CD pipelines slow down significantly
- Potential security impact: Developers may disable caching, leading to other vulnerabilities
Detection Conditions #
The rule warns when a workflow uses 5 or more cache actions, indicating potential vulnerability to cache flooding attacks.
jobs:
build:
steps:
- uses: actions/cache@v3 # Cache 1
- uses: actions/setup-node@v4
with:
cache: 'npm' # Cache 2
- uses: actions/setup-python@v5
with:
cache: 'pip' # Cache 3
- uses: actions/cache@v3 # Cache 4
- uses: actions/cache@v3 # Cache 5 - WARNING triggered
Example Output #
$ sisakulint ./workflow.yaml
./workflow.yaml:1:1: cache eviction risk: workflow uses 5 cache actions.
Multiple caches increase risk of cache flooding attacks where attackers fill
the 10GB repository limit to evict legitimate caches. Consider consolidating
caches or using cache-read-only for non-critical jobs [cache-poisoning]
Mitigation Strategies for Cache Eviction Risk #
Consolidate caches: Combine multiple caches into fewer, larger caches
Use cache-read-only: For non-critical jobs, only read caches without writing
- uses: actions/cache/restore@v3 # Read-only cacheImplement cache cleanup: Regularly clean up old or unused caches
Monitor cache usage: Set up alerts for abnormal cache growth
Use branch-specific limits: Scope cache keys to limit blast radius
Detection Strategy and CodeQL Compatibility #
This rule is based on CodeQL’s actions-cache-poisoning-direct-cache query but implements additional detection capabilities:
Conservative Detection Approach #
sisakulint uses a conservative detection strategy for maximum security:
- Direct patterns: Detects explicit PR head references like
github.head_refandgithub.event.pull_request.head.sha - Indirect patterns: Detects step outputs that may contain PR head references (e.g.,
steps.*.outputs.head_sha) - Unknown expressions: Any unknown expression in
refwith untrusted triggers is treated as potentially unsafe
This conservative approach may result in some false positives but ensures that subtle attack vectors are not missed.
Differences from CodeQL #
| Aspect | CodeQL | sisakulint |
|---|---|---|
| Detection scope | Explicit patterns only | Explicit + indirect + unknown expressions |
| Label guards | Considers if: contains(labels) as safe | Reports warning (conservative) |
| Multiple checkouts | May not handle correctly | Resets state on safe checkout |
| Step outputs | Limited detection | Comprehensive pattern matching |
Example difference: CodeQL may consider workflows with label guards safe, but sisakulint still reports warnings because label-based protection depends on operational procedures that may fail.
OWASP CI/CD Security Risks #
This rule addresses CICD-SEC-9: Improper Artifact Integrity Validation and helps mitigate risks related to cache manipulation in CI/CD pipelines.
See Also #
- CodeQL: Cache Poisoning via Caching of Untrusted Files
- GitHub Actions Security: Preventing Pwn Requests
- OWASP CI/CD Top 10: CICD-SEC-9
- The Monsters in Your Build Cache - GitHub Actions Cache Poisoning - Detailed analysis of cache hierarchy exploitation
