Fixing npm Transitive Vulnerabilities: Overrides vs Direct Upgrades
Most vulnerabilities in a JavaScript project are not in packages you chose. They are two, five or ten levels down, pulled in by something you did choose. The advisory names a package you have never heard of, the fix is a version you cannot declare directly, and the scanner's suggested remediation is "upgrade to 1.2.6."
There are two ways to fix an npm transitive vulnerability: force the vulnerable package to a safe version with an override, or upgrade the direct dependency that pulls it in until the safe version comes with it. Both work. They fail in different ways, and picking the right one for each finding is most of the job. This guide walks through both, with the commands and the trade-offs.
Step 1: find out why the package is there
Before choosing a fix, find the path from your manifest to the vulnerable package. npm will tell you:
npm explain minimist
or, for the full tree:
npm ls minimist --all
The output shows every chain from a direct dependency down to each installed copy. Three things matter in it:
- How many paths lead there. One direct dependency pulling the package in is a simple fix. Six different parents means six places a fix has to land.
- Which versions are installed. npm can install several copies of the same package at different versions in different parts of the tree. Only the vulnerable ones need fixing.
- Whether it is a runtime or dev dependency. A vulnerable package that only exists in your test tooling is a different risk from one that ships to production.
Option 1: the direct upgrade
The cleanest fix is usually to upgrade the direct dependency that pulls in the vulnerable package, to a version whose own dependency range already includes the fixed release.
Say your app depends on build-tool@3.1.0, which depends on some-lib@^1.2.0, which resolved to the vulnerable 1.2.4. If build-tool@3.2.0 requires some-lib@^1.2.6, upgrading build-tool fixes the finding and leaves you with a normal, declared dependency tree.
Why the direct upgrade is usually better
- The resulting tree is one the upstream maintainers tested. You are not combining versions nobody else runs.
- Nothing in your manifest overrides the resolver, so the next person reading
package.jsonis not confused by a forced version with no explanation. - It keeps working as the tree changes. An override is a standing exception that has to be revisited.
Where it goes wrong
- The smallest fixing version of the parent may be a major release. A direct upgrade that crosses a major version can mean API changes in code you actually call.
- The parent may be abandoned. If no version of the parent pulls in a fixed child, there is no direct upgrade to take.
- Finding the right version is tedious. You need the smallest version of the parent whose resolved tree no longer contains the vulnerable child, and that means checking the dependency ranges of each candidate release, not just the latest.
The last point is where most teams give up and reach for the latest version, which drags in far more change than the fix needs. The right target is the smallest bump that resolves the finding, not the newest release.
Option 2: the override
When no reasonable direct upgrade exists, you can tell the package manager to use a specific version of the transitive package regardless of what its parent asks for. Each package manager spells it differently.
npm overrides
npm supports an overrides field in package.json, documented in the npm package.json reference. To force every copy of a package to a fixed version:
"overrides": { "some-lib": "1.2.6" }
To scope the override to one parent only:
"overrides": { "build-tool": { "some-lib": "1.2.6" } }
Scoping is worth the extra line. A global override changes every copy in the tree, including copies under parents that were never vulnerable and may not work with the forced version.
Yarn resolutions
Yarn uses a resolutions field with the same idea:
"resolutions": { "some-lib": "1.2.6" }
pnpm overrides
pnpm reads overrides from a pnpm section in package.json, and supports version selectors on the left-hand side, so you can target only the vulnerable range:
"pnpm": { "overrides": { "some-lib@<1.2.6": "^1.2.6" } }
After any override, reinstall and confirm the lockfile changed the way you expected: npm install followed by npm ls some-lib --all.
Where overrides go wrong
- You are running an untested combination. The parent declared a range for a reason. Forcing a version outside it can break at runtime, not at install, and only on a code path your tests do not cover.
- Overrides outlive their purpose. A year later the parent has moved on, the override pins a version that is now old or vulnerable itself, and nobody remembers why it is there.
- They hide the real fix. An override makes the scanner quiet without moving the project to a maintained version of the parent.
If you do add an override, leave a comment in the PR description, or a note in your repository's dependency docs, saying which advisory it addresses and when it can be removed. package.json does not allow comments, so the explanation has to live somewhere else.
Choosing between them
A simple decision order works for most findings:
- Is the vulnerable package reachable from your code, or only present? If your code never reaches the vulnerable function, the fix is still worth doing, but it can wait for the next planned upgrade rather than jumping the queue.
- Is there a direct upgrade within the current major version? Take it.
- Is the smallest fixing direct upgrade a major version? Weigh the migration against an override. For a critical, reachable finding, a scoped override now and a planned major upgrade later is a reasonable split.
- Is there no direct upgrade at all? Override, scoped to the affected parent, and open a ticket to replace the abandoned dependency.
Whichever you choose, the fix is not done until the build and the tests pass with it. A dependency change that installs cleanly and breaks at runtime is worse than the finding it resolved.
What npm audit fix does, and does not do
npm audit fix applies semver-compatible updates that resolve advisories. It will not cross a major version unless you pass --force, and --force will happily install breaking major upgrades to make the report clean. It also reports against what is installed, not against what your code reaches. It is a useful first pass. It is not a remediation strategy for a large tree.
A transitive vulnerability checklist
- Trace every path to the vulnerable package with
npm explainbefore changing anything. - Prefer the smallest direct upgrade that resolves the finding over the latest release.
- Scope overrides to the parent that needs them, never globally by default.
- Record why every override exists and when it can go.
- Commit the lockfile and install with
npm ciin CI so the fix you tested is the fix you ship. - Run the build and tests before merging any dependency fix.
- Review overrides quarterly and remove the ones the tree no longer needs.
How Heeler fixes transitive npm vulnerabilities
Heeler resolves the actual installed graph from your manifests and lockfiles, direct and transitive, which maps the path from a vulnerable package up to a dependency you control. For a transitive finding, SCA Auto-fix finds the smallest bump to a direct dependency, the vulnerable package's ancestor in your manifest, that pulls in a fixed version. It shows that alongside the transitive override, so a reviewer can pick. When no direct upgrade resolves the finding, Heeler says so rather than proposing a change that will not fix it. The same direct-upgrade approach is supported for Go and Cargo, as described in the SCA Auto-fix documentation.
The agent then makes the change, runs your project's build and tests through your CI, and opens the pull request only when it is validated. Reachability data from SCA shows whether your code actually reaches the vulnerable function, which is the input to deciding how soon each fix needs to land.
FAQ
What is a transitive vulnerability in npm?
A transitive vulnerability is one in a package your project does not declare directly but installs because another dependency requires it. Most vulnerabilities in a typical JavaScript project are transitive.
Should I use npm overrides to fix transitive vulnerabilities?
Use an override when no reasonable direct upgrade fixes the finding. Scope it to the parent that needs it and record why it exists, because overrides force a version combination the parent's maintainers never tested.
How do I find which dependency pulls in a vulnerable package?
Run npm explain <package> or npm ls <package> --all. Both show every chain from a direct dependency to each installed copy of the package.
Is npm audit fix --force safe?
Not by default. It installs whatever versions make the audit report clean, including breaking major upgrades. Review the diff and run the full test suite before merging anything it changes.
What is the difference between npm overrides and Yarn resolutions?
They serve the same purpose, forcing a transitive package to a chosen version. npm reads an overrides field, Yarn reads resolutions, and pnpm reads pnpm.overrides, each with slightly different syntax for scoping.
The right fix for a transitive vulnerability is usually the smallest direct upgrade, and finding it by hand is slow. To see Heeler compute that upgrade from your real dependency graph, validate it in your CI and open the pull request, Get a demo.


.jpg)
