Run npm ls --all 2>/dev/null | wc -l on a mid-size Express application and you'll see a number that surprises most engineers the first time: somewhere north of 800 entries, often closer to 1,200. That's not a bug. That's the npm ecosystem doing what it was designed to do — letting every package pull in exactly what it needs without negotiating with your other packages.
The problem isn't the dependency count. The problem is that most SCA (software composition analysis) tools treat all 800+ entries as equally relevant to your security posture. They're not.
Why Transitive Deps Create a Specific Scoring Problem
Your direct dependencies — the ones in your package.json — are packages you consciously chose. You use their APIs directly. When a CVE lands in [email protected] or [email protected], you have a direct, traceable path from that vulnerability to your running code.
Transitive dependencies are a different category entirely. When you install webpack, you also get enhanced-resolve, tapable, acorn, ajv, and about 60 other packages — none of which your application code calls directly. Webpack calls them. Your entry points have no direct line to them.
Here's the crux: a CVE in [email protected] (a JSON schema validator that webpack uses internally) is only relevant to your running application if there's a code path from one of your HTTP endpoints, through webpack's runtime, into the specific ajv function that's vulnerable. In most production Node.js services, webpack is a build-time tool — it doesn't run in production at all. The CVE is irrelevant to your deployed code, full stop.
Yet every SCA tool I've seen will flag it. Some will flag it as HIGH.
Anatomy of a Typical npm Dependency Tree
We analyzed the dependency manifests from a sample of Node.js applications (Express + NestJS services, primarily) and found a pattern that held reasonably consistently:
- Direct dependencies: typically 20–60 packages listed in
package.json - First-level transitives: 80–200 packages pulled in by your direct deps
- Second-level and deeper: the remaining 600–1,000+ packages in a full
node_modulestree
CVE advisories in npm packages are distributed roughly proportionally to package popularity, not to depth in your tree. That means a significant fraction of your advisory hits are sitting 3–5 hops deep in a dependency chain that your production code never traverses.
Take a common scenario: a NestJS API service at a growing B2B SaaS company. Its node_modules contains 1,047 packages after a full install. Running Patchlynx's call-graph analysis against the production build (not the dev build — more on that below) reduced the reachable package set to 94. Of those 94, 11 had CVE advisories. Of those 11, 4 had confirmed call paths from an HTTP entry point to the vulnerable function. The triage queue went from 43 advisories to 4 actionable patches.
The devDependencies Conflation
There's a frequently overlooked split that amplifies the noise: devDependencies.
When you run npm audit, it scans your entire node_modules directory — including packages that only exist because of your test framework, your bundler, your linters, and your type generation tooling. None of those packages ship to production. A CVE in jest-worker is a vulnerability in your CI environment, not in your deployed service.
Tools that scope to --production help, but they don't solve the underlying problem: even within production dependencies, the question isn't "is this package installed?" — it's "does a live request to any of my endpoints eventually invoke the vulnerable code path?"
We're not saying devDependency CVEs don't matter. A supply chain attack that poisons your build toolchain is a real threat class. But it's a different threat class from a runtime vulnerability in a reachable dependency, and it should be triaged differently — not dumped into the same list.
How Call-Graph Resolution Works for npm
Patchlynx builds the reachable call graph for a Node.js application in three passes:
Pass 1: Entry-point enumeration. We read the Express/NestJS/Fastify router configuration to identify all HTTP endpoints. Each route handler is a root node in the call graph. For a typical mid-size service this produces 30–120 entry points.
Pass 2: Static call-graph traversal. Starting from each entry point, we walk the call graph — following function calls, module requires, and import resolutions — through your application code and into node_modules. We build a reachability set: every package and function that can be reached from at least one entry point.
Pass 3: Advisory intersection. We cross-reference the reachable package set against OSV and NVD. Only packages in the reachable set get scored. Packages outside that set are marked UNREACHABLE and moved to a lower-priority review queue — not ignored, but deprioritized appropriately.
The output is a ranked list where position 1 is the CVE with the shortest confirmed call path from a high-traffic entry point, combined with CVSS score and fix availability.
Why Depth-Based Heuristics Fail
Some teams try to manage transitive noise with a heuristic: only triage CVEs in packages at depth ≤ 2 in the dependency tree. This is understandable — but it breaks in two common ways.
First, depth in the npm tree doesn't correlate with reachability. A package at depth 5 might be in your hottest code path if it's pulled in by your HTTP framework's core router. A direct dependency you installed might be used only in a feature flag that's been disabled for 18 months.
Second, npm deduplication means the same package might appear at multiple tree depths. lodash could be hoisted to depth 1 in your flattened node_modules while being semantically a transitive dependency of 12 other packages. Depth becomes a proxy for nothing useful.
Reachability from entry points is the only heuristic that actually maps to exploitability. Everything else is a noisy approximation.
Practical Triage Workflow for Node.js Teams
If you're running a Node.js service and want to bring your advisory queue under control without waiting for a tool change, here's what actually works today:
Start by running your production build — not npm install with full devDependencies — and scoping your SCA scan to only the packages that ship. Most CI configurations have a flag for this. It cuts noise by 30–40% immediately.
Next, identify your highest-traffic entry points. These are the routes that real users hit — not health check endpoints, not internal admin routes. If a CVE's call path only reaches through a /admin/debug route that's behind an IP allowlist, it's a different priority than one reachable from /api/user/login.
Finally, separate the "unreachable but monitored" queue from your sprint work. Unreachable CVEs are not zero risk — an architectural change six months from now might make a currently-unreachable package reachable. But they don't belong in the same triage flow as confirmed reachable vulnerabilities. Keep the list, review it quarterly, and don't let it pollute your sprint velocity.
What We Watch in the npm Ecosystem
A few patterns we've observed that are worth keeping on your radar:
Peer dependency implicit loads. Some packages use peerDependencies to declare optional capabilities that get loaded at runtime if present. This creates call-graph edges that pure static analysis can miss — you need to know which peer deps are actually installed in a given environment.
Dynamic require() calls. require(someVariable) or require(\`./plugins/${name}\`) creates module boundaries that static analysis can't always resolve. We handle the common plugin-loader patterns but dynamic require with fully variable paths is a known gap. We document where our analysis goes conservative versus approximate.
Monorepo cross-package calls. In a Turborepo or Nx workspace, packages can call each other across workspace boundaries. We build a unified call graph across the workspace when you connect a monorepo, but the entry-point discovery is more complex — you need to tell us which workspace packages are the API-facing services versus internal utilities.
Node.js dependency graphs aren't going to get simpler. The ecosystem's composability is its strength, and that strength comes with a transitive footprint that scales nonlinearly. The answer isn't to audit less — it's to audit the right things first.