A secure CI/CD pipeline is not a pipeline that runs scanners: it is a
pipeline that stops when a scanner finds what the team has decided to
block. This lesson assembles the four pillars of this defence, security
gates, secrets, isolation and signing, for a DevOps engineer who
already has a working pipeline. It rests on measurements: the exit codes of
the scanners seen in the previous module, which show that half of them block
nothing by default, a gates workflow run through three workflow scanners,
and an image replaced under the same tag that cosign verify refuses.
What you will learn
- Explain why the pipeline concentrates what an attacker is looking for
- Place the security gates at the transitions of the pipeline, and decide their thresholds
- Make each scanner block, based on its measured exit code
- Write a gates workflow validated by actionlint, zizmor and poutine
- Limit what a compromised job can reach: secrets, permissions, runners
- Refuse at deployment an image that nobody signed
Why is the pipeline a target?
A pipeline turns source code into a deployed artifact, and to do so it brings together three things that an attacker otherwise looks for separately: secrets (deployment tokens, cloud credentials), privileges (publishing a package, pushing an image, deploying) and automatic execution on every commit. Compromising it means getting your own code signed and shipped by the victim's chain.
The tj-actions/changed-files attack, in March 2025, showed it bluntly: by moving the version tags of an action, the attacker had the secrets of more than 23,000 repositories printed in their logs. No test failed, no application scanner went off: the threat did not come from the code, but from what the pipeline runs and what it exposes. The previous lesson demonstrated it on a moved tag; this one builds the defence.
| Pillar | What it prevents | Dedicated lesson |
|---|---|---|
| Security gates | Shipping a flaw, a secret or a vulnerable dependency | this lesson |
| Secrets | Exposing a credential to a job that does not need it | Secrets in pipelines |
| Isolation | A compromised job reaching the rest of the system | Minimal permissions |
| Signing | Deploying a substituted artifact | Signing and verifying with Cosign |
The four pillars are inseparable. Perfect gates do not prevent a secret from leaking into the logs, and an image signed by a pipeline without gates is a certified flaw. The weakest link sets the strength of the whole.
Where do you place the security gates?
A security gate is an automated decision point: if the condition is not met, the pipeline stops and nothing moves on to the next stage. You place one at each transition, like successive airlocks, so that a control that is bypassed or misconfigured is caught by the next one.
| Gate | Transition | What it checks | What it blocks |
|---|---|---|---|
| Pre-commit | Before the commit | Secrets | The commit, on the workstation |
| Pull request | Before the merge | Secrets, SAST, SCA | The merge into main |
| Build | Before publication | Image scan, signing | The publication of the artifact |
| Deployment | Before production | Verified signature, DAST in staging | The deployment |
Each gate has its reason to exist. The pre-commit gives feedback within a
few seconds, but can be bypassed with --no-verify: it serves the
developer's comfort. The pull request is the first control that nobody
bypasses. The build sees the real artifact, base image layers included.
The deployment checks that what goes to production is indeed what the
pipeline produced.
Deciding the thresholds
Blocking on every alert, false positives and minor severities included, makes the teams lose trust, and they end up bypassing or disabling the gate. Blocking nothing makes the gate decorative. The threshold is therefore decided per type of finding, and written down in black and white: a secret always blocks, because it is binary; a CVE depends on its exploitation and on the exposure of the component, as the lesson on CVEs showed.
| Finding | Pull request | Deployment |
|---|---|---|
| Secret detected | Blocks | Blocks |
| SAST with ERROR severity | Blocks | Blocks |
| CVE listed in the KEV or CRITICAL | Blocks | Blocks |
| HIGH CVE | Warns in the PR | Blocks, unless a dated exception |
| Injection confirmed by DAST | not applicable | Blocks |
| Unsigned image | not applicable | Blocks |
Start strict on this reduced table, and widen it as the team matures. An exception is documented with an expiry date, otherwise it becomes invisible debt.
A gate is an exit code
The runner does not read a scanner's report: it reads its exit code. A
scanner that finds flaws and exits with 0 lets the job pass, whatever
its report contains. Yet, measured on the tools of the previous module, half
of them exit with 0 by default even when they find something.
| Tool (measured version) | By default, with findings | To block |
|---|---|---|
| Gitleaks 8.30.1 | 1 | nothing to add |
| Semgrep 1.178.0 | 0 | --error, with --severity ERROR for the threshold |
| Trivy 0.74.0 | 0 | --exit-code 1 and --severity |
| Grype 0.119.0 | 0 | --fail-on <severity>, which exits with 2 |
| ZAP 2.17.0 | 2 on a mere WARN | rules file and -I |
| Nuclei 3.11.1 | 0, always | test the file written by -o |
| cosign verify 3.1.1 | 10 without a valid signature | nothing to add |
The threshold is part of the gate. Measured with Trivy on a
requirements.txt that only contains requests==2.25.1: 4 MEDIUM
vulnerabilities. Without options, the code is 0. With
--severity HIGH,CRITICAL --exit-code 1, it is still 0, which is intended:
nothing exceeds the threshold. With --severity MEDIUM --exit-code 1, it
moves to 1 and the job fails. Always test a gate both ways: on a
repository that must pass, and on a repository that must be refused.
A complete gates workflow
The workflow below assembles the pull request gates, then signs the image
on a push to main. Each scanner runs in its official image pinned by
digest, with the option that makes it block, and the actions are pinned
by commit SHA. The build job only starts if the three gates have
succeeded. The step names keep the French of the measured file.
name: Security gates
on: pull_request: branches: [main] push: branches: [main]
permissions: {}
jobs: secrets: runs-on: ubuntu-24.04 permissions: contents: read container: image: ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Secrets dans l'historique run: gitleaks git --redact --exit-code 1 .
sast: runs-on: ubuntu-24.04 permissions: contents: read container: image: semgrep/semgrep:1.178.0@sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Analyse statique, bloquante sur ERROR run: semgrep scan --config p/security-audit --severity ERROR --error --metrics=off .
sca: runs-on: ubuntu-24.04 permissions: contents: read container: image: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Dépendances, bloquant sur CRITICAL run: trivy fs --scanners vuln --severity CRITICAL --exit-code 1 .
build-sign: if: github.event_name == 'push' needs: [secrets, sast, sca] runs-on: ubuntu-24.04 permissions: contents: read packages: write id-token: write outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Build et push id: push env: IMAGE: ghcr.io/${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTOR: ${{ github.actor }} run: | echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin docker build -t "$IMAGE:$GITHUB_SHA" . docker push "$IMAGE:$GITHUB_SHA" digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$GITHUB_SHA" | cut -d@ -f2) echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Signature keyless, par digest env: IMAGE: ghcr.io/${{ github.repository }} DIGEST: ${{ steps.push.outputs.digest }} run: cosign sign --yes "$IMAGE@$DIGEST"Three choices deserve a close reading. permissions: {} at workflow level
removes all the default rights, and each job only gets back its own: only
build-sign can write a package and obtain an OIDC token
(id-token: write), which cosign needs for keyless signing. Values coming
from the GitHub context go through env:, never directly into run:, which
shuts the door on template injection. Finally, the signature targets the
image's digest, not its tag, for the reason measured further down.
Measured on this file: actionlint 1.7.12 exits with 0, zizmor 1.30.0
answers No findings to report (its 6 pedantic remarks, missing names and
missing comments on permissions, are hidden by default), and poutine 1.1.6
passes all its rules. This is not a security guarantee, it is the assurance
that the workflow reproduces none of the known defects of these tools. Run
them on your own workflows, starting with
zizmor.
Protecting the pipeline's secrets
Gates stop a dangerous delivery, they do not protect the secrets the pipeline holds while it runs. Three rules limit what a compromised job can steal, and the next lesson details them:
- a secret is never written in the code or in the workflow, only
referenced (
${{ secrets.NAME }}), and it is only exposed to the step that uses it, not to the whole workflow; - each environment has its own secrets, and the
productionenvironment requires an approval before releasing them; - an ephemeral token obtained through OIDC replaces the long-lived cloud key: there is nothing left to steal once the job is over.
The Shai-Hulud worm, in 2025, illustrated what is at stake: its install script looked for GitHub, npm and cloud tokens in the environment of the machines where it ran, then republished itself with the stolen accounts. A job that holds no unnecessary secret offered it nothing.
Limiting what a compromised job can reach
You start from the assumption that a job will be compromised one day, through an action, a dependency or a trapped pull request. Isolation then decides the extent of the damage, the "blast radius". It plays on four axes, which do not replace one another.
| Axis | Measure | Effect |
|---|---|---|
| Permissions | permissions: {} then rights per job | A stolen token only allows what the job did |
| Runner | Ephemeral runner, recreated for each job | A compromise does not survive the job |
| Network | Runner with no route to the internal network | The job does not reach production |
| Environment | Secrets and accounts separated per environment | A leak in test does not affect production |
GitHub-hosted runners are ephemeral: a fresh machine per job, destroyed afterwards. A permanent self-hosted runner, on the other hand, keeps from one job to the next files, caches and processes: a trapped job can leave behind something to attack the next one. Keep them for real needs, in ephemeral mode, and never for pull requests coming from forks.
Refusing a substituted image at deployment
Signing answers a question that gates do not ask: is the image going to production the one the pipeline built? An attacker who obtains write access to the registry can push another image under the same tag. The measurement can be replayed with a local registry and a cosign key pair; in CI, keyless signing replaces the key pair with the workflow's OIDC identity, detailed in the Cosign lesson.
IMG=localhost:5055/api-facturation:1.0.0cosign verify --key cosign.pub --insecure-ignore-tlog "$IMG"Measured with cosign 3.1.1, in three steps:
| Situation | cosign verify code | Message |
|---|---|---|
| Image signed by the pipeline | 0 | The signatures were verified against the specified public key |
| Another image pushed under the same tag | 10 | Error: no signatures found |
| Verification by the original digest, after the substitution | 0 | the original image remains verifiable |
The deployment gate therefore fits in a single command, whose code 10
stops the pipeline. The third row says how to deploy: by digest, not by
tag. The tag changed content before your eyes; the digest designates one
image and one only. The --insecure-ignore-tlog option is only there
because the bench signs without a public transparency log; in CI, keyless
signing is recorded in Rekor and verification consults it.
The diagram sums up the substitution: the tag 1.0.0 used to
designate the signed image, it now designates the trapped image, and only
verification by digest finds the original.
Signature and provenance attestation
A signature proves that the image comes from the pipeline and has not been modified. It says nothing about how it was built: a Dockerfile trapped before the build produces a trapped image, correctly signed. The provenance attestation adds this information, repository, commit, workflow and runner, signed along with the artifact. The SLSA framework defines its levels, from L0 to L3 on the Build track; aiming for L2 is achievable with the native tools of GitHub Actions. The module on the supply chain devotes the lesson from SLSA provenance to the decision to it.
Secure pipeline checklist
The checklist reads in tiers: the first can be set up in a few days on an existing pipeline, the last requires heavier tooling and organisation. Each box matches a control seen in this lesson or in the lessons of the module.
Foundation, right away:
- Each scanner blocks on a written threshold, checked through its exit code
- The gates are declared required in the protection of
main -
permissions: {}at workflow level, minimal rights per job - Actions and images pinned by SHA or digest
Next:
- Secrets exposed only to the step that uses them, OIDC instead of cloud keys
- Workflows run through zizmor, actionlint and poutine in CI
- Signed images, and signature verified at deployment, by digest
Advanced maturity:
- SLSA L2 or higher provenance attestations
- SBOM published with each artifact
- Ephemeral self-hosted runners isolated from the internal network
Key points
- The pipeline concentrates secrets, privileges and automatic execution: that is what makes it a target.
- A gate sits at each transition and blocks on a decided, written threshold.
- The runner only reads the exit code: Semgrep, Trivy, Grype and Nuclei exit with
0by default even with findings. - A gate is tested both ways: a repository that must pass, a repository that must be refused.
- The measured gates workflow passes actionlint, zizmor and poutine without findings, thanks to
permissions: {}, the SHA andenv:. cosign verifyexits with 10 on an image substituted under the same tag: deploy by digest.- A gate that is not required in branch protection prevents no merge.
Knowledge check
Check that you have the essentials of this lesson. The questions only cover what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Hardening the build environment: securing runners and pipelines
A new full quiz with random questions
Work again only on the questions you missed
FAQ: frequently asked questions
The questions below cover the most common doubts about building a secure pipeline: where to start, what to block, and what sets a signature apart from an attestation.