mDiv.pl
mDiv.pl
arrow_backKnowledge base
SecurityHosting12 min read2026-10-25

Eight vulnerabilities in packages you never installed. npm audit in practice

npm audit gives you a number, but the decision is yours. How to read the output, what to do when the suggested fix rolls your project back a dozen releases, how to force a safe version of a transitive dependency and why a lockfile can pass locally and break your deployment.

Colourful shipping containers stacked on top of each other
Photo: Adem Percem / Unsplash

TL;DR

A typical Node site carries a few hundred packages in node_modules, of which you picked maybe twenty yourself. The rest arrived as dependencies of your dependencies, and that is where vulnerabilities usually sit. npm audit counts them in two seconds, but the number is only the start of the conversation. Three things can turn a routine update into a lost afternoon. First, the fix npm suggests is sometimes a rollback of a package by a dozen releases. Second, some patches exist only in a new major version, so you have to work out what else will break. Third, a package-lock.json generated on your machine can pass locally and break the build on your server, because it lacks the variants for a different operating system. Below is each of these traps on a concrete example, with the commands that defuse them.

One command to start with

npm audit

The output is a list of advisories on a four-step severity scale: low, moderate, high, critical. Each package comes with the range of affected versions and whether a fix is available.

The number alone says little. Two critical advisories in a package that only ever runs while you build the project are less urgent than one moderate advisory in the library handling your contact form. So before you update anything, get the full picture:

npm audit --json | python3 -c "
import json,sys
d=json.load(sys.stdin)
for name,v in d['vulnerabilities'].items():
    fix = v['fixAvailable']
    print(v['severity'].upper(), name, 'range:', v['range'], 'fix:', fix)
"

Three things matter for each entry. Whether the package is your direct dependency or arrived with someone else's. Whether its code runs in production or only at build time. And whether the fix needs a bump inside the same major version or above it.

What "I never installed that package" actually means

The most common misreading goes like this: I did not add that library, so it is not my problem. Unfortunately it is your problem, because it is your server running its code.

Find out who pulls it in:

npm ls package-name

You get a tree showing the path from your package.json to the vulnerable library. That is the key piece of information, because it determines how you fix it. If the package is your direct dependency, you simply bump the version. If it came with someone else's, you have three ways out: update the parent package, force a safe version with an overrides entry, or accept the risk and write down why.

It is worth separating advisories that are reachable in production from those that only concern the build. A hole in a tool that processes your own source files at compile time carries different weight than a hole in code handling traffic from the internet. An attacker will not feed malicious input to your compiler without access to your repository. That is not a reason to leave it unpatched, it is a reason to know what to do first.

Trap one. A "fix" that rolls your project back

Start with the safe variant:

npm audit fix

This only updates within the ranges written in your package.json, so it will not change major versions. It usually clears most of the advisories.

For the rest, npm will suggest npm audit fix --force, and this is where you stop. That flag does whatever it considers necessary, including downgrading packages. A common scenario looks like this: a plugin in its newest release pins an old version of its own dependency, and someone found a hole in it. The plugin author has not shipped a fix yet, so npm proposes the only solution it knows, which is dropping back to a release from two years ago when that dependency was different.

Before you run --force, check what it will actually do:

npm audit fix --force --dry-run

If the result shows a version number lower than your current one, walk away. Going back a dozen releases means losing every bug fix from that period and usually breaks compatibility with the rest of your project. The cure can be worse than the disease.

When there is no fix upstream

That leaves forcing a safe version. In package.json you add an overrides section:

{
  "overrides": {
    "name-of-transitive-dependency": "^7.1.6"
  }
}

Then npm install, and npm audit should report zero. This works from npm 8.3 onwards and solves exactly this case: the parent package gets a newer version of its dependency than it declares itself.

And here is the most important sentence in this article. Forcing a version overrides someone else's compatibility declaration, so the entry alone proves nothing. The author pinned the old version for a reason, and that reason may be an API change. You have to check that everything still works after the swap, and check it by measurement, not by eye.

The simplest trustworthy test is comparing the build output before and after. Record a checksum of the generated files before adding the entry:

