Chapter 16 · Part III · Software & the web

The software supply chain

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.

L0–L3the four levels of the SLSA Build track (spec v1.2), from "no guarantees" to "hardened build platform"
10 minhow long a Sigstore Fulcio code-signing certificate is valid; the identity is checked through a transparency log, not a long-lived key
2main SBOM formats: SPDX (international standard ISO/IEC 5962:2021) and CycloneDX (standardized as ECMA-424)

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.

Someone else's code

Dependencies of dependencies

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.

paths to P = Σ over parents q of P ( paths to q )
The number of ways your app reaches a package is the sum of the ways it reaches each package that depends on it. Shared packages collect paths from every branch above them, which is why the deepest, most popular packages carry the most risk and why "just remove it" rarely works for them. The explorer below computes exactly this.
EcosystemYou write (manifest)Tool writes (lockfile)Install exactly the lockfile
JavaScript (npm)package.jsonpackage-lock.jsonnpm ci
Python (pip)requirements.in / pyproject.tomlrequirements.txt with hashespip install --require-hashes
Rust (Cargo)Cargo.tomlCargo.lockcargo build --locked
Go (modules)go.modgo.sum (hashes)checked on every build
Go deeper: version ranges and how a resolver chooses

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.

Instrument 1

Follow a bad package up the tree

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.

Packages installed–
Paths to the bad package–
Features affected–
Packages on those paths–
    Look-alike name checker (all pairs, one edit apart)

      Where it gets in

      Every link is an entry point

      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.

      1. (A)Producerthe maintainer turns hostile, or their account is taken over
      2. (B) (C)Sourcean unreviewed change lands; the repository host itself is compromised
      3. (D) (E)Buildbuilt from the wrong source or parameters; the build machine is tampered with
      4. (F)Publisha package is uploaded that does not match the official source
      5. (G)Distributiona mirror, cache or update channel serves something different
      6. (H) (I)Youyou pick the wrong package by name; you use it in an unsafe way
      The SLSA v1.2 threat clusters, simplified. Dependency threats are this whole chain again, once for every package your build pulls in.
      PatternHow it worksWhat stops or catches it
      TyposquattingA 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 confusionA 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 maintainerAn 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 buildThe 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 updateA 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 packageNothing 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.
      Go deeper: one documented case, where the release did not match the repository

      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.

      Go deeper: why names are hard, and how registries normalize them

      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.

      Know exactly what you installed

      Lockfiles, pins and hashes

      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)"
      }
      integrity = "sha512-" + base64( SHA-512( file bytes ) )
      A hash is a fingerprint of the bytes (Chapter 02). Change one bit of the download and the fingerprint changes completely, so a matching hash means "the same file I reviewed and tested", whoever served it.

      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.

      Go deeper: auto-update or not?

      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.

      Who built this, from what

      Signatures, provenance and SLSA

      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.

      1. 1. Log ina person or CI job gets an identity token (OIDC)
      2. 2. Fulcioissues a 10-minute certificate binding a fresh key to that identity
      3. 3. Signthe artifact or its provenance is signed; the key is discarded
      4. 4. Rekorthe signature and certificate go into a public append-only log
      5. 5. Verifycheck signature, expected identity, and the log entry
      Keyless signing: the long-lived secret is replaced by a short-lived certificate and a public record.

      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.

      LevelRequiresMainly protects against
      Build L0Nothing: no SLSAn/a
      Build L1Provenance exists, describing how the package was builtMistakes; gives documentation to check against
      Build L2Signed provenance, generated by a hosted build platformTampering after the build
      Build L3A hardened build platform: builds isolated from each other, signing secrets out of reach of the build's own stepsTampering 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.

      Go deeper: what a verifier should insist on

      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.

      Instrument 3

      Tamper with a release

      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.

        sha256 of the file in hand–

        The ingredients label

        SBOMs: knowing what you shipped

        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.

        FormatStewardStandardTypical strengths
        SPDXLinux FoundationISO/IEC 5962:2021Born for license compliance; rich file- and package-level detail; tag-value, JSON and other encodings
        CycloneDXOWASP / Ecma TC54ECMA-424Born 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.

        Go deeper: what an SBOM must contain, and where it comes from

        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.

        Instrument 2

        Are we affected?

        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.

        
                
        ComponentVersionStatus
        Range means–
        Manifest-only check finds–

        From advisory to action

        CVE, OSV, KEV: deciding what to fix first

        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.

        1. In KEV and in your SBOM: fix or mitigate now, outside the normal release cycle.
        2. High severity, reachable, high exploit likelihood: next release, with a deadline.
        3. Present but VEX says not affected: record the reasoning; update on the normal cadence.
        4. Everything else: the routine update stream, which only stays small if it actually runs every week.
        Go deeper: why version comparison is a classic bug

        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.

        Putting it together

        Frameworks that make it routine

        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 groupGoalSupply-chain examples from this chapter
        PO · Prepare the OrganizationPeople, processes and tools readyPolicy for adding dependencies; who may publish; build platform choice
        PS · Protect the SoftwareKeep code and releases from tamperingProtected branches, signed provenance, integrity hashes, archived releases
        PW · Produce Well-Secured SoftwareFew vulnerabilities at releaseVetting third-party components, minimal dependencies, reproducible builds
        RV · Respond to VulnerabilitiesFind, fix and learn from flawsSBOM 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.

        1. Commit lockfiles with hashes, and install from them in CI.
        2. Review every new dependency like code: who maintains it, how many more it brings, whether you need it.
        3. Map private names to one registry; reserve your organization's names publicly.
        4. Require phishing-resistant MFA, or trusted publishing from CI, for every account that can publish.
        5. Build on a hosted, isolated platform that emits signed provenance; verify it on deploy.
        6. Generate an SBOM at build time for every release and keep it where you can query it.
        7. Watch KEV and your advisory feed against those SBOMs; run updates on a steady cadence.
        Go deeper: consuming open source well

        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.

        Cheat sheet

        Terms from this chapter

        Transitive dependency
        A package you did not name yourself, pulled in because one of your dependencies (or theirs) needs it.
        Lockfile
        A file the package manager writes listing the exact version, source and integrity hash of every installed package, so installs repeat exactly.
        Integrity hash
        A cryptographic hash of a package's bytes, checked at install time so a changed file is refused.
        Typosquatting
        Publishing a package under a name one small change away from a popular one, to catch typing mistakes.
        Dependency confusion
        A resolver fetching a public package that shares a name with an internal one, because it consults both registries.
        Provenance
        A signed statement from a build system saying which source, recipe and builder produced an artifact with a given digest.
        SLSA
        Supply-chain Levels for Software Artifacts: a framework of Build (L0–L3) and Source levels for how trustworthy a build is.
        Sigstore
        Free signing infrastructure that binds short-lived keys to a login identity (Fulcio) and records every signature in a public log (Rekor).
        Reproducible build
        A build that produces bit-for-bit identical output from the same source, so independent builders can confirm each other.
        SBOM
        Software bill of materials: a machine-readable list of every component in a release, with versions and identifiers.
        VEX
        A supplier's statement of whether a known vulnerability actually affects their product, and why.
        KEV
        CISA's catalog of vulnerabilities known to be exploited in the wild, used to decide what to fix first.
        Where this comes from

        Sources