Most of the code you ship, you did not write. It arrives as libraries, which bring their own libraries, built by machines you have never seen and downloaded from registries anyone can publish to. This chapter follows that code from a stranger's keyboard to your production server, shows the places where someone can slip something in, and walks through the defenses that turn "we hope so" into "we checked": lockfiles and hashes, signed provenance, reproducible builds, and a parts list you can search the morning an advisory drops.
Common mix-up: a valid signature tells you who published a package and that it was not changed afterward. It says nothing about whether the code is safe. A maintainer whose account was taken over, or who turned malicious, produces perfectly signed malware. Signing and provenance narrow the question to "do I trust this publisher and this build?"; they do not answer it for you.
Read first: no networking background needed. This chapter leans on two earlier ones: what a hash and a signature are (Chapter 02) and how a public append-only log keeps certificate authorities honest (Chapter 03). Flaws inside the code itself are Chapters 12 and 13; what to do once you find a compromise is Chapter 18.
Think of a sandwich. You made it, but you did not grow the wheat, mill the flour, raise the chicken or press the oil, and you trust a long line of strangers for each. Software is the same, only the line is longer and nobody prints it on a label. A web app that its team thinks of as "ours" is usually a thin layer of its own code sitting on a framework, a few dozen libraries, a language runtime, a compiler, a build server and an operating system, each written and shipped by someone else.
The libraries you name yourself are your direct dependencies. Each of them names its own, and those name theirs: these are transitive dependencies, and you never chose them. A package manager reads your list, follows every name down, picks a version of each that satisfies all the constraints, and downloads the lot. Because most ecosystems favor small single-purpose packages and every package brings its own needs, a list of ten direct dependencies can easily become a hundred or more installed packages. The count is a property of the tree's fan-out, not of carelessness: if each package averages three dependencies and the tree is four levels deep, the arithmetic runs away quickly, held back only by sharing.
Sharing is the second surprise. Popular low-level packages (a date parser, a hash function, a string helper) sit under many branches at once. That is efficient, and it is also why one flaw deep in the tree can reach every corner of an application: there is not one path from your code to that package, there are dozens. The tree is really a graph, with diamonds where two branches meet at the same package.
| Ecosystem | You write (manifest) | Tool writes (lockfile) | Install exactly the lockfile |
|---|---|---|---|
| JavaScript (npm) | package.json | package-lock.json | npm ci |
| Python (pip) | requirements.in / pyproject.toml | requirements.txt with hashes | pip install --require-hashes |
| Rust (Cargo) | Cargo.toml | Cargo.lock | cargo build --locked |
| Go (modules) | go.mod | go.sum (hashes) | checked on every build |
Manifests rarely name one exact version. They name a range: ^2.3.0 in npm means "2.3.0 or any later 2.x", on the theory of semantic versioning that a change in the first number is the only one allowed to break you. The resolver picks the newest version in range at install time, so two installs a week apart can produce different trees from an unchanged manifest. That is good for getting fixes and bad for knowing what you shipped, and it is why lockfiles exist.
When two parents need incompatible versions of the same package, ecosystems differ. npm installs both copies, nested under each parent. Python and Go must pick one version for the whole program, so a conflict either fails the install or forces the newest compatible version everywhere (Go's "minimal version selection" deliberately picks the oldest version that satisfies everyone, which makes builds stable). The practical consequence for security: in npm you can have two versions of the same library installed at once, one patched and one not, and only a full inventory shows it. The SBOM instrument below has exactly that case.
A small fictional app, pantry-demo, has five features on the left, ten direct dependencies, and 45 transitive ones further right: 55 packages in all, every name invented for this page. Click a package (or choose it in the list) to mark it vulnerable or malicious. The explorer counts every path from a feature down to it, lights those paths, lists the features that pull it in, and ranks the single changes that cut the most paths. Switch the mode to remove and click packages to take them out of the tree, then watch the counts change. The typosquat preset slips a near-name package into the tree; the checker underneath compares every pair of names and flags any that are one edit apart.
A supply-chain attack does not break into your systems directly. It gets something malicious or vulnerable into a thing you will install of your own free will. The SLSA threat model labels the places that can happen from (A) to (I), following the code from the person who writes it to the person who runs it. Every dependency has its own copy of this chain, all the way down.
| Pattern | How it works | What stops or catches it |
|---|---|---|
| Typosquatting | A package is published under a name one keystroke away from a popular one, hoping for typos in install commands and manifests. | Copy names from the official docs; review new dependencies in code review; name-similarity checks in CI; registries block confusable names. |
| Dependency confusion | A company uses an internal package name that is not reserved on the public registry. Someone publishes that name publicly with a higher version, and a resolver that consults both registries prefers the public one. | Scoped or namespaced names you own publicly; one registry proxy that maps each scope to exactly one source; lockfiles with integrity hashes. |
| Compromised maintainer | An attacker phishes or guesses a maintainer's credentials and publishes a new version of a trusted package. | Phishing-resistant MFA for publishers; trusted publishing from CI instead of personal tokens; provenance that names the expected build. |
| Compromised build | The source is clean but the build machine or a build step injects code, so the published artifact differs from the source. | Hosted, isolated, ephemeral builders (SLSA Build L3); signed provenance; reproducible builds checked by a second builder. |
| Tampered update | A mirror or update server delivers a modified file, or an old vulnerable version presented as current. | Signature and digest verification on the client; transparency logs; update frameworks that reject rollbacks. |
| Abandoned or stale package | Nothing malicious happens; a known flaw is simply never fixed, or the package is handed to a new owner nobody vetted. | Inventory (SBOM), vulnerability feeds, health signals such as the OpenSSF Scorecard, and budgeted time to replace dead dependencies. |
In March 2024 a backdoor was found in XZ Utils, a compression library present in many Linux distributions, and was assigned CVE-2024-3094. NVD's description says the malicious code was in the upstream tarballs starting with version 5.6.0: the release archives contained build-time changes that the project's public source history did not show plainly, and the build process extracted a prebuilt object hidden in a test file. CISA's alert named versions 5.6.0 and 5.6.1 and advised downgrading to an uncompromised version such as 5.4.6.
Three lessons map straight onto this chapter. A trusted maintainer identity was the entry point (threat A), so signatures alone could not have helped. The gap between a release archive and the repository is exactly what reproducible builds and build provenance are designed to expose (threats D to F). And the people who could answer "are we running 5.6.0 or 5.6.1 anywhere?" in minutes were the ones with an inventory.
MITRE ATT&CK files this family of techniques under T1195 Supply Chain Compromise, with T1195.001 Compromise Software Dependencies and Development Tools as the sub-technique for packages. MITRE's weakness list has the defender's side: CWE-494 Download of Code Without Integrity Check and CWE-1357 Reliance on Insufficiently Trustworthy Component.
To a human, cobblestr and cobblesrt are the same word at a glance. To a registry they are unrelated names with unrelated owners. The common tricks are a swapped pair of letters, a doubled or dropped letter, a different separator (- versus _ versus .), a plural, and characters that look alike. Some registries fold the easy cases: Python's package index treats runs of -, _ and . as the same and ignores case, so those variants cannot be registered separately. Swapped and dropped letters are still separate names everywhere, which is why the checker in Instrument 1 uses an edit distance that counts a swap of two neighboring letters as one edit (the Damerau-Levenshtein distance).
Dependency confusion is a resolver problem, not a naming one. If your build tool can fetch a name from more than one place, the question "which source wins?" has an answer you may not like. The fix is to make each name come from exactly one place. In npm, a scope does that in one line of configuration:
# .npmrc: every @pantry/* package comes only from the internal registry @pantry:registry=https://npm.internal.example.com/
Register your organization's scope or prefix on the public registry too, even if you never publish there, so nobody else can.
The cheapest defense is also the most important: write down exactly what you installed, and refuse to install anything else. A lockfile records the full resolved tree: every package, the exact version, where it was downloaded from, and an integrity hash of the file's bytes. Installing "from the lockfile" (npm ci rather than npm install) means the build gets the same bits every time, and if a registry, mirror or cache ever serves a different file under the same name and version, the hash will not match and the install stops.
// package.json: a range. Tomorrow's install may pick a newer 2.x you have never seen.
"dependencies": { "brindlejson": "^2.3.0" }
// package-lock.json: the exact version, the source, and a hash of the downloaded bytes
"node_modules/brindlejson": {
"version": "2.3.1",
"resolved": "https://registry.example.org/brindlejson/-/brindlejson-2.3.1.tgz",
"integrity": "sha512-(base64 of the SHA-512 of that exact .tgz)"
}
The web has a close cousin. When a page loads a script from someone else's server, Subresource Integrity lets it pin the file the same way: the browser hashes what arrives and refuses to run it if the hash differs. It is the right tool for a fixed, versioned file on a CDN, and the wrong one for a script that is meant to change under you.
<script src="https://cdn.example.net/widget-2.1.0.js"
integrity="sha384-(base64 of the SHA-384 of widget-2.1.0.js)"
crossorigin="anonymous"></script>
Pinning has a cost: a pinned tree does not pick up fixes by itself. The working answer is to pin everything and update deliberately: an automated bot opens a pull request when a new version appears, the change runs through the same tests and review as any other change, and the lockfile diff shows exactly which packages moved. Two more habits shrink the problem. Keep fewer dependencies: every package you do not install is a maintainer, a build and a registry account you do not have to trust. And treat adding a dependency as a design decision, reviewed like code, with a look at who maintains it and how.
Both extremes fail. A project that never updates accumulates known vulnerabilities, and when it finally must jump several major versions at once, the upgrade is so large that it is postponed again. A project that installs the newest version of everything the moment it appears will install a hijacked release within minutes of its publication, before anyone has noticed. Malicious versions of popular packages are often discovered and removed within days, so a short waiting period before adopting brand-new releases, combined with fast-tracked updates for versions that fix a known exploited flaw, removes most of the risk on both sides.
Two related npm habits: run installs with lifecycle scripts disabled where you can (npm ci --ignore-scripts), because install scripts run arbitrary code at install time on developer laptops and build machines, and treat a new install script appearing in a dependency update as a red flag for review. The OWASP NPM Security Cheat Sheet collects these.
A lockfile hash proves you got the same file as last time. It cannot tell you whether the first file was honest. For that you need evidence about where the file came from: provenance, a signed statement made by the build system that says, in effect, "I built the artifact with this SHA-256, from this repository at this commit, using this build recipe". A consumer who checks the signature, the builder identity, the source, and the digest of the file in hand has closed most of the gaps between "the repository I reviewed" and "the file I am running".
Signing used to stall on key management: every maintainer had to guard a long-lived private key forever. Sigstore removes that. The signer proves who they are by logging in with an identity provider (or, for a CI system, with the workload identity the CI platform issues). Sigstore's certificate authority, Fulcio, issues a code-signing certificate for a fresh key bound to that identity, valid for 10 minutes. The signer signs and the signature is recorded in Rekor, a public append-only transparency log, then the key is thrown away. A verifier later checks that the signature matches, that the certificate names the identity it expected, and that the log entry exists and was made while the certificate was valid. It is the same idea as Certificate Transparency in Chapter 03: the log does not make a signature trustworthy, it makes every signature visible, so a stolen identity cannot sign in secret.
SLSA (Supply-chain Levels for Software Artifacts) turns "how much do I trust this build?" into levels a producer can claim and a consumer can check. Its Build track has four.
| Level | Requires | Mainly protects against |
|---|---|---|
| Build L0 | Nothing: no SLSA | n/a |
| Build L1 | Provenance exists, describing how the package was built | Mistakes; gives documentation to check against |
| Build L2 | Signed provenance, generated by a hosted build platform | Tampering after the build |
| Build L3 | A hardened build platform: builds isolated from each other, signing secrets out of reach of the build's own steps | Tampering during the build |
SLSA v1.2 adds a Source track (Source L1 to L4, from "use version control" up to "require code review"), because a perfect build of malicious source is still malicious. And there is a check that needs no trust in any single builder at all: a reproducible build. If the build process is deterministic, anyone can take the same source and toolchain and get bit-for-bit identical output, so a second, independent builder that arrives at the same hash is strong evidence that nobody tampered with the first. Getting there means removing every accidental difference: timestamps embedded in files, file ordering from the filesystem, build paths, locale, random seeds. The Reproducible Builds project keeps the definition and a catalog of the usual culprits.
A signature check that accepts "any valid signature" accepts the attacker's too, since anyone can get a Sigstore certificate for their own identity. The policy is what matters: for this package, the signer must be this CI workflow in this repository, building from a release tag, and the provenance subject must equal the digest of the file in hand. Change any expectation to "whatever it says" and the check degrades to decoration.
The provenance format SLSA recommends is an in-toto attestation: a small JSON statement whose subject lists artifact names and digests and whose predicate describes the build (builder ID, build type, external parameters such as the source repository and ref, and resolved dependencies). The signature covers the whole statement, so editing any field, including the digest, breaks it. Instrument 3 below lets you try each of these tampering moves.
A fictional release of pantry-demo 3.2.0 with its provenance. Edit the file, the statement's fields, or pick a scenario, and the verifier runs six checks in order and stops at the first failure, as real verifiers do. The file digest is a real SHA-256 computed in your browser. The "signature" is a stand-in: a SHA-256 keyed with the signer's identity, so that it behaves like a signature (change a signed byte and it fails) without real public-key maths, which Chapter 02 covers.
The morning a serious flaw is announced in a popular library, every organization asks the same question: are we running it, where, and which version? Without an inventory, the honest answer takes days of searching repositories, container images and servers, and it misses whatever was vendored, bundled or copied in. A software bill of materials (SBOM) is that inventory, written down per release in a machine-readable format: every component, its exact version, a precise identifier, who supplies it, and how the components depend on each other.
The precise identifier is usually a package URL (purl): pkg:npm/rueinflate@1.2.11 names the ecosystem, the package and the version in one string that tools on both sides agree on. With purls in an SBOM and affected ranges in an advisory, "are we affected?" becomes a query, not a project.
| Format | Steward | Standard | Typical strengths |
|---|---|---|---|
| SPDX | Linux Foundation | ISO/IEC 5962:2021 | Born for license compliance; rich file- and package-level detail; tag-value, JSON and other encodings |
| CycloneDX | OWASP / Ecma TC54 | ECMA-424 | Born for security use; components, services and dependencies; built-in vulnerability and VEX records |
An SBOM tells you a component is present. It does not tell you whether the vulnerable code in it can actually be reached in your product. That is what VEX (Vulnerability Exploitability eXchange) adds: a statement from the supplier, per vulnerability and component, of affected, not affected (with a justification such as "the vulnerable code is not reachable"), fixed or under investigation. VEX turns a scanner's long list of hits into the short list that needs action. The instrument below has one VEX statement you can switch on.
The U.S. NTIA's minimum elements for an SBOM (July 2021) set the baseline data fields: supplier name, component name, version, other unique identifiers, dependency relationship, author of the SBOM data, and timestamp. CISA's SBOM pages carry the ongoing community work.
Where the SBOM is generated matters. One made from the source manifest lists what you asked for; one made from the lockfile lists what resolved; one made by inspecting the built container image also catches system packages and anything copied in by hand. The best place is the build itself, signed alongside the provenance, so the inventory describes the exact artifact you shipped. An SBOM that is generated once and never regenerated is a list of what you used to run.
On the left, a CycloneDX-style SBOM for the fictional pantry-demo 3.2.0: six direct dependencies and the transitive components they pulled in, including one library present in two versions. On the right, fictional advisories with affected version ranges in npm's range syntax. Pick an advisory, or type your own range, and the tool parses the range, compares every matching component's version properly (as numbers, not text) and reports exactly which entries are affected. It also shows what you would have found checking only the manifest, the file people usually look at first.
| Component | Version | Status |
|---|
Vulnerabilities get names so that everyone can talk about the same one. A CVE ID (from the CVE Program) is that name. The U.S. National Vulnerability Database adds analysis to CVE records, including affected products and a severity score. For open-source packages, OSV publishes advisories in a schema designed for machines: each record lists the ecosystem, the package name and the exact affected version ranges, which is exactly what the instrument above consumes.
A severity score answers "how bad could this be?" It does not answer "is anyone doing it?" The CVSS score from Chapter 01 is about the flaw, and a critical score does not mean anyone is exploiting it, while a medium one may be exploited widely. CISA's Known Exploited Vulnerabilities catalog is the counterweight: CISA describes it as the authoritative source of vulnerabilities that have been exploited in the wild, and recommends using it as an input to prioritization. FIRST's EPSS adds a probability estimate that a vulnerability will be exploited soon. Together they give a sensible order of work.
Compare 0.9.12 with 0.9.2 as text and the 12 loses, because the character "1" sorts before "2". Real comparison splits on dots and compares numbers. Ranges add their own traps. In npm, ^1.4.0 means "below 2.0.0", but ^0.9.0 means "below 0.10.0", because a leading zero is taken to mean "anything may break". Pre-release tags (2.0.0-rc.1) sort before their release, and most ecosystems exclude them from ranges unless asked. Python, Go, Maven and Debian each have their own version rules, which is why OSV records say which ecosystem's ordering applies. The parser in Instrument 2 implements npm's rules for release versions and shows its expansion of every range.
None of the controls above is hard on its own; the hard part is doing all of them, every release, for years. Two public frameworks organize the work. NIST's Secure Software Development Framework (SP 800-218, version 1.1, February 2022) groups secure-development practices into four groups, each with concrete tasks and references to other standards. It is written for producers, and U.S. federal buyers use it to ask suppliers what they do.
| SSDF group | Goal | Supply-chain examples from this chapter |
|---|---|---|
| PO · Prepare the Organization | People, processes and tools ready | Policy for adding dependencies; who may publish; build platform choice |
| PS · Protect the Software | Keep code and releases from tampering | Protected branches, signed provenance, integrity hashes, archived releases |
| PW · Produce Well-Secured Software | Few vulnerabilities at release | Vetting third-party components, minimal dependencies, reproducible builds |
| RV · Respond to Vulnerabilities | Find, fix and learn from flaws | SBOM queries, KEV-driven priority, VEX statements, root-cause fixes |
CISA's Secure by Design initiative speaks to the same producers from the other side. Its principles ask manufacturers to take ownership of customer security outcomes, embrace radical transparency and accountability (publishing SBOMs and vulnerability data is part of that), and lead from the top, treating security as a business goal rather than a feature. For a team on a Monday morning, the whole chapter fits on one list.
Most open-source maintainers are volunteers who owe you nothing, and an inventory of what you depend on is also a list of projects worth supporting. The Open Source Security Foundation runs much of the shared tooling mentioned here (Sigstore, SLSA, OSV, Scorecard). The OWASP Software Supply Chain Security Cheat Sheet is a compact checklist, and the OWASP Top 10 for 2025 lists Software Supply Chain Failures as its own category.