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 trust | Explicit 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).
# The maintainer publishes a clean action, tagged v1printf 'echo "build propre"\n' > action.shgit add action.sh && git commit -m "v1 propre" && git tag v1git push origin main v1
# The maintainer's account is compromised: new commit, tag movedprintf 'echo "build propre"\nenv | base64 # exfiltration\n' > action.shgit commit -am "maj" && git tag -f v1git push origin v1 # refused: ! [rejected] v1 -> v1 (already exists)git push -f origin v1 # acceptedMeasured: 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écuteecho "build propre"env | base64 # exfiltration--- après : le SHA épinglé exécuteecho "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.
The diagram reads from left to right, and each zone calls for its own question:
- Dependencies: each
npm installorpip installruns third-party code, sometimes as early as installation. Who maintains them, and what happens if their account is stolen? - Actions and plugins: they run with the permissions of the job and read its secrets. A compromised action can exfiltrate everything.
- Downloaded scripts: a
curl | bashruns whatever the server serves on that day, with no version and no fingerprint. - Cache: a poisoned cache reinjects malicious code into every build, even after the source has been fixed.
- CDNs and external services: the Polyfill.io case, a domain that changes owner and content.
- Tokens and identities: a stolen token gives access to everything it allows. If it is broad, so is the attacker.
- 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.
# ❌ Tutorial workflow: each line grants implicit trustname: 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 testzizmor --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 finding | Line targeted | Implicit trust |
|---|---|---|
unpinned-uses (x3) | checkout@v4, setup-node@v4, changed-files@v45 | The action's code can change under the same tag |
dangerous-triggers | pull_request_target | The workflow runs with the secrets on PR code |
template-injection | ${{ github.event.pull_request.title }} in run | The PR title becomes shell |
excessive-permissions | the build job | The default token keeps broad rights |
artipacked | actions/checkout | The 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.
| Bias | What we tell ourselves | What 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.
- Identification: the attacker spots a widely used but poorly watched component, a lone maintainer, a utility project.
- Infiltration: they earn trust through useful contributions, or compromise the account of an existing maintainer.
- Injection: the malicious code is buried in a large change, obfuscated, or conditional, for example only active in CI.
- Propagation: pipelines automatically download the new version, and speed up its spread.
- 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.1Monitor 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
@v1ran 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 | bashnor 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
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: Securing a CI/CD pipeline: the four control surfaces
Continue the course: Blocking a pipeline does not improve security: gates and guardrails
A new full quiz with random questions
Work again only on the questions you missed