Your package.json lists 14 dependencies. Run npm ls --all against the lockfile it produced and you'll see 1,300 resolved packages. You chose 14 of them. A maintainer you've never heard of, three or four layers down, chose the other 1,286.
Almost every CVE your scanner reports this month lives in that second group. Not because code three layers down is worse than code you picked yourself. There's simply so much more of it.
How lopsided that split gets varies enormously by ecosystem, so don't reach for a universal number. Zimmermann et al.'s 2019 USENIX Security study found the average npm package reaches roughly 80 transitive dependencies — against a handful of direct ones — while a later ten-ecosystem survey put CocoaPods at an amplification factor of about 0.32×, or under a quarter of its resolved tree. npm and CocoaPods are not the same problem. There's no single "X% of your tree is transitive" figure that holds across languages, so treat any such number, ours included, as ecosystem-specific rather than universal.
What We'll Cover
- Direct vs. transitive: what your lockfile actually contains
- Why transitive CVEs dominate scanner output
- The four ways to actually fix a transitive CVE
- Why
npm audit fix --forceis the wrong reflex - Prioritizing a transitive finding you can't patch today
- What GeekWala shows for transitive findings
- FAQ
Direct vs. Transitive: What Your Lockfile Actually Contains
Prose descriptions of a dependency tree are easy to skim past and easy to forget. Real output isn't. Here's the same distinction (direct dependencies you declared versus the transitive packages your package manager resolved on top of them) in three ecosystems.
npm: npm ls --all
npm ls on its own only shows the top level; that's the shallow view npm assumes you want. --all overrides that and walks the full resolved tree:
$ npm ls --all
my-app@1.0.0 /home/user/my-app
├─┬ express@4.19.2
│ ├── accepts@1.3.8
│ │ ├── mime-types@2.1.35
│ │ │ └── mime-db@1.52.0
│ │ └── negotiator@0.6.3
│ ├─┬ body-parser@1.20.2
│ │ ├── bytes@3.1.2
│ │ ├── content-type@1.0.5
│ │ ├── qs@6.11.0
│ │ └── raw-body@2.5.2
│ └── send@0.18.0
│ └── mime@1.6.0
└─┬ jest@29.7.0
└─┬ @jest/core@29.7.0
└── jest-config@29.7.0
One line in package.json (express, or jest) produced everything indented beneath it. Every indented entry is transitive: something express or jest depends on, not something you installed.
Python: pipdeptree (or uv tree)
pip install has no tree output at all; it resolves and installs, it doesn't show you what it resolved. For that you need a dedicated tool: pipdeptree on pip, Poetry, or pip-tools projects, or uv tree on uv projects.
$ pipdeptree
Flask==3.0.3
├── blinker==1.8.2 [required: >=1.6.2]
├── click==8.1.7 [required: >=8.1.3]
├── itsdangerous==2.2.0 [required: >=2.1.2]
├── Jinja2==3.1.4 [required: >=3.1.2]
│ └── MarkupSafe==2.1.5 [required: >=2.0]
└── Werkzeug==3.0.3 [required: >=3.0.0]
└── MarkupSafe==2.1.5 [required: >=3.0.0]
Note MarkupSafe appears twice: resolved separately under two different parents, which is normal and comes up again in the FAQ below. uv tree shows the same relationship with its own formatting:
$ uv tree
myapp v0.1.0
└── requests v2.32.3
├── certifi v2024.7.4
├── charset-normalizer v3.3.2
├── idna v3.7
└── urllib3 v2.2.2
Maven: mvn dependency:tree
$ mvn dependency:tree
[INFO] com.example:my-app:jar:1.0.0
[INFO] +- org.springframework.boot:spring-boot-starter-web:jar:3.3.2:compile
[INFO] | +- org.springframework.boot:spring-boot-starter:jar:3.3.2:compile
[INFO] | | +- org.springframework.boot:spring-boot:jar:3.3.2:compile
[INFO] | | \- org.springframework:spring-core:jar:6.1.11:compile
[INFO] | \- org.springframework.boot:spring-boot-starter-json:jar:3.3.2:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-databind:jar:2.17.2:compile
[INFO] \- org.springframework.boot:spring-boot-starter-test:jar:3.3.2:test
One spring-boot-starter-web entry in your pom.xml and jackson-databind (a package your team never named) arrives two levels down.
Three ecosystems, three tree shapes, one common thread: the CVE that shows up in your scan almost never sits on the first line of any of these trees. For the native scanner tool in the other five ecosystems, see the ecosystem-by-ecosystem guide — this article only needs three trees to make the point.
Why Transitive CVEs Dominate Scanner Output
Depth compounds. If a direct dependency pulls in 10 packages, and each of those pulls in 10 more, you're at 100 transitive packages two levels down, before anything you wrote ships. Real trees aren't that uniform (some packages carry zero dependencies, others carry 40), but the compounding effect is real: a handful of direct choices explodes into hundreds of resolved packages by the time npm, pip, or Maven finishes walking the graph.
More packages means more CVEs, full stop. Not because deeper packages are inherently riskier, but because vulnerability counts scale with package counts. This is exactly why roughly 6% of published CVEs are ever exploited matters here: depth increases the count of findings in your scan, not the fraction of them that pose real risk. A 40-package tree and a 400-package tree carry roughly the same proportion of exploited vulnerabilities; the 400-package tree just surfaces ten times as many total findings to sort through.
That's the actual mechanism behind alert fatigue on a transitive-heavy scan. Nobody reads all 380 unprioritized transitive findings line by line. They see the number, conclude the scanner is noise, and stop looking — which is worse than not scanning, because now the team believes it's covered when it isn't.
The Four Ways to Actually Fix a Transitive CVE
This is the part that actually matters. Once you've found a transitive CVE, here are your options, roughly in order of preference.
1. Upgrade the direct parent
The cleanest fix, when it's available. If body-parser ships a patch that bumps its internal qs pin past the vulnerable version, you upgrade body-parser, not qs directly, since your package manager will just re-resolve qs back to whatever body-parser's own manifest allows. This works when the parent has actually released a fix. It fails, obviously, when the parent hasn't: an abandoned or slow-moving maintainer leaves you stuck no matter how badly you need the patch.
Once a fix does exist, the registry advisory that a scanner reads is usually not the bottleneck: we measured the gap directly across 48,162 CVEs and 8 ecosystems and found a median of 0.01 days between an NVD disclosure and the matching registry advisory. If your scanner still shows a transitive CVE as unfixed the same day the parent released a patch, that's a re-scan timing issue, not an advisory-lag one.
2. Force the resolution
When the parent hasn't caught up, every major package manager gives you a way to pin the transitive version yourself. The mechanism and its syntax differ, and they've drifted across versions in ways worth naming precisely:
- npm (8.3+):
overridesinpackage.json. - Yarn (Classic and Berry):
resolutions. - pnpm: the key is also
overrides, but where it lives has moved. Through pnpm 10 it sat underpnpm.overridesinpackage.json; as of pnpm 11, pnpm stopped reading that field, and the override has to live as a top-leveloverrides:block inpnpm-workspace.yamlinstead. pnpm also honors a Yarn-styleresolutionsfield, and since pnpm 8.9.0 (October 2023) it merges the two rather than letting one replace the other. Don't treat them as mutually exclusive. - Maven:
dependencyManagement. - Cargo's
[patch]is a different animal from the other four. It's a source-replacement mechanism: it redirects where Cargo fetches a crate (a git fork, a local path, an unpublished version), applied transitively. What it can't do is override semver:[patch]can only land a version your existingCargo.tomlrequirements already permit. Forcing a semver-incompatible transitive bump needs the direct parent to relax its requirement, orcargo update -p <package> --precise <version>within the range your manifest already allows.
The syntax for the two most common cases. npm, pinning a transitive lodash past CVE-2021-23337, a command-injection flaw in the template function that this exact version pin fixes:
{
"overrides": {
"lodash": "4.17.21"
}
}
Maven, doing the same for a transitive jackson-databind pulled in by a Spring starter:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
</dependencies>
</dependencyManagement>
The failure mode is identical across every one of these mechanisms: you've now pinned a version the direct parent never tested against. Most semver-compatible overrides work fine. Occasionally one doesn't, and you find out in production instead of in the parent's CI, because the parent's test suite never ran against the version you forced.
3. Replace or drop the direct parent
More viable than most teams assume, especially for thin wrapper packages. If a direct dependency exists mainly to save you a handful of lines and it's pulling in a vulnerable transitive tree with no fix in sight, dropping it and inlining those lines yourself is often less work than it sounds, and it removes the whole transitive branch at once instead of patching around it.
4. Document and defer with VEX
When the vulnerable code path genuinely isn't reachable in your application, upgrading or overriding is wasted effort. VEX exists for exactly this case: a structured way to say "we have this CVE, we assessed it, and it doesn't affect us here," instead of an unexplained suppression the next auditor has to re-litigate from scratch.
Why npm audit fix --force Is the Wrong Reflex
npm audit fix --force doesn't ask what your build can tolerate. It rewrites your lockfile to whatever satisfies the advisory, even if that means a major version bump three levels up the tree.
Say your project depends on babel-loader@8, which pins loader-utils@^1.4.0, and a moderate-severity CVE lands against that 1.x line. Run npm audit fix --force and here's a plausible diff:
- "loader-utils": "^1.4.0"
+ "loader-utils": "^3.2.1"
That satisfies the advisory. It also breaks babel-loader@8, which was built against the 1.x API and never shipped support for 3.x; the fix command has no way to know that, because it isn't reasoning about your build, only about the advisory it's trying to close. npm does print a SEMVER WARNING inline when this happens, but it's a warning, not a gate, so nothing stops the command from writing the break to disk anyway.
That's why the break shows up in CI instead of at the terminal where you ran the command. You see clean audit output, commit the lockfile change, push, and the build fails several steps later in a pipeline that has nothing to do with the CVE you thought you'd fixed. Reading the diff --force actually proposes, before accepting it, catches this every time. Running it blind doesn't.
Prioritizing a Transitive Finding You Can't Patch Today
Not every transitive CVE has a fix available right now. Sometimes the parent hasn't patched, an override feels too risky for that particular package, and dropping the dependency isn't realistic on your timeline. You still need to decide how urgently to treat the finding, and the same 3-signal framework we use for direct findings applies without modification:
- CISA KEV membership first. If the CVE is in the KEV catalog, someone is exploiting it right now — transitive or not. Treat it as a same-day fix regardless of how deep in the tree it sits.
- EPSS ≥ 0.1 second. A transitive CVE with real exploitation probability doesn't get a pass for being three layers down. Attackers targeting a popular transitive package don't know or care how it ended up in your tree.
- Reachability third. This signal matters more for transitive findings than for direct ones. A vulnerable function buried in a package your code never actually calls, directly or indirectly, is lower urgency than the same CVE sitting on your hot path. It's also the hardest of the three to check without tooling that traces call graphs, which most scanners (ours included) don't do today.
Depth alone isn't a signal. "Four levels deep" tells you nothing about risk. It tells you how the tree happened to resolve.
If the whole idea of triaging 200 findings by hand still sounds unmanageable, start with what to do when your scanner finds 200 CVEs for the baseline sort, then apply the reachability tiebreaker above once you're down to the findings that are actually transitive.
What GeekWala Shows for Transitive Findings
One limitation, stated plainly because the honest version is more useful than the flattering one: GeekWala doesn't track which direct dependency introduced a given transitive package. We parse your lockfile (which does enumerate every transitive package resolved into it) into a flat list of {ecosystem, name, version, sourceFile} entries — the source file tells you which manifest a package came from, but not which package pulled it in — and a transitive finding gets reported exactly like a direct one, with no parent chain attached. There's no dependency-graph construction happening behind the scenes to reconstruct that chain for you. If you need to trace which direct dependency pulled in a flagged package, run npm ls <package> (or the Python or Maven equivalent from the tree section above) against your own project — it'll show you the path in seconds. The industry advice is "keep your dependencies updated." For transitive dependencies that advice isn't actionable — you can't update a package you didn't choose. The actionable version is "know which parent to upgrade," and almost no tooling tells you that by default, GeekWala included today. That's a real gap, worth naming rather than glossing over.
FAQ
Can I ignore a transitive CVE if I never call the vulnerable function?
Deprioritize, don't delete. If you've actually verified the vulnerable function is never reached, directly or transitively, that's a legitimate reason to push the fix down your queue: document it with a VEX statement so the reasoning survives the next audit instead of getting silently forgotten. The one override: a CVE with confirmed active exploitation on CISA KEV still deserves urgency even when you believe the path is unreachable, because unreachability assessments turn out wrong more often than teams like to admit.
Why does my lockfile have two versions of the same package?
Because two different parts of your tree required incompatible version ranges of that package, and your package manager resolved each independently instead of forcing one winner. One direct dependency might need semver@^6, while another, three layers away, needs semver@^7. npm, and most resolvers, will install both rather than break either requirement. It isn't a bug in your lockfile; it's what non-flat dependency resolution looks like when requirements genuinely conflict. It does mean a single CVE can require patching in two separate places if both installed versions are affected.
Does removing dev dependencies from the scan help?
It narrows the list, and for production-risk purposes that's often the right call: a vulnerable dev-only linter plugin isn't shipping to your users. But "help" depends on what you're optimizing for. If the goal is a shorter list to review weekly, scope to production dependencies. If the goal is broader supply-chain integrity, dev dependencies run inside your build pipeline too, and a compromised one can still poison a release before it reaches a single user.
Why do npm and PyPI produce such different tree depths?
Different resolution cultures. The npm ecosystem favors small, single-purpose packages, so a typical Node project pulls in far more total packages, at greater depth, than an equivalent Python project solving the same problem. Python's standard library covers more ground natively, and PyPI's ecosystem norms lean toward fewer, larger dependencies. Neither pattern is a security ranking — more transitive depth doesn't mean more risk. It means more surface to enumerate, which is exactly why the tree commands earlier in this article matter regardless of which ecosystem you're working in.
The direct/transitive split explains something that otherwise looks contradictory: "we have no vulnerabilities" and "our scanner found 200" can both be true statements about the exact same repository on the same day, depending on whether anyone bothered to resolve the full tree. Direct-only tooling gives you the first sentence. A lockfile-aware scan gives you the honest one.
Scan your dependencies and see which sentence describes your project — no account required, and the scan reads your full resolved lockfile, transitive packages included.


