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

Securing a CI/CD pipeline: the four control surfaces

By updated

Improve this page

Securing a CI/CD pipeline means reducing what it can do once compromised, across four surfaces: identities, dependencies, runners and artifacts. A pipeline holds secrets to production, writes the artifacts your users install, and runs third-party code nobody reviews. This lesson is for anyone who writes or maintains pipelines without being a security specialist. It gives a model that holds whatever the tool, GitHub Actions, GitLab CI or Jenkins, and points to the lessons that put it into practice.

What you will learn

  • Explain why a pipeline is a more profitable target than a server
  • Identify the four control surfaces and the risk specific to each
  • Apply least privilege, pinning and runner isolation
  • Tell a structural control from a point-in-time control
  • Restrict the third-party code allowed to run and trace the CI's configuration changes
  • Place a pipeline in a four-level maturity model

Why is a pipeline a target?

A CI/CD pipeline (continuous integration and delivery) is the system that tests, builds and deploys your code without human intervention. To do that job, it receives broad rights: read the code, publish artifacts, connect to production. An attacker who controls it inherits all those rights at once, without ever touching your servers.

That is the difference with a classic attack. Compromising a server gives access to that server; compromising the pipeline gives access to everything it ships, on every run. The usual picture is the water supply: you do not poison each glass, you poison the reservoir, and the poison reaches everyone.

What an attacker can obtain

The table below links each resource the pipeline handles to what an attacker would do with it. Read it as a list of what to protect: each row matches a control surface covered further down, and the last column shows that the impact always reaches beyond the pipeline itself.

ResourceWhat the attacker can doConcrete example
SecretsSteal API tokens, SSH keys, cloud credentialsUse your own keys on your cloud account
Source codeModify the code before compilationAdd a backdoor invisible to review
ArtifactsReplace a deliverableYour users install a tampered version
ProductionDeploy directlyTake over your servers

The tj-actions/changed-files case, March 2025

The most instructive attack of recent years hit a GitHub action used by more than 23,000 repositories. On 14 and 15 March 2025, its version tags were moved to a malicious commit (CVE-2025-30066). That code read the runner memory, extracted the secrets, and wrote them base64-encoded into the workflow logs.

On a public repository, those logs are readable by anyone: the secrets were exposed without contacting any external server. The affected teams had changed nothing, since they referenced the action by tag, and that tag had moved under their feet. Only the repositories that pinned the action by SHA did not run the malicious code. The fix shipped in version 46.0.1.

What are the four control surfaces?

Pipeline security plays out on four surfaces, and each one matches a trust decision that must be explicit and verifiable. The table sums up the question to ask for each one and what happens when it is ignored. The following sections cover them one by one, from the simplest to set up to the most structural.

SurfaceKey questionRisk if neglected
IdentitiesWho may do what, and for how long?Excessive access, stolen secrets
DependenciesWhich external code do we trust?Malicious code executed
RunnersWhere does the code run, and what does the machine keep?Contamination between jobs
ArtifactsHow do we prove where the deliverable comes from?Shipping a tampered deliverable

Surface 1: identities and permissions

The question is not whether the pipeline must authenticate, but with what and for how long. It connects to an image registry, a cloud provider, a deployment tool, and each connection relies on a credential. A long-lived secret stored in CI remains valid after its author leaves, and after the leak that exposed it.

Least privilege, job by job

Each job receives only the permissions strictly needed for its task. On GitHub Actions, the automatic token is set by the permissions block: empty it at workflow level, then grant rights job by job. A compromised test job can then neither push code nor publish a package.

# ❌ Excessive permissions: a compromised job can do anything
permissions: write-all
# Minimal permissions: nothing by default, then the bare minimum per job
permissions: {}
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read # read the code, without being able to change it
packages: write # publish a package, nothing else

Ephemeral tokens rather than permanent secrets

An ephemeral token expires on its own after a few minutes; a static secret stays valid until someone revokes it, which is often forgotten. The OIDC protocol (OpenID Connect) lets the pipeline ask the cloud provider for a temporary token on each run, instead of storing a permanent key. The table shows what this choice changes on the day of a leak.

PropertyStatic secretEphemeral token (OIDC)
LifetimeUnlimitedA few minutes
If stolenUsable until detectedExpires quickly
RevocationManual, often forgottenAutomatic

The implementation on GitHub Actions is covered in the lesson OIDC with GitHub Actions.

One secret per environment

