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

Implicit trust in CI/CD pipelines

By updated

Improve this page

A CI/CD pipeline runs code it did not write: third-party actions, dependencies, downloaded scripts. Each of these elements is a trust decision, and most of them are made without anyone having actually decided. This lesson, which opens the module on pipelines, shows what this implicit trust costs and how to make it explicit. Two measurements carry it: a Git tag moved after the fact, which makes the same @v1 run different code, and a tutorial workflow where zizmor raises 7 findings without flagging the curl | bash it contains.

What you will learn

  • Define implicit trust and tell it apart from explicit trust
  • Locate the seven trust zones of a pipeline
  • Demonstrate that a tag does not guarantee the code that runs, unlike a SHA
  • Inventory the trust decisions of a workflow with zizmor, and its blind spots
  • Apply the five principles of explicit trust

What is implicit trust?

Implicit trust means granting an authorisation without having consciously decided to. In a pipeline, it takes the form of a third-party action used without reading its code, a dependency downloaded without checking its integrity, a script fetched from the Internet and run as is, or a secret available to every step of a job when only one of them needs it.

What makes it dangerous is its invisibility. Nobody decided to trust this component: it is there, inherited from a tutorial, copied from another project, added "because it worked". Explicit trust does the same thing, using an external component, but knowing the risk accepted and the person who accepted it.

Implicit trustExplicit trust
"We have always used this action""We have read this action and accepted its risks"
"It comes from a well-known maintainer""We verify the signature of every version"
"It is popular, so it is safe""We pin the SHA and monitor changes"
"The script is in our internal repository""The script is reviewed, and its provenance verifiable"

One question is enough to spot implicit trust: for each component of the pipeline, who decided to trust it, and on what criteria? If the answer is "nobody" or "I don't know", the decision exists all the same, but nobody is accountable for it.

Three incidents that exploited it

The three attacks below exploited no code flaw in the classic sense. They exploited the trust granted to an external component, and each one affects a different zone of the pipeline.

XZ Utils, 2024. Over several years, a contributor earned the trust of the maintainer of this compression library, before introducing a backdoor into versions 5.6.0 and 5.6.1, published as CVE-2024-3094 on 29 March 2024. The malicious code was in the release tarballs, not in the Git repository: reviewing the repository was not enough. It was spotted before wide distribution, and only development distributions received it, Fedora Rawhide and part of Fedora 40; no RHEL version was affected.

Polyfill.io, 2024. The cdn.polyfill.io domain, embedded by more than 100,000 sites according to Sansec, was bought in February 2024 along with its GitHub account. In June, the service was serving code that redirected some visitors to malicious sites. The affected sites had changed nothing: they were simply loading a script from a third-party domain.

tj-actions/changed-files, 2025. Between 14 and 15 March 2025, an attacker moved the version tags of this GitHub action, used by more than 23,000 repositories according to security advisory GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066), to a malicious commit. The added code read the runner's memory and printed the secrets into the workflow logs, readable by anyone on a public repository. Workflows pinned by commit SHA were not affected.

Can a tag change its content?

Yes, and that is the exact mechanism of the tj-actions attack. A Git tag is a label placed on a commit, which the repository owner can move. A commit SHA, on the other hand, is the fingerprint of the content: it cannot designate anything else. The demonstration can be replayed on a workstation, with a local repository playing the role of the action (the strings and commit messages are those of the measurement, hence their French wording).

Terminal window
# The maintainer publishes a clean action, tagged v1
printf 'echo "build propre"\n' > action.sh
git add action.sh && git commit -m "v1 propre" && git tag v1
git push origin main v1
# The maintainer's account is compromised: new commit, tag moved
printf 'echo "build propre"\nenv | base64 # exfiltration\n' > action.sh
git commit -am "maj" && git tag -f v1
git push origin v1 # refused: ! [rejected] v1 -> v1 (already exists)
git push -f origin v1 # accepted

Measured: without -f, Git refuses to move a tag that is already published, and the safeguard stops there, because a compromised account has push -f. Two consumers of the action then fetch their version. The one that follows the v1 tag now runs the exfiltration line; the one that pinned the SHA of the original commit still runs echo "build propre", and nothing else.

--- après : le tag v1 exécute
echo "build propre"
env | base64 # exfiltration
--- après : le SHA épinglé exécute
echo "build propre"

Nothing, on the consumer side, signals the change: the workflow still references @v1, the run goes green, and only the logs reveal what ran. That is why the pinning rule targets the commit SHA, never a tag, even a "stable" one.

What are the trust zones of a pipeline?

A pipeline brings together, in the same job, your code, its secrets and a series of external components. Each of them can change what the job runs, and therefore what it ships: a single compromised link is enough to produce a compromised artifact, signed by your own chain.

Trust zones of a pipeline: npm, PyPI and Go dependencies, third-party actions and plugins, and scripts downloaded through curl | bash feed the CI job directly, which holds the code and the secrets; the build cache and CDNs or external services feed it indirectly; the job produces the shipped artifacts and holds the tokens and identities, the most sought-after target.Dependenciesnpm, PyPI, GoThird-partyactions and pluginsDownloaded scriptscurl | bashBuildcacheCDNs andexternal servicesCI job:code + secretsTokens andidentitiesShippedartifacts
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The diagram reads from left to right, and each zone calls for its own question:

  1. Dependencies: each npm install or pip install runs third-party code, sometimes as early as installation. Who maintains them, and what happens if their account is stolen?
  2. Actions and plugins: they run with the permissions of the job and read its secrets. A compromised action can exfiltrate everything.
  3. Downloaded scripts: a curl | bash runs whatever the server serves on that day, with no version and no fingerprint.
  4. Cache: a poisoned cache reinjects malicious code into every build, even after the source has been fixed.
  5. CDNs and external services: the Polyfill.io case, a domain that changes owner and content.
  6. Tokens and identities: a stolen token gives access to everything it allows. If it is broad, so is the attacker.
  7. Artifacts: if the job is compromised, what it produces is too, and without a signature you cannot tell the trapped artifact from the legitimate one.

