Transitive Dependency
A transitive dependency is a package your project pulls in indirectly, because one of your dependencies (or theirs) requires it.
Also known as indirect dependency, transitive dependencies, subdependency, nested dependency, dependency of a dependency, transitive deps
A transitive dependency, also called an indirect dependency or subdependency, is a package your project uses only because another dependency requires it. You never declare it, but the package manager resolves, installs, and runs it with the same privileges as your own code. Transitive dependencies make up most of a typical dependency tree.
We resolved a small Node.js app with nine direct dependencies on October 15, 2026. It installed 518 packages, and 509 of them (98%) were transitive. Four in five sat at least two hops away from anything the app's authors chose. That is the core fact about transitive dependencies: the manifest you wrote describes a small fraction of the code you ship. Our position is that the transitive tree should be governed by resolution policy at the application, with lockfiles, release cooldowns, and install-time checks. Pinning every version inside libraries is the wrong place for that control.
What is a transitive dependency?
#A transitive dependency is any package in your build that you did not list yourself. If your app depends on A, and A depends on B, then B is a transitive dependency of your app. Every software dependency you add brings its own manifest, and the package manager follows those manifests recursively until it reaches packages with no dependencies.
The chain is easy to see with npm explain. This is an app that declares one dependency, express@4.17.1:
$ npm explain qs
qs@6.7.0
node_modules/qs
qs@"6.7.0" from body-parser@1.19.0
node_modules/body-parser
body-parser@"1.19.0" from express@4.17.1
node_modules/express
express@"^4.17.1" from the root project
qs@"6.7.0" from express@4.17.1
node_modules/express
express@"^4.17.1" from the root projectThe app never mentions qs, yet qs parses every query string the app receives. Two paths lead to it, and both are transitive. The full set of packages and edges is the project's dependency graph. A transitive dependency is any node in that graph more than one edge from the root.
Ecosystems name the same idea differently. Go marks such requirements in go.mod with an // indirect comment, which the Go modules reference defines as "no package from the required module is directly imported by any package in the main module." npm and pnpm docs say "subdependency." Maven says "transitive." The term also has an unrelated meaning in database normalization, where a transitive functional dependency is what third normal form removes. This page covers the software supply chain sense.
A transitive dependency that your code imports directly, without declaring it, is a phantom dependency. It works until the package that brought it in drops it.
How package managers resolve transitive dependencies
#Manifests declare constraints, not versions. A library declares a semver range such as "follow-redirects": "^1.15.6", and the package manager decides which concrete version satisfies it. Resolution computes the closure of every constraint in the tree and picks one answer. Libraries publish ranges on purpose. If every library pinned exact versions, two libraries sharing a dependency would force duplicate copies or unresolvable conflicts.
Where ecosystems differ is the rule for picking a version, and that rule decides how exposed you are to whatever was published most recently. However, even within ecosystems, algorithms can differ across versions of the same CLI implementation, and between different CLI implementations.
| npm | Maven | Go modules | pip | |
|---|---|---|---|---|
| Version chosen | Newest version matching each declared range, at resolution time | "Nearest definition" in the tree; first declaration wins ties | Highest of the minimum versions required (minimal version selection) | Newest version that satisfies every constraint |
| Multiple versions of one package | Yes, nested under node_modules | No, one per artifact | One per major version (separate module paths) | No, one per environment |
| Lock by default | package-lock.json | None; declared versions are soft requirements | go.mod plus go.sum hashes | None; pinned requirements or experimental pip lock |
| Force a transitive version | overrides in the root package.json | <dependencyManagement> | Require a higher version in go.mod, or replace | Constraints file (pip install -c) |
| New upstream release changes your build? | Yes, on any fresh resolution | Only if a nearer declaration changes | No | Yes, on any fresh resolution |
Verdict: Go's minimal version selection is the most reproducible design, because, in the Go reference's words, "the build list doesn't change when new versions of dependencies are released." npm and pip are the most exposed to a version published minutes ago. Maven's nearest-wins rule is stable but can silently select an older version. In the Maven documentation example, D 1.0 beats D 2.0 because its path is one hop shorter.
How deep do dependency trees go?
#The deepest path in a typical npm dependency tree is about 6 levels, and a quarter of the trees we measured reach 9 or 10. On October 15, 2026, we resolved lockfiles for 19 widely used npm packages with npm 10.9.4 (npm install --package-lock-only --ignore-scripts). We then walked each graph breadth-first from the root, counting platform-specific optional binaries only for linux-x64. Depth 1 is the package you install. Everything at depth 2 or deeper is transitive. We also resolved one composite app, shown in Figure 1.
The composite app declared nine dependencies: express, mongoose, axios, @aws-sdk/client-s3, and @slack/bolt, plus jest, eslint, typescript, and vite as dev dependencies. It resolved to 518 installed packages. By depth, the counts were 9 at depth 1, 98 at depth 2, 118 at depth 3, 121 at depth 4, 105 at depth 5, 31 at depth 6, 16 at depth 7, 8 at depth 8, 8 at depth 9, and 4 at depth 10. The median package sat at depth 4. 411 packages (79%) were at depth 3 or deeper, at least two hops from any package the authors chose.
The individual packages vary by two orders of magnitude:
| Package | Transitive packages | Max depth | Median depth |
|---|---|---|---|
| gatsby@5.16.1 | 1,344 | 9 | 4 |
| react-scripts@5.0.1 | 1,287 | 10 | 4 |
| @angular/cli@21.2.27 | 287 | 9 | 4 |
| jest@30.5.2 | 279 | 10 | 5 |
| @slack/bolt@5.1.0 | 108 | 8 | 4 |
| nx@23.3.0 | 99 | 2 | 2 |
| firebase@13.0.0 | 85 | 9 | 3 |
| eslint@10.12.0 | 76 | 6 | 3 |
| express@5.3.0 | 64 | 7 | 3 |
| webpack@5.111.1 | 62 | 5 | 3 |
| twilio@6.2.0 | 50 | 7 | 4 |
| axios@1.20.0 | 26 | 6 | 4 |
| @aws-sdk/client-s3@3.1152.0 | 25 | 4 | 3 |
| next@16.4.1 | 24 | 3 | 3 |
| @nestjs/core@12.1.3 | 21 | 6 | 3 |
| mongoose@9.11.2 | 18 | 6 | 3 |
| vite@8.3.4 | 16 | 3 | 3 |
| typescript@7.0.2 | 1 | 2 | 1.5 |
| lodash@4.18.1 | 0 | 1 | 1 |
The median package in our sample pulled in 62 transitive packages, and the median maximum depth was 6. nx is an outlier with a flat tree, for reasons covered below. Depth counts the installed package itself as 1, so a max depth of 10 means nine hops of transitive resolution.
Each transitive package is also a set of people. The 65 packages in the express@5.3.0 tree list 36 distinct npm maintainer accounts in their registry metadata. Any one of those accounts can publish a version that the next fresh install will resolve. The 2019 USENIX Security study of npm by Zimmermann et al. found the same shape across the whole registry: "Installing an average npm package introduces an implicit trust on 79 third-party packages and 39 maintainers." Their data ran through April 2018.
The tree also keeps moving after its root stops changing. react-scripts@5.0.1 was published in April 2022 and has not had a stable release since. Of the 1,214 distinct package versions its tree resolved to on October 15, 2026, 648 (53%) were published after react-scripts itself. The newest, baseline-browser-mapping@2.11.29, reached the tree through browserslist's ^2.11.26 range. It had been on the registry for 58 minutes when we resolved it.
Why transitive dependencies carry most of the risk
#Transitive dependencies are where most known vulnerabilities and most malicious code reach an application. A vulnerability or a payload sitting four hops down is invisible in your manifest, but it executes just the same.
The vulnerability side is well documented. When Log4Shell broke in December 2021, Google's Open Source Insights team counted 35,863 affected Java artifacts on Maven Central, more than 8% of the repository. Only about 7,000 of those depended on log4j directly. For more than 80% of affected packages, the vulnerable code was more than one level deep, "with a majority affected five levels down (and some as many as nine levels down)." The authors explained why that matters for patching: "The deeper the vulnerability is in a dependency chain, the more steps are required for it to be fixed."
The malware side is worse, because malware does not get a CVE, and a range resolves to it the moment it is published. The March 2026 axios compromise is the clearest case we have studied. An attacker with publishing access added a new dependency, plain-crypto-js@4.2.1, to two axios releases. That package's postinstall hook ran a dropper. The registry's own time fields record the sequence:
^1.13.5 inside the shaded window could select the malicious version.The sequence above, in text:
| Time (UTC) | Event |
|---|---|
| Mar 30, 23:59:12 | plain-crypto-js@4.2.1 published with a postinstall dropper |
| Mar 31, 00:05:41 | Socket's scanner flags plain-crypto-js@4.2.1 as malicious |
| Mar 31, 00:21:58 | axios@1.14.1 published, adding plain-crypto-js as a dependency |
| Mar 31, 01:00:57 | axios@0.30.4 published with the same change |
| Mar 31, 03:25:11 | npm replaces plain-crypto-js with a security holding package |
An app reached the payload through a chain like this: your app → @slack/bolt@4.6.0 (declares @slack/web-api: "^7.12.0") → @slack/web-api@7.15.0 (declares axios: "^1.13.5") → axios@1.14.1 → plain-crypto-js@4.2.1 → postinstall → node setup.js. Only axios and plain-crypto-js in that chain were malicious. Every other declaration was valid and typical.
Our analysis of the axios blast radius found the same ^1.x pattern in CI tools, CLIs, SDKs for Slack, Twilio, Contentful, and Snowflake, and MCP servers. Any fresh resolution during the window selected 1.14.1. That includes npx runs and CI jobs without a committed lockfile. Even npx foo@1.2.3 pins only foo. npm never publishes package-lock.json, so foo's own dependencies resolve against the registry as it stands at that minute. This is the general shape of a transitive supply chain attack: compromise one popular node, and every range above it delivers the payload.
The same structure shows up outside npm. In March 2026, we documented the GlassWorm campaign turning Open VSX extensions into transitive installers. An extension that looked benign at first publication added extensionPack or extensionDependencies entries in a later update, and the editor then installed a separate malicious extension automatically.
Pin or float? What the nx experiment showed
#Pinning every transitive version inside a library trades exposure to malicious releases for slower security fixes. The nx build tool ran that experiment in public in 2026, and the npm registry shows each step.
- nx@22.6.0 (March 18): 37 dependencies, including
axios: "^1.12.0", a range that admitted the malicious 1.14.1. - nx@22.6.5 (April 10): axios pinned to exactly
1.15.0. - nx@22.7.0 (April 24): 110 dependencies, every one an exact version. The publish step expanded nx's entire resolved tree into its manifest, so packages nx previously reached only through axios, such as
follow-redirects, became direct exact pins.
The pull request behind 22.7.0 is titled "supply chain hardening via transitive dependency pinning". It states the threat plainly: a compromised transitive dependency "can inject code into any nx@latest install, even if nx itself is secure."
The cost arrived quickly. nx@22.7.0 pinned follow-redirects@1.15.11. Eleven days earlier, on April 13, GHSA-r4q5-vmmm-2653 had disclosed a header leak in follow-redirects 1.15.11 and older, and 1.16.0 had shipped the fix. Users could no longer fix that with a lockfile refresh. They had to wait for nx or write overrides. Users filed an issue and opened a discussion in which one user counted 13 vulnerabilities in nx@22.7.1. nx@22.7.5 moved to follow-redirects 1.16.0 on May 27. As of nx@23.3.0 (October 7, 2026), nx still ships 97 exact pins and no longer depends on axios at all.
Express went the other way. express@4.17.1 pins qs to exactly 6.7.0. express@4.22.3 declares qs: "~6.16.0", a patch-level range, along with similar ranges for body-parser, send, cookie, and path-to-regexp.
We think the nx instinct was right and the mechanism was wrong for a library. Exact pins in a published package turn the library author into a bottleneck for every security fix downstream. npm's documentation makes the same argument about npm-shrinkwrap.json, the one lockfile npm will publish: "It's strongly discouraged for library authors to publish this file, since that would prevent end users from having control over transitive dependency updates." - and as of npm 12, shrinkwrap files can not be published at all. Flattening the tree into exact pins in package.json has the same effect. The control nx actually needed was a rule against resolving a version published twenty minutes earlier. That control now exists where it belongs, in the application's package manager. npm added minimumReleaseAge in 11.x releases in early 2026. pnpm 11 defaults it to 1,440 minutes. A one-day dependency cooldown at the application would have kept plain-crypto-js@4.2.1 out of every install, and it would have let follow-redirects 1.16.0 in on schedule. Libraries should publish ranges. Applications should own the lock and the policy.
How do you fix transitive dependency vulnerabilities?
#To fix a vulnerable transitive dependency, find the path that pulls it in, then use the least invasive change that moves it to a fixed version. In order, try a lockfile refresh within the existing range, then an upgrade of the direct parent, then an override. Patching in place and proving the code unreachable are for when none of those work. Start with npm explain <pkg>, pnpm why <pkg>, mvn dependency:tree, or go mod why -m <module> to see who requires the package and with what constraint. The constraint decides which fix is available.
The parent's range already admits the fix. This is the cheapest case, and it needs no manifest change at all. The commands below reproduce it. They build a lockfile as the registry stood on January 15, 2025, audit it, and refresh only the two vulnerable packages:
$ cat package.json
{ "name": "app", "private": true, "dependencies": { "axios": "1.7.9" } }
$ npm install --package-lock-only --before=2025-01-15
$ npm ls --all
app@1.0.0
`-- axios@1.7.9
+-- follow-redirects@1.15.9 # GHSA-r4q5-vmmm-2653 (moderate)
+-- form-data@4.0.1 # GHSA-fjxv-7rqg-78g4 (critical)
...
$ npm update follow-redirects form-data --package-lock-only
$ npm ls --all
app@1.0.0
`-- axios@1.7.9
+-- follow-redirects@1.16.1
+-- form-data@4.0.6
...axios declares ^1.15.6 and ^4.0.0 for those two packages, so both fixed versions were already in range. The remaining audit finding was axios's own advisory, which only an axios upgrade fixes. pnpm 11 automates this path with pnpm audit --fix=update. A refresh is a fresh resolution, though. It pulls the newest version in range, which is exactly how axios@1.14.1 spread, so pair it with a release-age cooldown.
The parent pins exactly. express@4.17.1 pins qs@6.7.0, so no refresh can move it. npm audit fix upgraded express within ^4.17.1 to 4.22.3, which cleared all 7 flagged packages. That cleanup also added 18 packages and changed 23. Fixing a transitive dependency through its parent often means accepting a much larger diff than the fix itself.
The parent will not move. An override forces a version from the root:
{
"dependencies": { "express": "4.17.1" },
"overrides": { "qs": "6.16.1" }
}$ npm ls qs
app@1.0.0
`-- express@4.17.1
+-- body-parser@1.19.0
| `-- qs@6.16.1 deduped
`-- qs@6.16.1 overriddenqs dropped out of the audit, and express@4.17.1 still parsed ?a[b]=c correctly in our smoke test. That test does not prove compatibility. The override runs a parent against a version it never declared or tested. npm applies overrides only from the root package.json, so they do nothing for tools run outside the project. Overrides also accumulate. In the nx discussion, one user warned that override lists can grow to thousands of entries.
| Fix | Use when | What it costs | What it does not cover |
|---|---|---|---|
| Refresh within range (npm update <pkg>, pnpm audit --fix=update) | The parent's range already admits a fixed version | Resolves the newest version in range, possibly hours old | Parents that pin exactly |
| Upgrade the direct parent | A newer parent requires the fixed version | Larger diff; express example added 18 and changed 23 packages | Unmaintained parents, fixes only on a new major |
| Override (overrides, resolutions, <dependencyManagement>, pip -c) | The parent pins and will not release | Untested combination; entries pile up and outlive the reason | Tools resolved outside your project root |
| Patch in place (pnpm patch, patch-package) | No compatible fixed version exists | You own the patch until upstream catches up | Anyone else running the unpatched package |
| Prove it unreachable (reachability, VEX) | The vulnerable function is never called | Analysis must be rerun as code changes | Malware, which runs at install time whether or not you call it |
Verdict: refresh first and upgrade the parent second. Treat overrides as dated, commented, temporary entries. Use reachability analysis to decide which findings deserve any of this work, because most CVE alerts do not. Socket reports that precomputed reachability marks about 60% of CVEs as unreachable, and full application analysis typically about 80%. socket fix computes the smallest set of upgrades across the chain, including non-vulnerable intermediate parents. Per its documentation, it writes overrides only when you pass --allow-overrides, because "Overrides quickly bloat package.json and are difficult to remove later."
The part lockfiles do not solve
#Lockfiles make an existing transitive tree reproducible. They do not make a new resolution safe, and they cannot tell you afterward what a past resolution contained. Once npm removed axios@1.14.1, every reinstall produced a clean tree. A CI job that ran npx during the roughly three-hour window left no lockfile behind. And npm keeps no public history of what a given range resolved to at a given minute.
So the useful question during an incident is not "do we depend on axios?" It is "did anything we executed resolve axios between 00:21 and 03:25 UTC?" Most teams cannot answer it. Until registries and package managers record resolutions as durable, queryable events, the transitive tree stays the part of your software that you can measure today and cannot audit for yesterday. That argues for blocking bad versions at resolution time, before install scripts run, rather than reconstructing exposure after the fact.
