Skip to main content

Why npm Audit Is Broken (And What to Use Instead)

·PkgPulse Team

TL;DR

npm audit has a signal-to-noise problem that makes it actively harmful. The typical npm project shows 10-30 "vulnerabilities" from transitive dependencies — most of which have no known exploit path, no affected code, and no fix available. Developers learn to ignore npm audit entirely, which means they also ignore the 1 in 50 that is actually critical. The tools that work: npm audit --audit-level=critical (ignore noise), Socket.dev (real threat detection), and Snyk (for enterprise teams). Here's how to fix your security workflow.

Key Takeaways

  • npm audit false positive rate: ~80% for a typical project
  • "Critical" CVEs with no exploit path are common — npm's severity ratings are broken
  • Transitive dependency vulnerabilities are often unfixable (no upstream patch)
  • Socket.dev detects actual malicious behavior, not just CVE database entries
  • npm audit --audit-level=critical reduces noise while keeping signal

The Problem: Death by False Positive

# A fresh create-react-app install (deprecated but illustrative):
npx create-react-app my-app
cd my-app
npm audit

# Output:
# 8 vulnerabilities (2 moderate, 5 high, 1 critical)
#
# To address issues that do not require attention, run:
#   npm audit fix
#
# To address all issues (including breaking changes), run:
#   npm audit fix --force

# Sounds alarming! But dig into the critical one:

npm audit --json | jq '.vulnerabilities["nth-check"].via'
# → nth-check: RegEx complexity vulnerability (ReDoS)
# → Affected: nth-check < 2.0.1
# → Via: css-select < 3.1.0 → css-select < 5.1.0 → svgo → ...
# → Used by: react-scripts (the CRA build tool)

# Is your production app at risk?
# nth-check parses CSS selectors during BUILD TIME.
# Attacker would need to inject malicious CSS into YOUR SOURCE CODE.
# Which means they already have code execution.
# The "critical" vulnerability has zero realistic attack path.
#
# But npm audit says it's critical. Developers learn to ignore this.
# Then they ignore the next alert. And the next.
# Until they ignore the one that's actually exploitable.

How npm's Severity Ratings Break Down

npm audit uses CVSS scores from the National Vulnerability Database.
CVSS rates vulnerability POTENTIAL in a worst-case theoretical scenario.
It doesn't rate "how exploitable is this in your specific context?"

This creates systematic false positives:

Type 1: Build-time tools flagged as runtime vulnerabilities
→ webpack, babel, eslint, postcss have CVEs
→ These run during development/CI, not in your production app
→ An attacker can't reach them via your deployed API
→ npm audit: "CRITICAL" — Reality: not exploitable in production

Type 2: Unreachable code paths
→ Package X has a vulnerability in function Y
→ You (and all your dependencies) never call function Y
→ npm audit: "HIGH" — Reality: not exploitable in your context

Type 3: Transitive deps with no fix available
→ your-package → old-dependency → vulnerable-old-util
→ your-package hasn't updated old-dependency
→ old-dependency hasn't released a fix
→ You can't fix this without forking the package
→ npm audit: "HIGH" — Reality: no action you can take right now

Type 4: Self-XSS and localhost-only vulnerabilities
→ Some CVEs are "if the user is already running code in the browser"
→ Or "if the attacker has access to localhost:3000"
→ These are near-zero threat in practice
→ npm audit: "MODERATE" — Reality: not a security risk

Result: A project with 8 "vulnerabilities" might have 0 real ones.
Developers learn: "npm audit is noise, ignore it."
This is a solved problem in theory. In practice, it's a workflow failure.

What npm Audit Misses (The Real Threats)

While npm audit chases CVE database entries, real npm attacks happen differently:

2022: node-ipc protestware
→ Maintainer added malicious code to protest the Ukraine war
→ Package ran rm -rf on machines in Russia/Belarus
→ npm audit: CLEAN (no CVE existed yet)
→ Downloaded 4M times before detection
→ Socket.dev would have caught it: new destructive code pattern

2022: colors.js / faker.js sabotage
→ Maintainer published infinite loop to protest unpaid open source
→ Broke any build using these packages
→ npm audit: CLEAN
→ Socket.dev would have caught it: new network access pattern

2021: ua-parser-js hijack
→ Attacker compromised npm account
→ Published malicious versions with cryptominer + password stealer
→ npm audit: CLEAN (CVE came later)
→ 8M weekly downloads at the time