Inventorying the trust decisions of a workflow

Spotting every trust decision of a workflow by eye is tedious. zizmor, a static analyser for GitHub Actions workflows, automates a good part of it. The workflow below assembles lines that are common in tutorials: each one grants trust that nobody decided. The echo line keeps the French text of the measured file.

.github/workflows/ci.yml
# ❌ Tutorial workflow: each line grants implicit trust
name: CI
on:
push:
pull_request_target:
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- uses: tj-actions/changed-files@v45
id: changed
- run: curl -sSL https://example.com/install.sh | bash
- run: echo "Titre de la PR ${{ github.event.pull_request.title }}"
- run: npm install
- run: npm test
Terminal window
zizmor --offline .

Measured with zizmor 1.30.0: the scan exits with code 14 and reports 10 findings (3 suppressed), that is 7 findings displayed, 5 of high severity and 2 medium.

zizmor findingLine targetedImplicit trust
unpinned-uses (x3)checkout@v4, setup-node@v4, changed-files@v45The action's code can change under the same tag
dangerous-triggerspull_request_targetThe workflow runs with the secrets on PR code
template-injection${{ github.event.pull_request.title }} in runThe PR title becomes shell
excessive-permissionsthe build jobThe default token keeps broad rights
artipackedactions/checkoutThe Git token stays in .git/config

The table says as much through what it leaves out. Two implicit trust decisions of the workflow appear in no finding: the curl | bash, which runs an unversioned script, and the NPM_TOKEN declared at workflow level, and therefore exposed to every step, including the three third-party actions. A scanner reduces the review work, it does not replace it. The lesson dedicated to zizmor details its audits and its integration into CI.

Why does blind trust persist?

Despite repeated incidents, implicit trust remains the norm, because it relies on biases that everyone shares. Naming them helps recognise them in code review, at the moment someone justifies a @v4 or a curl | bash by habit.

BiasWhat we tell ourselvesWhat actually happens
Automation"It is automated, so it is reliable"The pipeline runs blindly, malicious code included
Familiarity"We have always done it this way"The absence of past incidents guarantees nothing
Illusion of control"The code is open source"Nobody reads every dependency
Popularity"Thousands of projects use it"The more it is used, the more massive the impact: 23,000 repositories for tj-actions
Time pressure"We will check after the release"The check never happens

How does an attacker exploit this trust?

Supply chain attacks follow a constant pattern, which the XZ Utils affair illustrates from start to finish. They are patient: the longest phase is not technical, it is building up trust.

  1. Identification: the attacker spots a widely used but poorly watched component, a lone maintainer, a utility project.
  2. Infiltration: they earn trust through useful contributions, or compromise the account of an existing maintainer.
  3. Injection: the malicious code is buried in a large change, obfuscated, or conditional, for example only active in CI.
  4. Propagation: pipelines automatically download the new version, and speed up its spread.
  5. Exploitation: secret exfiltration, a backdoor in the artifacts, persistent access.

Ideal targets share a few traits: a single or exhausted maintainer, a utility project less scrutinised than a major framework, elevated permissions on the system or the network, and little review of outside contributions.

The five principles of explicit trust

Moving from implicit to explicit trust does not mean doing without external components, but deciding consciously on each one. The five principles below sum up the approach; the lessons of the module provide tooling for several of them, from permissions to pinning.

Verify rather than assume. An external component is verified before use: its signature (is it signed by its legitimate author?), its provenance (can you trace it back to its source code?) and its integrity (has it been modified since it was published?).

Minimise permissions. Each step only accesses what it needs, following the principle of least privilege. A test job does not need the production secrets, and a formatting action does not need to write to the repository.

Pin by SHA. A tag such as @v4 accepts any future change in advance, as the demonstration above showed. The commit SHA, with the version in a comment for the reader, fixes what runs.

# ❌ Implicit trust: the tag can be moved
- uses: actions/checkout@v4
# Explicit trust: the commit is frozen, the version stays readable
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Monitor changes. An initial audit is not enough: you get alerted about new versions, you read their changes before updating, and you watch for abnormal behaviour in a job, such as an unexpected network connection.

Document decisions. When the team adopts a component, it writes down why this one, which alternatives were rejected, which risk is accepted and who monitors it. A written decision gets reread in review; an implicit decision never does.

Key points

  • Every action, dependency or script in a pipeline is a trust decision, often made by nobody.
  • XZ Utils, Polyfill.io and tj-actions exploited trust, not a code flaw.
  • A tag can be moved: measured, the same @v1 ran different code; only the commit SHA is frozen.
  • A pipeline has seven trust zones, from dependencies to shipped artifacts.
  • zizmor raises 7 findings on a tutorial workflow, but neither the curl | bash nor the secret exposed to the whole job.
  • Explicit trust rests on five principles: verify, minimise permissions, pin, monitor, document.

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

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