Staging and production secrets must be distinct, and stored in protected environments of the platform. A leak on the staging side then gives no access to production. Before configuring a job's access, four questions are enough: must this token write, or only read? Is OIDC possible? What do we lose if this secret is stolen? When was it last rotated?

Surface 2: dependencies

The code you write is only a fraction of what runs. Every npm install, every pip install, every third-party action runs code from maintainers you do not know, with your pipeline's permissions. The goal is to make what the pipeline installs reproducible, and what changes detectable.

Pin by SHA, not by tag

A tag such as @v4 is mutable: its maintainer, or an attacker who took over their account, can move it to another commit. A commit SHA is immutable: it always designates exactly the same code. That is precisely what protected the pinned repositories during the tj-actions attack.

# ❌ Mutable tag: may point to different code tomorrow
- uses: actions/checkout@v4
# Immutable commit SHA, with the version as a comment for readability
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

The SHA to keep is the commit one, not the tag object one. The command git ls-remote --tags https://github.com/actions/checkout v7.0.1 gives it; for an annotated tag, take the line suffixed with ^{}, which designates the commit pointed to. The full method is in the lesson Pin actions by SHA.

Decide which third-party code may run

Pinning answers the question "will this code change?", not "am I allowed to run it?". An allowlist set at the organisation level closes the second one: only explicitly admitted actions run, whatever workflow calls them, including a workflow added by a compromised account. On GitHub, the "allow the organisation and a selection" policy accepts patterns such as actions/* or sigstore/cosign-installer@*, and only exists at the level of an organisation.

It is a structural control: it applies without depending on the YAML of each repository, which any contributor can modify. The two controls complement each other without replacing one another: an allowed action pinned by tag stays exposed to a rewrite, a pinned action that is not allowed should not run at all. The lesson Restricting allowed actions details the syntax.

Commit the lock files

A lock file (package-lock.json, poetry.lock) records the exact version of each dependency, including transitive dependencies. The pipeline then installs it as is, with npm ci rather than npm install, and produces the same result on every run. The table compares the two situations.

Without a lock fileWith a lock file
npm install takes the latest versionsnpm ci installs exactly the recorded versions
Different result from one run to the nextIdentical result on every run
An update lands without reviewUpdates go through review

Audit before adding

A new dependency is a trust decision, not a technical detail. Before adding it, check who maintains it, for how long, how many projects use it, and whether its code is readable. A single maintainer is a risk, as is a recent package or obfuscated code. The OpenSSF Scorecard tool automates part of these checks.

CriterionWhy it matters
Who maintains it?A single maintainer is a breaking point
For how long?A recent dependency has not proven itself
How many projects use it?Few users, few eyes to spot a problem
Is the code readable?Obfuscated code may hide something

Surface 3: runners and isolation

The runner is the machine, physical or virtual, that runs your jobs. Whatever it keeps from one run to the next, cache, credentials, temporary files, becomes reachable by the next job, including when that job comes from an external contribution. The goal is to remove that persistence, the main vector of contamination from one job to another.

Ephemeral runners

An ephemeral runner is created for one job, then destroyed: nothing survives the run. A persistent runner, on the contrary, keeps the state left by each job, and malicious code can settle there to wait for the next deployment. The table follows two successive jobs in each case; the lesson Ephemeral runners gives the implementation.

Persistent runnerEphemeral runner
Job 1 modifies filesJob 1 runs, the runner is destroyed
Job 2 inherits those modificationsJob 2 gets a fresh runner
Malicious code can persistNothing can persist

Strict network isolation

The runner only reaches the resources the job needs: the registry, the package repository, the deployment API. By default, it sees neither the internal network nor the company databases. A compromised job on an isolated runner can then neither explore the network nor exfiltrate data to an unexpected destination.

Keep external code away from secrets

When an external person opens a pull request from a fork, the code they propose runs on your runners. That code must never reach your secrets. On GitHub Actions, the pull_request trigger respects this rule: a workflow started from a fork receives neither secrets nor a write token.

A pull request from an external contributor runs on a runner without secrets, with read-only permissions and no deployment access.PR from an external contributorRunner WITHOUT secretsread-only permissions, no deployment access
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The trap comes from the pull_request_target trigger, which runs with the target repository's secrets. If it checks out and runs the pull request code, it hands those secrets to any contributor. The lesson Securing pull_request_target details the attack and the countermeasure.

Surface 4: artifacts and chain of trust

Between building an artifact and deploying it, nothing proves by default that it is the same content. An artifact is the pipeline's output: a container image, an executable, a package. Without a verifiable digest or a signature, a swap along the way is undetectable by whoever installs it.

Sign the artifacts

Signing an artifact means attaching cryptographic proof of its origin: if the artifact is modified after signing, verification fails. The consumer no longer has to trust the distribution channel, they verify for themselves. The reference tool for images is Cosign.

Unsigned artifactSigned artifact
Unknown originVerifiable origin
Uncertain integrityAny modification is detected
"Trust me""Check for yourself"

Provenance attestations and SBOM

A provenance attestation describes how the artifact was built: which commit, which pipeline, which environment. The SLSA (in French) framework sets its levels of assurance. An SBOM (Software Bill of Materials) is the full list of the artifact's components, like a list of ingredients.

These three proofs answer three different questions. The signature says who produced the artifact, the attestation says how, and the SBOM says what it contains. The day a vulnerability like Log4Shell is published, the SBOM tells you within seconds which artifacts are affected.

Structural or point-in-time control: which one to favour?

A structural control makes an action impossible by construction; a point-in-time control checks it at a given moment. The first is sturdier, because it depends neither on a step that can be removed nor on an alert that can be ignored. A vulnerability scan, a static analysis or a security test on a pull request are point-in-time controls: useful, but they can be bypassed, disabled, or simply forgotten.

The table gives five structural controls and what they make impossible. Point-in-time controls remain additional safety nets, not ramparts. The lesson Blocking a pipeline does not improve security shows what happens when we expect more from them than they can give.

Structural controlWhat it makes impossible
Minimal permissions by defaultGetting more rights than planned
Ephemeral runnersMaking malicious code persist
Signature required at deploymentDeploying an unverified artifact
OIDC instead of static secretsStealing a permanent credential
Allowlist of actionsRunning an unapproved third-party action

Who changed the pipeline? The audit log

The Git history traces workflow files, not the platform's settings. A replaced secret, a loosened actions policy, a self-hosted runner added to the organisation: none of this shows up in a commit. These changes live in the platform's audit log, and it is the audit log that answers, on the day of an incident, the question "who loosened this control, and when?".

The audit log prevents nothing: it is a detection and investigation control, worth something only if it is read. On GitHub, four events sum up what a security team should watch on the CI:

What changesGitHub event
The organisation's Actions policyorg.update_actions_settings
An organisation Actions secretorg.update_actions_secret
A new self-hosted runnerorg.register_self_hosted_runner
The configuration of a runner grouporg.runner_group_updated

Two limits, read in the GitHub documentation, decide how to use it. Only the organisation's owners can access it, and the interface only goes back 180 days. API access, which makes it possible to export it to a SIEM, is reserved for GitHub Enterprise Cloud: without it, keeping more than six months requires a regular manual export. On the GitLab side, audit events play the same role, as the lesson GitLab CI: audit, compliance and traceability (in French) details.

Where does your pipeline stand? A four-level maturity model

This model helps place a pipeline and choose the next step, not do everything at once. Each level assumes the previous one: signing artifacts built on a persistent runner with broad permissions protects little. Start with basic hygiene, which costs little and closes the most frequent attacks.

LevelGoalControls
1. Basic hygieneClose the most frequent attacksMinimal permissions per job, lock files with npm ci, no hard-coded secret
2. Pinning and isolationRun nothing that can change on its ownActions and dependencies pinned by SHA, allowlist of actions, ephemeral runners, one secret per environment
3. Verification and traceabilityProve what is shippedSigned artifacts, SBOM on every build, provenance attestations, audit log read and kept
4. Zero trustStop storing long-lived credentialsOIDC wherever possible, mandatory verification before deployment, runner monitoring

Key points

  • A compromised pipeline gives access to secrets, code, artifacts and production, on every run.
  • The four control surfaces are identities, dependencies, runners and artifacts.
  • Least privilege: permissions emptied at workflow level, then granted job by job.
  • Pin by commit SHA: the tj-actions attack of March 2025 moved tags, not SHAs.
  • Ephemeral runners and network isolation remove persistence from one job to the next.
  • Signature, provenance and SBOM say who produced the artifact, how, and what it contains.
  • Favour structural controls, which make the attack impossible, over point-in-time controls, which only observe it.
  • An organisation allowlist decides which third-party code runs; the audit log says who changed the settings that Git does not see.

Knowledge check

Check that the essentials of this lesson are in place. 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

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