Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Secure CI/CD pipeline: gates, secrets, signing

By updated

Improve this page

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.

PillarWhat it preventsDedicated lesson
Security gatesShipping a flaw, a secret or a vulnerable dependencythis lesson
SecretsExposing a credential to a job that does not need itSecrets in pipelines
IsolationA compromised job reaching the rest of the systemMinimal permissions
SigningDeploying a substituted artifactSigning 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.

GateTransitionWhat it checksWhat it blocks
Pre-commitBefore the commitSecretsThe commit, on the workstation
Pull requestBefore the mergeSecrets, SAST, SCAThe merge into main
BuildBefore publicationImage scan, signingThe publication of the artifact
DeploymentBefore productionVerified signature, DAST in stagingThe 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.

FindingPull requestDeployment
Secret detectedBlocksBlocks
SAST with ERROR severityBlocksBlocks
CVE listed in the KEV or CRITICALBlocksBlocks
HIGH CVEWarns in the PRBlocks, unless a dated exception
Injection confirmed by DASTnot applicableBlocks
Unsigned imagenot applicableBlocks

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 findingsTo block
Gitleaks 8.30.11nothing to add
Semgrep 1.178.00--error, with --severity ERROR for the threshold
Trivy 0.74.00--exit-code 1 and --severity
Grype 0.119.00--fail-on <severity>, which exits with 2
ZAP 2.17.02 on a mere WARNrules file and -I
Nuclei 3.11.10, alwaystest the file written by -o
cosign verify 3.1.110 without a valid signaturenothing 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.

.github/workflows/security-gates.yml
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 production environment 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.

AxisMeasureEffect
Permissionspermissions: {} then rights per jobA stolen token only allows what the job did
RunnerEphemeral runner, recreated for each jobA compromise does not survive the job
NetworkRunner with no route to the internal networkThe job does not reach production
EnvironmentSecrets and accounts separated per environmentA 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.

Terminal window
IMG=localhost:5055/api-facturation:1.0.0
cosign verify --key cosign.pub --insecure-ignore-tlog "$IMG"

Measured with cosign 3.1.1, in three steps:

Situationcosign verify codeMessage
Image signed by the pipeline0The signatures were verified against the specified public key
Another image pushed under the same tag10Error: no signatures found
Verification by the original digest, after the substitution0the 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.

Image substitution under the same tag: the pipeline builds then signs the original image, with digest A; an attacker with write access to the registry pushes a trapped image, with digest B, without a signature; the 1.0.0 tag, which designated the original image, designates the trapped image after the substitution. cosign verify on the tag exits with code 10 and refuses the deployment, cosign verify on digest A exits with code 0.beforeafter substitutionPipeline: buildthen cosign signAttacker with writeaccess to the registryOriginal imagedigest A, signedTrapped imagedigest B, unsignedTag 1.0.0cosign verify on the tagcode 10: refusedcosign verify on digest Acode 0: deployable
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

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 0 by 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 and env:.
  • cosign verify exits 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

6 questions
6 min
70% to pass

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

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.

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn