Supply Chain Security: Threat Modeling Your Dependencies

Draw a data flow diagram for almost any real system and it stops at a clean edge: here is our API, here is our database, here is the boundary where our code ends. Everything inside that boundary gets modeled — trust assumptions, data flows, STRIDE passes, the works. Everything that box actually depends on to run — the compiler that built it, the CI runner that packaged it, a compression library three layers down in the dependency tree that neither you nor anyone you've ever met chose on purpose — sits outside the diagram entirely, despite running with exactly as much privilege as the code you did write.

Supply chain security is what happens when you finally draw those boxes. It isn't a separate discipline from threat modeling; it's the same discipline applied to a part of the architecture teams habitually leave off the page.

The short version: Three incidents, three different unmodeled trust assumptions. SolarWinds trusted its own build pipeline unconditionally. Dependency confusion exploits a package manager's default resolution behavior that nobody at the affected companies chose on purpose. The xz-utils backdoor exploited the assumption that a dependency's age and popularity are themselves a security control. None of the three has anything to do with a vulnerability in code your team wrote — which is exactly why a threat model that stops at your own repository's edge cannot see any of them.

Case 1 — SolarWinds: the build pipeline as an unmodeled trust boundary

Between March and June 2020, SolarWinds distributed a routine, legitimately signed update of its Orion network-monitoring software to customers. The update contained SUNBURST, a backdoor that attackers — later attributed by the US and UK governments to Russia's SVR, publicly referenced as APT29 or Cozy Bear — had injected directly into SolarWinds' own build process. Because the malicious code was compiled into a binary SolarWinds itself signed, there was no forged certificate to catch and no tampered download to flag: the supply chain's actual point of compromise was upstream of every integrity check a customer could have run.

Roughly 18,000 organizations installed the compromised update; the operators selected a much smaller subset for follow-on intrusion, in several documented cases using forged SAML tokens to move into victims' cloud identity systems and read email while bypassing multi-factor authentication entirely. The compromise was discovered in December 2020 — not by SolarWinds, but by FireEye, investigating the theft of its own red-team tools, tracing the intrusion back to Orion.

The vulnerability an attacker actually needed wasn't in Orion's code. It was in the assumption that a vendor's build system, because it belongs to the vendor, needs no trust boundary of its own. Our CI/CD pipeline post makes exactly this argument in the abstract — the pipeline can put anything into production without passing a single control in the application model — and SolarWinds is the case where that abstraction became one of the most consequential intrusions in the industry's history.

The design question that was skipped: "If our build system were compromised, what could it sign and distribute, to how many customers, and would our own security team be the ones to notice?"

Case 2 — Dependency confusion: package resolution as an unmodeled trust boundary

In 2021, researcher Alex Birsan published a technique that produced confirmed code execution inside Microsoft, Apple, Uber, and more than thirty other companies, over an eight-month research project that eventually paid out over $130,000 in bug bounties. The method required no access to any target's network. Birsan found internal package names — leaked in public GitHub configuration files, in npm error messages, in job postings — and published his own packages under those same names on public registries like npm and PyPI, at artificially high version numbers. Because the affected companies' build tooling, by default, resolved a package name to whichever source offered the higher version, the public, attacker-controlled package won every time a build ran, and executed inside the company's own infrastructure.

Nobody at any of those companies decided that a public registry should win a naming collision against an internal one. A package manager's default behavior made that decision on their behalf, silently, and it functioned as the company's actual security policy on the question until someone went looking. The aftermath makes clear this isn't a patched, one-time bug: within 72 hours of Birsan's disclosure, more than 300 copycat packages appeared on npm, and dependency-scanning vendors have since flagged tens of thousands of suspicious packages that fit the same pattern. It's a standing technique, runnable today, against any organization whose internal package names are discoverable.

The design question that was skipped: "When our build resolves a package name, which registry wins — and is that a decision we made, or a default we inherited?"

Case 3 — xz-utils: reputation as an unmodeled trust boundary

This one is included specifically because it isn't a breach — it's a near-miss, and it was caught by luck rather than by any team's security process, which makes it the most honest of the three cases in this article.

Starting around 2022, a contributor using the name "Jia Tan" began submitting patches to xz-utils, a compression library so ubiquitous it sits quietly inside most Linux distributions. Over roughly two years, that identity built enough credibility within the project to be granted co-maintainer access — a patient, low-and-slow social-engineering campaign against a small open-source project run largely by volunteers. In 2023 and early 2024, Jia Tan introduced a backdoor hidden inside obfuscated build scripts and binary test files in the release tarball, specifically constructed so that a normal review of the project's Git history would not reveal it. It shipped in the 5.6.0 release in February 2024, and gave anyone holding a specific private key remote code execution through OpenSSH on affected systems — a flaw serious enough to receive a maximum CVSS score of 10.

