transitive dependenciesSCAsupply chain by Derek Voss

The Transitive Dependency Problem: Why Your Direct Packages Are Not Your Only Risk

You added 5 direct dependencies. Your lockfile has 1,200 packages. Most CVEs in your dependency tree are 3-4 levels deep in transitive imports you never consciously chose.

The Transitive Dependency Problem: Why Your Direct Packages Are Not Your Only Risk

When a developer adds a package to their project, they're rarely adding just that package. They're adding every package that package depends on, and every package those packages depend on, recursively until the entire transitive closure is resolved. In modern JavaScript/TypeScript projects, adding 5 direct dependencies to an empty project can produce a node_modules directory with 800+ packages. Most of those packages, and most of the CVEs in them, were never a conscious choice by the developer who added the original five.

This is the transitive dependency problem, and it's the primary reason why dependency security is hard to reason about from first principles.

The dependency tree structure

Most developers understand their direct dependencies reasonably well — they're listed in package.json, requirements.txt, go.mod, or Cargo.toml. What they rarely have a mental model of is the full depth of the dependency tree those direct dependencies pull in.

A dependency tree is a directed acyclic graph (cycles can exist in theory but package managers handle them through versioning constraints). Your application is the root. Direct dependencies are depth 1. Dependencies of your direct dependencies are depth 2. And so on.

In practice, most CVEs affecting real application lockfiles are found at depth 3-5 in the tree, not at depth 1. This makes intuitive sense: depth 1 packages are maintained libraries you chose and presumably evaluated. Depth 4 packages are utility libraries that depth 3 packages pull in for string manipulation or HTTP parsing — low-profile packages that receive less scrutiny, are less frequently audited, and accumulate known vulnerabilities that go unpublicized for longer.

Why developers don't see the transitive tree

The cognitive invisibility of transitive dependencies is structural, not a failure of individual attention. Package managers abstract the transitive resolution away — you ask for [email protected], npm or yarn resolves the full dependency closure and installs it. The lockfile contains the full resolved set, but it's not designed to be human-read. A package-lock.json for a moderately complex project is thousands of lines of JSON.

Even experienced developers who know to check their transitive dependencies rarely do it on every PR. The mental overhead of auditing depth-4 dependencies on a routine basis is unsustainable. This is exactly the problem SCA tooling exists to solve — automated tracking of the full dependency tree, including depth you'd never inspect manually.

The problem with advisory-only SCA in this context is that it surfaces depth-4 CVE hits with the same visual presentation as depth-1 CVE hits, without context about whether the vulnerable code is actually called. At depth 4, it's especially common for a package to be in the tree for narrow utility purposes that don't exercise the vulnerable function — but the advisory match treats all of them identically.

A concrete scenario: the health data API

Consider a healthcare data API service — a Python Flask application handling de-identified patient record queries. The engineering team has 4 direct dependencies in requirements.txt: Flask, SQLAlchemy, marshmallow, and boto3 (for S3 access). After full pip resolution, their virtual environment contains 71 packages.

In a routine SCA scan, the advisory tool surfaces a CRITICAL CVE against urllib3 — a request library that boto3 pulls in as a transitive dependency at depth 2. The CVE (a request header injection vulnerability in certain proxy configurations) has a CVSS score of 9.8 and is in the "actively exploited" NVD category.

On the advisory match alone, this is CRITICAL and demands immediate attention. After call graph analysis: the Flask application routes all outbound HTTP through boto3's S3 client, which uses urllib3 internally. The specific vulnerable code path in urllib3 involves proxy authentication headers — but this application's boto3 usage doesn't use HTTP proxies. The S3 client makes direct connections without any proxy configuration.

Is the CVE reachable? Technically, there's a call path from the application into boto3 into urllib3. But the vulnerable proxy handling code is never invoked because proxy configuration is never provided. A sophisticated reachability analysis with configuration awareness would classify this as NOT REACHABLE IN CURRENT CONFIGURATION. A simpler call-graph-only analysis would classify it as REACHABLE. The advisory-only tool classifies it as CRITICAL — no context about how urllib3 is actually used.

This scenario illustrates both the value of call graph analysis over advisory matching, and the genuine complexity at the edges of reachability classification. The right answer here is "probably not exploitable given your configuration, but worth verifying and tracking."

Transitive dependency updates: the coordination problem

Fixing a CVE in a transitive dependency is fundamentally different from fixing one in a direct dependency. For a direct dependency, you update your version range in package.json and pin the lockfile. For a transitive dependency, you have two options:

  • Update the parent dependency — find which of your direct (or closer) dependencies pulls in the vulnerable transitive package, and upgrade that parent to a version that depends on a non-vulnerable release of the transitive package.
  • Override the transitive resolution — most package managers support some form of dependency override or resolutions map that forces a specific version of a transitive package regardless of what the parent dependencies request.

The override approach is faster but brittle: you're overriding the version constraint that the parent package specified, which may break the parent's functionality if the transitive API changed between versions. The parent update approach is safer but requires understanding the full dependency chain to find the right parent to update.

A security tool that understands the full dependency tree — not just the CVE hit — can surface this context: "CVE is in [email protected], which is pulled in by [email protected] as a dev dependency. Upgrading npm-check-updates to v17.x resolves this without requiring an override."

The supply chain implication

Transitive dependencies are also the primary attack surface for supply chain attacks, distinct from CVEs in existing packages. When a malicious actor compromises a package's npm account and publishes a malicious version, the downstream blast radius includes every package that depends on it — directly or transitively. The 2021 ua-parser-js compromise and the colors.js/faker.js incident are examples of how transitive impact spreads far beyond what developers would predict from their direct dependency list.

We're not saying transitive dependency security is unsolvable — it's tractable with the right tooling. What we're saying is that the scale of the problem is systematically underestimated when developers only think in terms of their direct dependency list. The risk surface is the full resolved lockfile, including every package at every depth. Understanding which parts of that lockfile are actually exercised by your application is the key to prioritizing remediation effort against the risk that's actually relevant.

That prioritization is the difference between a useful security signal and a noise machine that surfaces 1,200 packages and tells you nothing about which 20 actually matter for your threat model.

OWASP Top 10 and Dependency Risk: What A06 Actually Means fo... PR-Level Security Gates That Don't Friction Your Developers