npm run build
cat .next/static/chunks/*.css | md5

Then add overrides, rebuild and compare again. If the checksum is identical, the swap changed nothing in the output and you can sleep well. If it differs, look at how:

cat before/*.css | tr '}' '\n' | sort > a.txt
cat after/*.css  | tr '}' '\n' | sort > b.txt
diff a.txt b.txt

Zero differences is hard evidence. "It looks about the same" is not.

Treat an overrides entry as temporary. Once the parent package author updates their dependency, the entry has to go, otherwise it starts blocking newer versions. Note it somewhere people will look, because package.json itself takes no comments.

Trap two. The patch exists only in a new major version

Sometimes the only release without the hole has a higher major number. That means the author was entitled to break compatibility, but not that they broke it in the part you actually use.

Three checks, in this order, take fifteen minutes.

First, the environment requirements:

npm view package-name@10.2.1 engines

If the package demands a newer Node than you run in production, your package update just turned into a server update and needs different planning.

Then the breaking changes. Look for CHANGELOG.md in the author's repository and find the entry for the dot-zero version, 10.0.0, not the newest patch. Breaking changes are described there, not in release 10.2.1.

Finally your own usage surface. List what you actually call from that library:

grep -rn "package-name" --include="*.ts" --include="*.js" ./src ./lib ./app

If you use two functions and the breaking changes concern entirely different ones, the risk is small. Sometimes the whole list of breaking changes amounts to raising the minimum Node version, which you already run. Then the major bump is a formality, and putting it off only means sitting longer on a vulnerable release.

Trap three. A lockfile that passes for you and breaks on the server

This one is the most treacherous, because locally everything looks fine.

Some packages have platform-specific optional dependencies, meaning separate bundles with prebuilt binaries for each operating system and processor. Image processing libraries are the classic example. When you run npm install on macOS, npm records in package-lock.json what is needed on macOS. Your server builds its image on Linux, often Alpine with the musl library, using:

npm ci

npm ci is deliberately strict. It does not negotiate dependencies, it installs exactly what the lockfile says, and when something is missing it stops with an error:

npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json are in sync.
npm error Missing: @name/runtime@1.11.3 from lock file

The effect is misleading. The build on the server fails, the previous version of the application keeps running, so the site looks normal and nobody notices the deployment never landed. That is how you miss a patch for a serious hole.

The fix is simple. Delete the lockfile along with the package directory and regenerate:

rm -rf node_modules package-lock.json
npm install

Verification matters more than the fix, and it is the step most often skipped. A local npm ci proves nothing, because it passes on your system. Check in the environment where the server actually builds:

docker run --rm --platform linux/amd64 \
  -v "$(pwd)/package.json:/t/package.json:ro" \
  -v "$(pwd)/package-lock.json:/t/package-lock.json:ro" \
  -w /t node:24-alpine npm ci

Thirty seconds, and it answers the only question that counts: will this build where it is meant to build. If your deployment uses a container image, an even better test is building it locally from the very same Dockerfile and running it.

Packages are not everything. The runtime ships security releases too

It is easy to settle into watching npm audit and miss that Node itself gets patches. The release of 21 October 2026 closed four high severity holes, including an out-of-bounds write in the Brotli decoder leading to remote code execution and three bypasses of hostname verification in TLS and DNS connections. They affected every supported line.

Check what you are running:

node -v

Update, if you manage versions with nvm, carrying your global packages across:

nvm install 24.22.1 --reinstall-packages-from=24.12.0
nvm alias default 24.22.1

Afterwards it is worth tidying up, because old versions stay on disk and each one takes several hundred megabytes:

nvm ls
nvm uninstall 24.12.0

One note about production. If your image starts with the line FROM node:24-alpine, that is a tag without a patch number, so every subsequent build pulls the newest release of that line and the patch arrives on its own. If you have a version pinned to the exact dot, you have to bump it by hand and nothing will remind you.

Checklist

Worth repeating once a month, or after any announcement about a serious vulnerability.

  1. npm audit, looking at severities rather than only at the count.
  2. npm ls package-name for each advisory, to learn who pulls it in.
  3. npm audit fix without the force flag as the first step.
  4. For anything suggested under --force, a check that it is not proposing a downgrade.
  5. For transitive dependencies with no fix upstream, an overrides entry plus a comparison of the build output before and after.
  6. For a major bump, a check of the environment requirements, the breaking changes and your own usage surface.
  7. npm ci in an image identical to production, before anything reaches the server.
  8. node -v compared against the current release of your supported line.

If you would rather not track this yourself

This is work that shows nothing visible until something gets missed. It adds no features and no speed, it takes a few hours a month, and it requires reading vulnerability announcements across several ecosystems at once.

Under our care plans, security releases go out off the regular review cycle, after checking that they do not change how the application behaves, with a backup on a separate server as the restore point. If you run a project where nobody has been assigned the job of watching those announcements, get in touch or request a quote.

A hole in a library whose name you never typed into your project works exactly like any other. The only difference is that nobody will tell you about it until you ask.

Mirosław Parcz

About the author

Mirosław Parcz

Developer and server administrator

Web developer and server admin with 16+ years of experience. Focused on WordPress/WooCommerce, web applications, hosting, performance, security, and AI integrations.

Found it useful? Pass it on.

LinkedIn

Need help?

mDiv can handle it for you. Safely, with backups, and without downtime.

Get in touch