It was discovered by accident. Andres Freund, a PostgreSQL developer benchmarking database performance on Debian's unstable branch, noticed SSH logins taking roughly 500 milliseconds instead of the usual 100 and pulled on the thread until he found the backdoor — on March 29, 2024, before the compromised version had propagated beyond the pre-release, testing branches of a small number of Linux distributions. Had it reached a widely deployed stable release, this article would very likely be describing a fourth breach on the scale of the first two instead of a near-miss.

The trust assumption here is the subtlest of the three: that a dependency's age, ubiquity, and years of clean history are themselves evidence of safety. They're evidence that nobody has yet compromised it — which is a different claim, and one that a patient, well-resourced adversary can specifically target by attacking the maintainer's identity rather than the code.

The design question that was skipped: "Does 'this dependency is old and everyone uses it' function as our actual control for it — and if the account maintaining it were compromised or quietly taken over tomorrow, is there any mechanism by which we'd find out before an attacker used it?"

Why this doesn't show up on your diagram by default

Three structural reasons, each of which makes the gap worse than it looks:

  • There's no element type for it. A standard DFD palette has a process, a data store, an external entity. It doesn't have a box for "the pipeline that produces the process" or "the four hundred transitive dependencies inside it" — so there's nowhere natural to even start drawing this, and most teams never do.
  • Transitive depth compounds silently. A team might deliberately choose and review twenty direct dependencies. Those twenty routinely pull in several hundred more, none of which anyone on the team selected, read, or would recognize by name — trust in all of them is inherited by default, never granted by decision.
  • It falls into an ownership gap. No internal team wrote the code, so it doesn't naturally belong to any team's model. It's also not the maintainer's job to threat model your deployment. The result is a component that both sides implicitly assume is someone else's problem.

What actually goes in the model

None of this requires a separate supply-chain program bolted onto your existing practice. It requires drawing a few boxes and boundaries that are usually left implicit.

  • Give the build pipeline its own trust boundary. Model what a compromised build step could sign, produce, and distribute, and to whom — the same treatment our CI/CD post argues for, and precisely the boundary that was missing in the SolarWinds case.
  • Draw package resolution as an explicit data flow. If anything in your build can pull from a public registry you don't control, that's a flow crossing a trust boundary, and it deserves an arrow on the diagram instead of living silently inside a config file's default settings.
  • Maintain a software bill of materials. You cannot threat model a dependency graph you can't enumerate, and most teams cannot enumerate their own transitive graph from memory. An SBOM is the artifact the rest of the model points at.
  • Record who can push a release for anything with wide blast radius. Auth libraries, crypto libraries, and anything that runs at build time with elevated privilege deserve a recorded answer to "how many people can publish a new version of this, and how would we know if that set changed" — precisely the property that mattered in the xz-utils case.
  • Pin and verify, and treat the record as the point. Version pinning, checksum or signature verification, and provenance attestation where it's available (SLSA- or Sigstore-style) aren't a guarantee against a sufficiently resourced attacker. Their real value is being able to state precisely what you built from, after an incident, instead of reconstructing it under pressure.

Where this argument overreaches

Consistent with how this series treats every framework it recommends, the honest limits here matter as much as the recommendations:

  • A model would not have caught xz-utils, and pretending otherwise would be dishonest. It was found by a developer's ear for a performance regression, not by any organization's dependency threat model. The real lesson from that case is about detection and blast-radius limitation on your side of the boundary — not a claim that a workshop would have stopped a two-year social-engineering campaign against someone else's project.
  • An SBOM speeds up response; it doesn't prevent an attack. It tells you fast, once something is found elsewhere, whether you're exposed. It does nothing to stop a sophisticated actor willing to match your controls in the first place.
  • You usually have no leverage over the other side of the boundary. You cannot demand a public registry change its default resolution order, and you cannot demand a volunteer maintainer adopt better operational security. The fixes available to you are almost entirely on your own side: reserve your internal namespace on public registries, scope your build tooling explicitly, and stop treating a dependency's popularity as a substitute for actually knowing who maintains it.
  • The lesson is not "open source is risky." xz-utils was maintained safely for years before this. The attack targeted trust in a specific identity, not a general property of open collaboration — and a large share of dependency-confusion exposure is self-inflicted by a company's own registry misconfiguration, not by anything the open-source ecosystem did wrong.

Supply chain requirements show up explicitly in several regulatory frameworks — see the industry threat modeling guides for what applies in fintech, healthcare, and critical infrastructure. The free templates include a pre-labelled DFD if you want to add a build-pipeline boundary to your own model this week.

Draw the box for what your code depends on

Give your build pipeline its own trust boundary, map where package resolution crosses one, and keep the dependency graph as part of the same forest as the rest of your architecture — instead of the one part of the system nobody threat modeled because nobody wrote it.

Get started free

Not ready to sign up? Get new threat-modeling guides by email instead.