What npm audit actually does (and what it definitely does not)

Let's be fair to npm audit. It does what it says on the tin. It takes your package-lock.json, sends the dependency tree to npm's advisory registry, and returns a list of known CVEs for packages you declared as dependencies. It is fast, it is free, and it catches real vulnerabilities.

The problem is what happens after you run it, see a clean result, and conclude your project's JavaScript dependencies are secure. That conclusion assumes that every piece of JavaScript code in your project went through npm install. And that assumption is wrong in a remarkable number of production codebases.

Terminal shell
$ npm audit

found 0 vulnerabilities

# Great! Ship it!
# ...
# Meanwhile, in static/vendor/jquery-1.11.3.min.js:
#   CVE-2019-11358  (Prototype Pollution)  โ€” CVSS 6.1
#   CVE-2020-11022  (XSS)                  โ€” CVSS 6.9
#   CVE-2020-11023  (XSS)                  โ€” CVSS 6.9
#   CVE-2015-9251   (XSS)                  โ€” CVSS 6.1

npm audit didn't lie to you. It told you the truth about what is in your package-lock.json. It just had no idea that static/vendor/jquery-1.11.3.min.js exists. That file doesn't show up in any lockfile. It was probably added with a git commit message that said something like "add jquery" at some point between 2015 and 2019, and no automated tool has touched it since.

Fun fact: jQuery 1.11.3 was released in October 2014. If this file is in your repo right now, it has been there through at least three major JavaScript framework cycles, two npm CLI rewrites, the rise and fall of Bower, and whatever era we're currently in. It's practically a historical artefact. Give it a plaque.

The vendor folder: where dependencies go to be forgotten

The pattern is extremely common. Before npm and bundlers were ubiquitous, the standard way to include a third-party JavaScript library was to download the minified file and drop it into a directory โ€” often called vendor/, lib/, static/js/, or public/assets/. The file got committed to the repository and was never thought about again.

As projects modernised, they adopted npm and webpack โ€” but often kept the old vendor files around because removing them felt risky, and nothing was obviously broken. The new package-based build pipeline ran alongside the old vendor files. Auditing tools scanned the new pipeline. The vendor files remained.

A perfectly normal project structure shell
my-app/
โ”œโ”€โ”€ package.json            # npm audit looks here โœ“
โ”œโ”€โ”€ package-lock.json       # npm audit looks here โœ“
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ index.js
โ”œโ”€โ”€ dist/
โ”‚   โ””โ”€โ”€ bundle.js           # npm audit does NOT look here โœ—
โ””โ”€โ”€ static/
    โ”œโ”€โ”€ css/
    โ””โ”€โ”€ vendor/
        โ”œโ”€โ”€ jquery-1.11.3.min.js      CVE-2015-9251, CVE-2019-11358, CVE-2020-11022
        โ”œโ”€โ”€ lodash-3.10.1.min.js      CVE-2019-10744, CVE-2020-8203
        โ”œโ”€โ”€ bootstrap-3.3.7.min.js    CVE-2018-14040, CVE-2019-8331
        โ””โ”€โ”€ moment-2.24.0.min.js      CVE-2022-24785 (ReDoS)
                                      # npm audit: "found 0 vulnerabilities"

This isn't just a frontend problem. Backend projects that serve static assets, Django apps with static/ directories, Ruby on Rails projects that never fully migrated off the asset pipeline, Java Spring apps with a resources/static/ directory โ€” they all have this. If the project has been running for more than three years and has ever served a web interface, there is a non-trivial chance there is a vendored JavaScript file in there somewhere.

The real danger isn't just that the file has a known CVE. It's that the file is being served to end users in a browser โ€” which makes client-side XSS and prototype pollution vulnerabilities in those libraries directly exploitable by anyone who can craft a request to your app. This is not a theoretical risk.

Real CVEs hiding in vendor files right now

Here are the most common libraries found in vendor directories โ€” and the vulnerabilities that come with them. These are not obscure edge-case bugs. These are well-documented, actively scanned-for, and in several cases have working proof-of-concept exploits.

jQuery < 3.5.0 CVE-2020-11022

XSS via jQuery.htmlPrefilter(). Passing HTML containing a self-closing tag to certain jQuery methods allows script execution. CVSS 6.9. Exploitable in any app using $.html(), $.append() with untrusted input.

jQuery < 3.4.0 CVE-2019-11358

Prototype pollution via jQuery.extend() when called with a deeply nested object whose key is __proto__. Affects any code path that passes user-controlled JSON to jQuery's extend. CVSS 6.1.

Lodash < 4.17.21 CVE-2020-8203

Prototype pollution via _.zipObjectDeep. An attacker can pollute the Object.prototype with arbitrary properties, which affects all objects created after the call. CVSS 7.4 โ€” High.

