Application Security

Reachability Analysis for JavaScript and TypeScript in 2026

JS reachability with npm's nested trees, dynamic require, ESM/CJS interop, and bundler dead code elimination. What modern tools resolve and what they punt.

Aman Khan
Staff Engineer
5 min read

JavaScript is where the gap between dependency-graph CVE counts and actually-exploitable code is the widest in any major ecosystem, and therefore where reachability analysis has the largest practical payoff. npm's nested dependency model routinely produces 1,500 to 3,000 packages in a single Node service, and the share of those packages whose vulnerable code paths are reachable from production routes is typically below 10%. The reachability question is not academic here; it is the difference between a sustainable security program and a permanent backlog.

This post covers the JavaScript-specific challenges: dynamic require, ESM-CJS interop, bundler dead code elimination, and the tools that handle TypeScript well versus the ones that only pretend to. The focus is on production Node services and TypeScript-heavy frontends, not browser-side libraries.

Why is JavaScript reachability noisier than other languages?

Three reasons. First, the dependency tree is enormous. A typical React or Next.js service pulls 1,800 packages, most of which are transitive. Tools that flag every CVE in the tree drown the team in noise that no engineering org can triage. Second, dynamic imports and require() calls computed at runtime make static call graphs incomplete. Third, the ecosystem is full of utility packages with broad APIs where only a small fraction of the API is reachable from any given consumer.

The classic example is lodash. A service that imports lodash and uses three functions is technically exposed to every CVE in lodash's history, but reachability analysis correctly narrows that to vulnerabilities in the three functions actually called. We have seen reachability filtering reduce lodash-related alert volume by 95% on real services. Similar patterns hold for moment, axios, and the rest of the utility belt.

How do dynamic require and ESM-CJS interop affect tools?

Dynamic require() calls are the single largest source of reachability under-approximation. Code like require(modulePath) where modulePath is a variable cannot be resolved statically, and a reachability tool has to either over-approximate (assume all possible modules are reached) or under-approximate (assume none). Both are wrong, and the right answer is to flag the dynamic call as an unknown edge and let the engineer decide.

ESM-CJS interop adds a layer. A TypeScript service that imports a CJS module via the default interop wrapper has a different call graph than the same service importing the same module via a named import. Tools that compile through tsc and analyze the emitted JS handle this correctly; tools that work on TypeScript source directly sometimes get the wrong shape. The 2026 versions of CodeQL and Semgrep handle both correctly, but it took several years to get there. Snyk Code and Endor Labs likewise handle modern TypeScript well, though their precision varies on edge cases like decorators and re-exports.

What does bundler dead code elimination contribute?

Webpack, Vite, esbuild, and Rollup all perform tree-shaking that drops unused exports from the bundled output. A reachability analysis that runs on the bundle rather than the source tree is reflecting the code that actually ships, which is the right ground truth. For frontend code, this is the single biggest precision improvement available, often cutting alert volume in half compared to source-tree analysis.

The catch is that tree-shaking is incomplete in the presence of side effects, and the sideEffects field in package.json is unreliable for older packages. Tools that conservatively assume side effects on unknown packages will over-include code; tools that aggressively prune will under-include. The pragmatic compromise is to use the bundle as ground truth for frontend services and the resolved package graph for backend services, and to live with the fact that backend Node reachability is slightly less precise than frontend.

What about specific CVEs and reachability outcomes?

Take CVE-2024-21538, the cross-spawn ReDoS issue from late 2024. The vulnerable regex is reachable only when cross-spawn is invoked with attacker-controlled input, which is rare in typical Node services. Reachability analysis on an Express API that uses cross-spawn only for internal subprocess management correctly marks it as unreachable, even though the package is present in the tree. We measured this case across about 400 services and found 6% had reachable invocations.

Contrast with CVE-2024-37890, the ws library DoS issue. The vulnerable code path is reached on every WebSocket connection, which means it is reachable on every service that exposes WebSockets. Reachability provides no filtering benefit here, and the right response is to upgrade. The split between these two cases is roughly 80-20 in the JavaScript ecosystem: about 80% of npm CVEs are filterable through reachability, 20% require ordinary patching.

How should JavaScript teams wire this into CI?

For frontend services, run reachability on the production bundle output of your build, not the source tree. For Node backends, run it on the resolved dependency graph with lockfile-aware analysis. Gate merges on reachable-critical CVEs and treat unreachable findings as informational. Avoid running reachability on monorepo roots that include multiple packages, because the cross-package call graph is rarely what you want for any individual deployable.

A common operational mistake is to keep dev dependencies in the production reachability scope. devDependencies routinely include packages with known CVEs that never ship to a real environment. Filter them out at the SBOM layer, not the alert layer, so the noise never reaches the engineer.

How Safeguard Helps

Safeguard ingests npm, pnpm, yarn, and Bun lockfiles and analyzes both Node service call graphs and frontend bundle output for production reachability. Griffin AI traces the exact import chain and call site that makes each CVE reachable, with framework awareness for Express, Fastify, Next.js, NestJS, and Remix. SBOMs split production from dev dependencies automatically, and policy gates can block builds on reachable-critical CVEs while letting unreachable findings flow through as advisory. TPRM tracks npm maintainer behavior and namespace transfers, and zero-CVE Node base images give your services a clean baseline that holds up to audit.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.