2020: event-stream incident
→ Attacker offered to maintain an abandoned package
→ Added malicious code targeting Bitcoin wallet software
→ In production for months before detection
→ npm audit: CLEAN

Pattern: Real npm attacks exploit new malicious packages or account hijacks.
CVE databases catalog OLD known vulnerabilities.
npm audit looks backward. Attacks come from new vectors.

What to Use Instead

# The layered approach that actually works:

# Layer 1: npm audit with sensible threshold
# Don't fail CI on moderate/low — they're noise
npm audit --audit-level=critical
# Only fails on "critical" CVEs
# Still has false positives, but fewer
# Run on every CI push

# Layer 2: Socket.dev for proactive scanning
# Detects malicious packages BEFORE you install them
npm install -g @socket/cli

# Before installing any new package:
socket npm install lodash     # Scans lodash + all its deps

# Add to CI for PR protection:
# github.com/SocketDev/socket-security-github-action
# Automatically comments on PRs that add risky dependencies

# Layer 3: Dependabot or Renovate for ongoing updates
# These create PRs when patches become available
# Addresses the "transitive dep has no fix" problem over time
# .github/dependabot.yml:
# version: 2
# updates:
#   - package-ecosystem: "npm"
#     directory: "/"
#     schedule:
#       interval: "weekly"

# Layer 4: Manual review for high-risk situations
# When a new critical CVE affects packages you actually use:
npm ls vulnerable-package  # Check if it's in YOUR dep tree
# Is it used at runtime? Or only during build?
# Is there an actual exploit path?
# THEN decide if it's urgent

# The result: actual signal instead of noise

The Audit That Actually Helped (And Why)

# Real example of npm audit working correctly:

npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "critical")'

# You find: express-rate-limit < 6.7.0
# CVE: Path traversal allows bypassing rate limits
# via: special request headers on specific Node.js versions
# Your app: uses express-rate-limit for auth endpoints in production
# Exploit path: attacker sends crafted headers → bypasses rate limiting → brute forces passwords

# THIS is the kind of vulnerability npm audit was designed for:
# → Affects production runtime code (not build tools)
# → Has a real exploit path
# → Has a fix available (npm audit fix)
# → Directly affects security properties you care about

# How to tell the difference:
# Ask these questions:
# 1. Is this package used in PRODUCTION? (not just dev/build tools)
npm ls vulnerable-package  # Is it in your dep tree?
cat package.json | jq '.dependencies["vulnerable-package"]'  # Direct dep?

# 2. Is the vulnerable code path reachable?
# Read the CVE description: what specific operation triggers it?
# Do you (or your dependencies) call that operation?

# 3. Is there an exploit in the wild?
# Check CVE page for "Known exploits" section
# Most critical-but-theoretical CVEs have no known exploits

# A vulnerability that hits YES on all three deserves immediate attention.
# Most npm audit output fails at question 1.

Building a Security Workflow That Doesn't Destroy Developer Trust

# The workflow that works:
# .github/workflows/security.yml

name: Security

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 8 * * 1'  # Weekly Monday audit

jobs:
  # Layer 1: Fail on critical CVEs only
  npm-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm audit --audit-level=critical
        # Fails ONLY on critical. Low/moderate: informational.
        # Developers get used to this passing, not ignoring it.

  # Layer 2: Socket.dev on dependency changes
  socket-scan:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v4
      - uses: SocketDev/socket-security-github-action@v1
        with:
          api-key: ${{ secrets.SOCKET_SECURITY_API_KEY }}
        # Comments on PRs that add packages with suspicious behavior
        # Behavioral analysis, not just CVE database

  # Layer 3: Lockfile integrity
  lockfile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci  # Fails if lockfile is out of sync
        # Catches "someone modified the lockfile manually" attacks
The philosophy:
→ Fail loudly on the small number of genuine critical risks
→ Record but don't block on moderate/low (they're often noise)
→ Use behavioral scanning (Socket) for real-time threat detection
→ Use Dependabot for gradual patching of the long tail
→ Review lockfile changes in PRs manually

Developer trust:
→ When CI passes, developers should trust it means "no known critical risks"
→ When CI fails, developers should trust it means "real problem"
→ If CI fails on noise, developers disable it entirely — worst outcome

The goal is a workflow where:
- False positives: near zero (if it fails, it's real)
- False negatives: low (real threats get caught by Socket layer)
- Developer friction: minimal (doesn't require daily CVE triage)

Check npm audit data and security health scores for packages at PkgPulse.

See the live comparison

View npm vs. pnpm on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.