Lodash < 4.17.17 CVE-2019-10744

Prototype pollution via _.defaultsDeep. This one is particularly nasty because defaultsDeep is commonly used to safely merge config objects โ€” which is exactly where you don't want prototype pollution.

Bootstrap < 3.4.1 / 4.1.2 CVE-2019-8331

XSS in the tooltip and popover components via the data-template attribute. If your app uses Bootstrap's interactive components and doesn't sanitise attribute input, this is exploitable. CVSS 6.1.

Moment.js < 2.29.2 CVE-2022-24785

Path traversal in locale file loading. An attacker controlling the locale string passed to moment.locale() can traverse the filesystem. More impactful in Node.js contexts, but the vendored browser build still contains the vulnerable parsing code.

All of these have a patch available. All of them are detected by npm audit โ€” if the library is installed as an npm package. If it is a vendored .min.js file in your repo, none of them will appear in your audit output.

"The vulnerability is not in your dependencies. It is in your dependencies' dependencies' manually committed historical artefacts that no automated tool in your pipeline has ever looked at."

Committed dist/ bundles: a special kind of bad

Vendor files are bad enough. But there is a second, slightly more insidious variant: committed build output. This happens when a project commits its dist/ or build/ directory to Git โ€” often because it is served directly, deployed from the repo, or because the team "just didn't want to run the build in CI."

The problem here is that dist/bundle.js contains minified, concatenated versions of every npm package that was installed at the time the bundle was built. These versions are frozen at build time and never updated automatically. npm audit fix will update your package-lock.json โ€” but not the committed dist/bundle.js. The bundle needs to be rebuilt and recommitted. If nobody does that, the vulnerable code ships.

The committed bundle scenario shell
# Developer runs npm audit fix. Lodash is updated to 4.17.21.
$ npm audit fix
fixed 3 of 3 vulnerabilities

# Developer commits the lockfile change.
$ git add package-lock.json
$ git commit -m "fix: update lodash via npm audit fix"

# Nobody rebuilds. Nobody recommits dist/bundle.js.
# dist/bundle.js still contains lodash 4.14.0 embedded inside it.
# The deployed application is serving the vulnerable bundle.
# npm audit: "found 0 vulnerabilities" โ€” technically true, deeply misleading.

$ git log dist/bundle.js --oneline
a3f8c2d  build: update bundle  โ€” 14 months ago

The committed bundle is arguably more dangerous than a plain vendor file because it looks managed. It is in the build output. CI creates it. The fact that CI is not the one creating the copy that is actually deployed is the gap โ€” and it is invisible to every scanning tool that works off your current source files.

Quick check: run git log --oneline dist/ in any long-running project. If the most recent commit is older than a few weeks, your deployed bundle contains dependency versions that were current at that point in time โ€” regardless of what npm audit says about your current package.json.

The copy-paste inline case: forensically invisible

This is the most difficult variant to find and arguably the most embarrassing to explain in a post-incident review. It goes like this: a developer needed one specific function from Lodash โ€” say, _.debounce โ€” and rather than adding a whole dependency, they copied the source of that function inline into a utility file. The function is 40 lines of JavaScript. It works perfectly.

That 40-line function is copied from Lodash 3.9.3 and contains a prototype pollution vulnerability. npm audit cannot see it because there is no package declaration. No SCA tool that works from lockfiles can see it. Even a grep-based tool looking for version strings will miss it because there is no version string โ€” it is just raw function code.

src/utils/helpers.js javascript
// Debounce utility โ€” copied from lodash for bundle size reasons
// (lodash 3.9.3 source, debounce.js)
// TODO: replace with proper import when we upgrade build pipeline
// (this TODO is 4 years old)
function debounce(func, wait, options) {
  var args, maxTimeoutId, result, stamp, thisArg, timeoutId, trailingCall,
    lastCalled = 0,
    leading = false,
    maxWait = false,
    trailing = true;

  // ... 35 more lines of lodash 3.9.3 ...
  // Contains vulnerable _.merge-based option handling
  // npm audit: has no idea this exists
}

The only tool class that can catch this is one that performs semantic analysis of JavaScript code and compares function signatures against known vulnerable implementations. That is a hard problem. Most SCA tools do not attempt it. The detection approach that comes closest is file-hash fingerprinting โ€” comparing the hashes of code segments against a database of known library versions.

Practical note: Tools like retire.js and OWASP Dependency-Check use a combination of version string scanning, file hashing, and pattern matching to detect vendored library code. They are imperfect but substantially better than lockfile-only scanning for this class of vulnerability. Neither is a drop-in replacement for SCA โ€” they are complementary layers.

How to actually find vendored vulnerabilities

Good news: you do not need a commercial tool to get started. Here are several techniques, ordered from quickest-to-run to most thorough.

