Privacy Tools Guide

How to Audit npm Packages for Security

The average Node.js project pulls in hundreds of transitive dependencies. Any one of them can introduce a vulnerability, or worse, malicious code designed to steal credentials or environment variables. This guide covers the full audit workflow from basic npm audit to CI automation.

Step 1: npm audit

The built-in audit command checks your installed packages against the npm advisory database.

# Basic audit — shows vulnerabilities and severity
npm audit

# JSON output for scripting
npm audit --json | jq '.vulnerabilities | to_entries[] | {pkg: .key, severity: .value.severity, via: .value.via}'

# Audit only production dependencies (skip devDependencies)
npm audit --omit=dev

# Show the full dependency path for each vulnerability
npm audit --json | jq '.vulnerabilities | to_entries[] | {
  pkg: .key,
  severity: .value.severity,
  fixAvailable: .value.fixAvailable,
  nodes: .value.nodes
}'

Step 2: Fix Vulnerabilities

# Automatically fix vulnerabilities where a compatible update exists
npm audit fix

# Force updates even if they include breaking changes (review diff first)
npm audit fix --force

# Dry run — show what would change without applying
npm audit fix --dry-run

# Fix a specific package manually
npm update lodash --save

Not all vulnerabilities can be auto-fixed. When a fix would require a major version bump, you need to evaluate manually:

# Check what version is available and what changed
npm view lodash versions --json | jq '.[-5:]'
npm view lodash changelog

# Update and run your test suite
npm install lodash@4.17.21
npm test

Step 3: Socket.dev for Supply Chain Analysis

npm audit only checks known CVEs. Supply chain attacks (malicious code injected into a package) often appear before a CVE is filed. Socket analyzes package behavior.

# Install Socket CLI
npm install -g @socketsecurity/cli

# Scan your project
socket scan npm

# Check a specific package before installing
socket npm info malicious-looking-package

Socket flags:

# Example output from socket scan:
# lodash@4.17.21: OK
# colors@1.4.0: WARNING — install script detected
# faker@5.5.3: CRITICAL — obfuscated code, network access

Step 4: Snyk for Vulnerability Scanning

Snyk maintains its own vulnerability database and catches issues npm audit misses.

# Install and authenticate
npm install -g snyk
snyk auth   # opens browser, links to snyk.io account

# Scan current project
snyk test

# Monitor a project (alerts when new vulns are disclosed)
snyk monitor

# Fix vulnerabilities using Snyk's patches
snyk fix

# Scan a Docker image for npm vulnerabilities
snyk container test node:18-alpine
# In CI without a Snyk account — use the free tier API
snyk test --severity-threshold=high
# Exit code 1 if high or critical vulns found

Step 5: Detect Malicious Package Patterns

Beyond scanners, know what to look for manually:

# List all install scripts in your dependency tree
node -e "
const lock = require('./package-lock.json');
const packages = lock.packages || {};
Object.entries(packages)
  .filter(([, v]) => v.scripts && (v.scripts.install || v.scripts.preinstall || v.scripts.postinstall))
  .forEach(([name, v]) => console.log(name, v.scripts));
"

# Inspect a specific package's install script
cat node_modules/suspicious-pkg/package.json | jq '.scripts'

# Look for network calls in install scripts
grep -r "http\|fetch\|axios\|request\|curl\|wget" node_modules/suspicious-pkg/

# Inspect for obfuscated code patterns
grep -r "eval\|atob\|Buffer.from.*base64\|String.fromCharCode" node_modules/suspicious-pkg/ | head -20

Step 6: Lockfile Integrity

The package-lock.json file is your first line of defense. Commit it and verify it.

# Install only what's in the lockfile — no surprise updates
npm ci

# Never run npm install in CI (it can update the lockfile)
# Always use npm ci in CI environments

# Verify lockfile hasn't been tampered with
git diff HEAD package-lock.json

# Check if the lockfile matches package.json
npm ls --depth=0 2>&1 | grep "UNMET\|invalid"

Integrity hashes are stored in package-lock.json:

{
  "node_modules/lodash": {
    "version": "4.17.21",
    "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZhr+33kbJlIdiwVRmA=="
  }
}

npm verifies this hash on every install. If a package is modified after publish, the hash will not match.

Step 7: CI Integration

# .github/workflows/security.yml
name: Security Scan

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

jobs:
  npm-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: npm audit
        run: npm audit --audit-level=high --omit=dev

      - name: Socket scan
        run: |
          npm install -g @socketsecurity/cli
          socket scan npm --strict
        continue-on-error: true   # change to false when baseline is clean

  snyk:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

Step 8: Dependency Review for Pull Requests

GitHub’s dependency review action blocks PRs that introduce vulnerable packages:

# .github/workflows/dependency-review.yml
name: Dependency Review

on:
  pull_request:

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: high
          deny-licenses: GPL-3.0, AGPL-3.0   # optional license policy

Auditing Existing Projects: Quick Script

#!/bin/bash
# npm-security-check.sh — run in any Node project

echo "=== npm audit ==="
npm audit --omit=dev --json | jq '{
  total: .metadata.vulnerabilities.total,
  critical: .metadata.vulnerabilities.critical,
  high: .metadata.vulnerabilities.high
}'

echo ""
echo "=== Packages with install scripts ==="
node -e "
const lock = require('./package-lock.json');
const pkgs = lock.packages || {};
let count = 0;
Object.entries(pkgs).forEach(([name, v]) => {
  if (v.scripts && (v.scripts.install || v.scripts.postinstall || v.scripts.preinstall)) {
    console.log(' -', name);
    count++;
  }
});
console.log('Total:', count);
"

echo ""
echo "=== Dependencies older than 1 year ==="
npm outdated --json | jq -r 'to_entries[] | select(.value.current != .value.latest) | "\(.key): \(.value.current) → \(.value.latest)"' | head -20