Most threat models rest on an assumption nobody writes down: that the code running in production is the code that was reviewed. Every control in the model depends on it. The authorization check you confirmed exists, the validation you traced, the encryption you argued about for twenty minutes — each one is a few lines in a file that something else has to compile, package, sign, and ship before any of it is real. That something else is the pipeline, and it is the component that makes the assumption true or false.
Which makes it a strange thing to leave off the diagram, because it is almost certainly the most privileged system you own. It can write to production without a human in the loop. It holds credentials for the cloud account, the registry, the database migrations, and probably a few things nobody remembers adding. It runs code from your repository, your dependencies, and whatever third-party build steps someone found useful two years ago. An attacker who can change what the pipeline builds does not need to defeat a single one of your application's controls — they can ship their own build with those controls intact and irrelevant. That is what happened to SolarWinds in 2020: the build process was compromised rather than the source repository, so the artifact that shipped was signed, was trusted, and did not match the code anyone had reviewed.
The short version: Draw the pipeline as its own system with its own trust boundaries, because it does not appear in the application model it deploys. Then work three questions: who can change what it builds (a much longer list than "who has production access"), what untrusted input reaches a runner holding credentials, and what one compromised job reaches next. The findings are almost always about scope and standing access, not about missing scanners.
Draw the pipeline, not the diagram you already have
The application data flow diagram shows the running system. The pipeline is a different system with different elements and different actors, and it deserves its own model rather than a single box in the corner of the existing one. The usual DFD mechanics still apply — processes, stores, flows, boundaries — but the inventory looks like this:
| Element | Why it matters in the model |
|---|---|
| Source repository | The input everyone thinks of. What is actually interesting is the write path: who can push, who can merge, who can bypass, and who can change the settings that define all three. |
| Build definition | The workflow or pipeline file lives in the repository and describes what the pipeline executes. Anyone who can change it can change what runs, which is a different permission from being able to change the application code. |
| Runners / build agents | Processes that execute arbitrary code while holding credentials. Whether they are ephemeral or long-lived, and shared or dedicated, drives most of the findings. |
| Dependency and action sources | Package registries, base images, and third-party build steps. Code from outside your organisation, resolved at build time, running inside the boundary. |
| Secrets store | The thing that turns a build job into a production identity. Model which secrets are reachable from which jobs, not just where they are kept. |
| Artifact registry | Where the build output is stored between build and deploy. A store with write access from CI and read access from production is a bridge between the two. |
| Caches | Dependency caches, layer caches, and test fixtures persist between jobs and often across branches. They are data stores that a job can write and a later job will trust. |
| Deploy target | The environment, and specifically what the deploy identity is permitted to do there beyond deploying. |
Then draw the trust boundaries, which in a pipeline are rarely where people expect. The one that matters most is not between the internet and the runner; it is between "code that a reviewer approved" and "code that the runner executes." Those are supposed to be the same set. Every place they diverge — a dependency resolved at build time, a third-party action pinned to a moving tag, a script fetched by the build itself, a cache entry written by an earlier job — is a boundary crossing, and each one deserves a line on the diagram.
Who can change what runs
Ask a team who can deploy to production and you will get a short list of names. Ask instead who can cause code to execute inside a pipeline job that holds production credentials, and the honest answer is considerably longer. That second list is the real permission set, and enumerating it is usually the highest-value hour in the whole session.
- Anyone who can merge to a deploying branch. The obvious path, and typically the only one anyone has thought about.
- Anyone who can edit the build definition. Often the same repository, the same review rules — but not always, and a change to the pipeline file is reviewed by people evaluating it as configuration rather than as code that runs with credentials.
- Anyone who can approve. Whether the approval gate is a genuine second pair of eyes or a formality one person can satisfy alone, including by approving their own change.
- Anyone who can bypass. Administrators who can push past branch protection, force-push, or re-run a job with modified parameters. Emergency paths that exist for good reasons and are audited by nobody.
- Anyone who can register a runner. If a self-hosted runner can be added to the pool, jobs can be steered onto a machine the attacker controls.
- Anyone who maintains a dependency you resolve at build time. Not a person you have ever met, and their compromise is your compromise.
- Anyone with a valid token. Personal access tokens, deploy keys, and integration credentials that were issued once, scoped broadly, and never expire.
Write that list down as actors in the model, because it reframes the conversation. "Only four people can deploy" and "thirty-one people, two service accounts, and four hundred transitive dependencies can influence what gets deployed" describe the same pipeline, and only one of them is a threat model.
Trace the untrusted input to the credentials
The single most productive question in a pipeline model is this: where does input an attacker can influence meet a job that holds a secret? Every serious CI/CD finding is some version of those two things touching, and the paths are more varied than they first look.
Pull requests from outside the trusted set are the canonical case. A build that runs a contributor's code is doing exactly what it was designed to do; the question is what that build is allowed to reach while doing it. Most platforms distinguish between a trigger that runs the proposed code without secrets and one that runs in the context of the target branch with secrets available — and the second kind exists because teams need it for labelling, commenting, and coverage reporting. When a workflow using the privileged trigger checks out and executes the proposed code, the isolation is gone. That specific combination is worth naming explicitly on the diagram rather than leaving it to be inferred.
But contributor code is only the most visible path. Build-time dependency resolution runs installation hooks from packages that may have been updated an hour ago. Base images referenced by a mutable tag are not the images that were scanned last month. A build step pinned to a branch or a version tag rather than an immutable digest is a promise from someone else that they will not change it. Caches written by a job on any branch and restored by a job on the main branch carry data across a boundary the diagram implies is solid. And test fixtures, seed data, and generated configuration are all inputs the build trusts and nobody reviews with the care given to application code.
A useful reframe for the room: a CI job is a remote code execution primitive that you built on purpose. Every threat question is downstream of that. The job is going to run whatever it is told to run — so the model is not about preventing execution, it is about constraining what executing means: what identity, what network reach, what lifetime, what evidence.
Scope the credentials
Pipeline credentials fail in a consistent way. They start scoped to a task, accumulate permissions as the pipeline grows, and are never narrowed again, because narrowing them risks breaking a deploy and nobody can say with certainty which permission is still load-bearing. The result is a build job authenticating as something close to an administrator, and a threat model in which the compromise of any job is the compromise of the account.
For each job that holds a secret, work three questions. What can it authenticate as? Not the name of the credential but the permissions behind it — and specifically whether the deploy identity can only deploy, or can also read the database, list other tenants' storage, alter logging configuration, or mint further credentials. How long does it live? A long-lived static key exfiltrated from a build log is valid until someone notices; a short-lived token federated to the workload for the duration of the job is a much smaller prize. Where the platform supports OIDC federation to the cloud provider, moving from stored keys to short-lived tokens removes an entire class of finding, and it is the change most likely to come out of this session. Who else can reach it? Whether secret scoping is enforced by the platform per environment and per branch, or achieved by convention — the convention being that no one has yet written a job that requests a secret it should not have.
Then ask where secrets end up incidentally. Environment variables visible to every child process of the build. Debug output enabled during an incident and never turned off. Error messages that echo the request that failed. Artifacts that bundle a configuration file. Masking in the log viewer is a display control, not a storage control, and it only masks values the platform knows about.
The STRIDE sweep, at pipeline altitude
STRIDE works well here provided you apply it to pipeline elements rather than application ones. The categories mean something slightly different when the asset is a build.
| Category | What to ask of the pipeline |
|---|---|
| Spoofing | What proves a commit came from who it claims? Are commits signed, and does anything actually verify signatures, or is the author field simply believed? What proves a runner is one of yours before a job with secrets is scheduled onto it? |
| Tampering | Between build and deploy, what could alter the artifact? Is the thing deployed verified to be the thing built — by digest, by signature, by provenance attestation — or only by having the same tag? Can a build step modify a file that a later step trusts? |
| Repudiation | Can you reconstruct, months later, which commit produced which running artifact, who approved it, and which credentials were used? Are pipeline logs retained beyond the platform default, and are they outside the reach of the identity the pipeline itself holds? |
| Information disclosure | What leaves the pipeline: build logs, artifacts, cache contents, error reports to third-party services. Are logs of a private repository's builds visible to a wider audience than the repository is? |
| Denial of service | Can anyone exhaust the runner pool or the build minutes, and is that just a cost problem or does it also block the emergency patch path? What is the deploy story when the pipeline platform itself is unavailable — and does that fallback have the same controls? |
| Elevation of privilege | Can a job in a low-trust stage reach a high-trust one — a test job that can write to the artifact registry, a staging deploy that shares an identity with production, a build that can rewrite the pipeline definition for the next run? |
What one compromised job reaches next
Finish by assuming the thing you have been trying to prevent. One job executes code chosen by an attacker. Not a hypothetical worth arguing about — dependency compromise alone makes it a routine scenario. From there, enumerate reach, the same way you would trace lateral movement between services:
- Other jobs on the same runner. A self-hosted runner that is not reset between jobs carries whatever the previous job left: credentials in memory or on disk, a modified toolchain, a poisoned cache directory. This is the difference between one compromised build and every subsequent build on that machine.
- Other repositories. A shared runner pool or an organisation-scoped token turns access to the least important repository into access to the most important one.
- The cache. Poisoned once, restored by every later job that matches the key — including on branches the attacker could never write to directly.
- The registry. Write access to artifacts means the ability to replace the artifact that a deploy will pull, entirely outside the source control path everyone is watching.
- The pipeline itself. If a job can modify the build definition, the source repository, or the platform's own settings, the compromise persists after the malicious commit is reverted.
- The network. What can the runner reach that a developer laptop cannot? Runners often sit inside the VPC with direct access to databases and internal services, on the argument that they need it for migrations and integration tests.
For the pipelines that deploy something that matters, this is worth building out properly as an attack tree under a goal an attacker would actually pursue — "run my code in production without an approved commit." The children are the paths above, and what the tree shows you is whether they are genuinely independent. If four of the five branches terminate at the same over-permissioned service account, the pipeline has one control standing between a compromised dependency and production, and a tree makes that visible in a way a list of findings does not. That is the defense in depth question applied to the build, and it is usually the strongest argument in the write-up.
What to record
Pipeline findings belong in the same risk register as everything else, but they need framing to survive prioritisation, because a finding phrased as "the deploy role has excess permissions" reads like housekeeping next to an application vulnerability with a CVE attached. Write the consequence into the entry: the risk is not that the role is broad, it is that any compromise of any dependency in this repository yields that role. Pipeline risks are nearly all of that shape — a small weakness multiplied by everything the pipeline touches — and the multiplier is the part that belongs in the register.
Record the systemic findings once, at the level where they get fixed. "No job verifies that the artifact deployed is the artifact built" is one entry with one architectural fix — signing and verification, or provenance attestation of the kind the SLSA framework describes — rather than one entry per service. And write down the assumptions the model rests on, because in a pipeline they decay fast and quietly: which runners you believed were ephemeral, which secrets you believed were environment-scoped, which third-party steps you believed were pinned. Those are claims about configuration, and configuration changes without anyone thinking of it as a change to the threat model. The trigger for revisiting is not a date — it is a new runner, a new integration, or a new stage.
Where CI/CD threat models go wrong
- Modelling the application and calling the pipeline out of scope. The pipeline is how the application gets to production. Excluding it excludes the path that bypasses every control in the model.
- Treating scanners as the answer. Dependency and secret scanning in the pipeline are worth having, but they are controls the pipeline runs, not controls on the pipeline. They do not constrain what a job can reach.
- Assuming the platform is the boundary. A managed CI service isolates jobs from each other by default; a self-hosted runner does whatever you configured, and the default is usually reuse.
- Confusing "requires review" with "reviewed." A pipeline file change and a dependency version bump both pass review routinely, and both change what executes with credentials.
- Stopping at the deploy. The deploy identity's permissions after deployment — read access to data, ability to alter logging, ability to create new identities — are part of the model.
- Modelling one repository. The interesting reach is between repositories, through shared runners, shared tokens, and shared registries.
None of this requires a separate methodology. It is the same three questions you ask of a REST API or a set of services — who can call this, what may they do with it, what does it reach — pointed at the one system whose output is everything else you have modelled.
Multi-tenant SaaS raises the stakes on the deploy identity in particular, since a pipeline role scoped per environment but not per tenant makes tenant isolation a property of the application only. See threat modeling for SaaS, or start from the pre-labelled DFD in the free threat modeling templates.