1. retire.js โ€” the fastest first sweep

retire.js scans the filesystem for JavaScript files, identifies library versions from embedded version strings, and checks them against a vulnerability database. It catches vendored files that npm audit misses entirely.

Terminal shell
# Install retire.js globally
npm install -g retire

# Scan your project directory (including static/ and vendor/)
retire --path ./

# Scan only specific directories
retire --path ./static --path ./public

# Output as JSON for CI integration
retire --path ./ --outputformat json --outputpath retire-results.json

# Example output on a project with vendor files:
static/vendor/jquery-1.11.3.min.js
 โ†ณ jquery 1.11.3
   Vulnerability: CVE-2020-11022 โ€” Severity: medium
   Vulnerability: CVE-2019-11358 โ€” Severity: medium

2. grep for version strings โ€” crude but useful for triage

Before running any tool, a quick grep across your repository can surface library files that are obviously present:

Terminal shell
# Find files with embedded version strings for common libraries
grep -r --include="*.js" "jQuery v[12]\." .
grep -r --include="*.js" "lodash.*[23]\." .
grep -r --include="*.js" "Bootstrap v[23]\." .
grep -r --include="*.js" "moment\.js.*2\.[01][0-9]\." .
grep -r --include="*.js" "Angular.*1\." .

# Find any .min.js files that aren't in node_modules
find . -name "*.min.js" -not -path "*/node_modules/*" -not -path "*/.git/*"

# These are your vendored files. Every one of them needs a manual check.

3. OWASP Dependency-Check for the full picture

OWASP Dependency-Check performs deeper analysis including file hashing against its CVE database. It is heavier to run but catches library code that retire.js misses because it doesn't have an embedded version string:

Terminal shell
# Docker-based run โ€” no local install required
docker run --rm \
  -v "$(pwd)":/src \
  -v odc-data:/usr/share/dependency-check/data \
  owasp/dependency-check \
  --scan /src \
  --format HTML \
  --out /src/reports/dependency-check-report.html

# This will scan everything โ€” node_modules, vendor/, dist/, source files.
# First run is slow (it needs to download the NVD data).  
# Subsequent runs with the mounted volume are much faster.

4. In CI โ€” combining both tools

.github/workflows/sca.yml yaml
name: Full SCA โ€” including vendored JS
on: [push, pull_request]

jobs:
  sca:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: npm audit โ€” lockfile dependencies
        run: npm audit --audit-level=high

      - name: retire.js โ€” vendored JS files
        run: |
          npm install -g retire
          retire --path . --exitwith 1 --ignore node_modules

      # retire.js catches what npm audit misses.
      # Both gates must pass. A green npm audit alone is not enough.

Fixing it without burning everything down

Once you have identified vulnerable vendor files, the remediation path depends on what the file is actually doing.

For vendor files used via a script tag

The right fix is to remove the file and install the library via npm, then bundle it. Yes, this means touching the build pipeline. Yes, it is annoying. Yes, it is the correct answer. A pragmatic interim step โ€” if the build pipeline migration is genuinely weeks away โ€” is to replace the vendor file with the current CDN version, pinned to a specific version with a subresource integrity (SRI) hash:

Before (vulnerable) html
<script src="/static/vendor/jquery-1.11.3.min.js"></script>
After (interim fix with SRI) html
<script
  src="https://code.jquery.com/jquery-3.7.1.min.js"
  integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
  crossorigin="anonymous"></script>
<!-- SRI hash ensures the CDN file hasn't been tampered with -->
<!-- Generate your own at: https://www.srihash.org/ -->

For committed dist/ bundles

Stop committing the build output. Full stop. Set up CI to build and deploy from source. If you genuinely cannot do this immediately, at minimum add a scheduled CI job that rebuilds the bundle against current dependencies and commits the result โ€” so the committed bundle stays within a week of the current lockfile, rather than 14 months behind it.

For copy-pasted inline code

Replace it with an actual import. The "we did it to save bundle size" argument stopped holding water the moment tree-shaking became mainstream. import { debounce } from 'lodash-es' with a modern bundler gives you exactly the function you need and nothing else โ€” and it is automatically included in npm audit and dependency update tooling from that point forward.

The summary: npm audit is a necessary part of your SCA pipeline, not a sufficient one. Run it, fix what it finds, and then run retire.js on the same codebase to find what it doesn't. The two tools scan different surfaces. You need both.

If you are reading this and already sweating about what might be living in your static/ directory: that is the correct reaction. The good news is that a retire --path . run takes about 30 seconds and will tell you exactly what is there. Run it now. If the answer is "nothing," great. If the answer is "jQuery 1.x and three versions of Bootstrap 3," at least now you know โ€” which is considerably better than confidently believing your clean npm audit output.