CI/CD Pipeline Security Explained

CI/CD Pipeline Security Explained

Written by

in

Your build and deploy pipeline is one of the most valuable targets in your whole stack, and it rarely gets the attention it deserves. Good CI/CD pipeline security matters because the pipeline holds your secrets, runs with broad permissions, and ships output that every downstream system trusts on sight. This post walks the main risk areas at a survey level, gives one concrete example for each, and names the defense, then points you to the deep dives that go all the way down.

Why CI/CD pipeline security is a high value target

Think about what a build job can touch. It reads your private source code. It holds tokens that push to your registry, deploy to production, and comment on pull requests. It signs releases. Then it produces an artifact, a container image or a package, that your servers pull and run without asking a single question. An attacker who lands inside a build gets all of that at once.

The trust is the real prize. If someone slips a change into what your pipeline outputs, every machine that pulls that output runs the change for them. No phishing, no lateral movement, just one poisoned build that fans out everywhere.

The pipeline is the shortest path from one line of attacker code to every server you own, because everything downstream already trusts what it produces.

The good news is that the risk areas are well understood, and each one has a plain defense. Here they are, one at a time.

Untrusted input running in a privileged build

The most common opening is a pull request from outside your team. Many pipelines build and test pull requests automatically, which means code you did not write runs on your infrastructure the moment it is proposed. If that build has access to secrets, a contributor can add one line to a build script and read them.

Here is the shape of the problem. A workflow runs a script the pull request itself controls:

on: pull_request
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/test.sh   # this file comes from the fork

Whoever opened the pull request wrote test.sh. If the job can see a deploy token, their script can print it, encode it, and send it out. This class of bug is called poisoned pipeline execution, and it is worth understanding on its own. The defense: run untrusted pull requests in a job with no secrets and read only permissions, and require a human to approve before any privileged step touches fork code. Read the full walkthrough in poisoned pipeline execution.

Secrets exposed to jobs

Pipelines run on secrets, so secrets end up scattered across them: registry passwords, cloud keys, signing tokens. The mistake is handing every job the whole set. A test job that only needs to run unit tests does not need production deploy keys, but it often gets them because the secrets sit at the top of the file for all jobs to share.

A single leak makes this concrete. A build step logs its environment for debugging:

- run: env | sort   # prints AWS_SECRET_ACCESS_KEY into the build log

Now the key sits in a log that many people can read, and logs are easy to forget. The defense: scope each secret to the one job that needs it, never the whole workflow, and mask secrets in logs. Rotate anything that a build has ever printed. A related trap is secrets that were committed to the repository long ago and are still sitting in the git history where a build, or anyone with clone access, can find them. That is its own topic in secrets in git history.

Dependency and package risk entering the build

Every build pulls in code from outside. Your package.json or requirements.txt lists direct dependencies, and each of those drags in more, until a small app installs hundreds of packages nobody on your team has read. Any one of them runs during the install, which is exactly when a malicious package strikes.

The classic version uses a lifecycle script. A package defines an install hook that runs on its own:

"scripts": {
  "postinstall": "node exfil.js"
}

The moment you run npm install, that script executes with whatever access the build has. No import, no call, just installing the package is enough. The defense: pin dependencies to exact versions with a lockfile so a package cannot change under you, review new additions, and consider disabling install scripts where you can. The mechanics are laid out in malicious npm lifecycle scripts.

Self-hosted runner exposure

Hosted runners are fresh and thrown away after each job. A self-hosted runner, a machine you own that picks up build jobs, is different. It is long lived, it may sit inside your network, and if you are not careful it reuses state between jobs. That combination is dangerous when it also builds untrusted pull requests, because one job can leave something behind for the next one to find, or reach systems that should never be reachable from a build.

Picture a runner in your office network that builds public pull requests. A contributor’s build runs a quick scan:

- run: curl http://10.0.0.5:8500/v1/kv/?recurse   # internal service, now reachable

A machine that should only compile code just read an internal key value store. The defense: keep self-hosted runners off untrusted pull requests, isolate them from your internal network, and treat each job as disposable by rebuilding the environment every time. Ephemeral runners that are destroyed after one job remove most of this risk.

The trust placed in build artifacts

The last area is the output itself. When your pipeline pushes an image tagged latest to a registry, your servers pull it and run it. Nothing checks that the image came from a clean build of the code you think it did. If an attacker can push to that registry, or tamper with a build, the bad artifact inherits all the trust of a good one.

A concrete gap: your deploy pulls registry.example/app:latest by tag. Two builds can produce that same tag, and the servers cannot tell a real one from a swapped one. The defense: generate provenance for every build, a signed record of what was built, from which commit, by which pipeline, and verify that record before you deploy. Pin to content digests, not moving tags, so you always run the exact bytes you meant to.

Putting it together

None of these defenses are exotic. Least privilege tokens, secrets scoped to one job, pinned dependencies, isolated runners, and signed provenance are all things you can start on this week. The reason they get skipped is that a pipeline feels like plumbing, not like an attack surface, right up until it is the thing that gets used. Treat it as production, because it has production’s keys.

Each area above is a survey. The real detail lives in the dedicated posts, and the platform specific traps in GitHub Actions security are worth a full read if that is where you build. For the longer teardowns of how these bugs actually get exploited and fixed, browse our deep dives.

Pipelines break in exactly the way UnboundCompute is built to study: not a known payload, but an assumption the system quietly trusts, like a build believing its input is safe. That is the kind of gap an autonomous researcher that tests assumptions goes looking for, and you can read how we think about it on our about page.

Frequently asked questions

What is CI/CD pipeline security?

CI/CD pipeline security is the practice of protecting the systems that build, test, and deploy your software. The pipeline holds secrets, runs with broad permissions, and produces artifacts that every downstream server trusts, so a single break can spread everywhere. The main work is limiting what each job can touch, keeping untrusted input away from privileged steps, and verifying what the pipeline ships.

Why is a build pipeline such a valuable target?

A build job reads your private source, holds tokens that deploy to production and push to your registry, and produces output that servers pull and run without checking. An attacker who lands inside one build gets all of that at once, and because everything downstream trusts the output, one poisoned build can fan out to every machine that pulls it.

How do pull requests put a pipeline at risk?

Many pipelines build and test pull requests automatically, which runs code you did not write on your infrastructure. If that job can see secrets, a contributor can add one line to a build script and read them. The fix is to run untrusted pull requests with no secrets and read only permissions, and to require human approval before any privileged step touches fork code.

What are the main defenses for CI/CD pipeline security?

Give each job the least privilege it needs, scope secrets to the single job that uses them, pin dependencies to exact versions with a lockfile, isolate self-hosted runners and rebuild them every job, and generate signed provenance for artifacts so you can verify what you deploy. None of these are exotic, and you can start on all of them this week.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.