← Back to npm Lifecycle Script Check

Why npm audit doesn't catch a malicious install script

npm audit compares the versions in your lockfile against a database of known, reported vulnerabilities (CVEs and GitHub Security Advisories). That is exactly the right tool for "is there a known bug in this version" — and exactly the wrong shape for a lifecycle script, because a script has no CVE to look up until someone has already found it, reported it, and gotten it published. In every incident below, that gap was measured in hours to days, and the script had already run on whoever installed the package during that window.

Four times this actually happened

PackageDateScriptWhat happened
ua-parser-js2021-10-22preinstallnpm account hijacked; for about four hours, malicious versions installed an XMRig cryptominer and a Windows password-stealing trojan. ~8M weekly downloads.
coa & rc2021-11-04postinstallTwo unrelated packages (8.8M and 14.2M weekly downloads) hijacked the same day, carrying an identical malicious postinstall payload.
Shai-Hulud2025-09-15postinstallSelf-propagating worm: harvested npm/GitHub credentials, then republished a trojanized version of every package the stolen token could reach.
Shai-Hulud 2.02025-11preinstallSecond wave, moved to preinstall to run before scanners' own install-time checks. 700+ packages, 27,000+ malicious repos.

Each was a widely used or previously-trusted package, and in every case the compromise arrived as a change to the package's declared lifecycle scripts — not a new, obviously-suspicious dependency someone had to choose to add.

What --ignore-scripts trades away

npm's own --ignore-scripts flag disables every lifecycle script on install, which removes the risk outright. The cost: some packages genuinely need their install script to function — compiling a native addon, fetching a platform-specific prebuilt binary — so a blanket policy breaks them, and figuring out which packages are in that set by hand is exactly the tedious audit most teams skip. Knowing which packages have a script, and whether that just changed, is what makes a scoped --ignore-scripts or a manual review worth the friction on the packages that actually need it, instead of an all-or-nothing call.

Wiring it into CI as a gate

Commit a reviewed baseline snapshot once, then compare against it on every PR that touches the lockfile:

- name: Check for newly added install-time scripts
  run: |
    npm ci
    node ventures/npm-lifecycle-script-check/cli.mjs snapshot --out /tmp/current.json
    node ventures/npm-lifecycle-script-check/cli.mjs diff script-baseline.json /tmp/current.json

The command exits non-zero only when a package newly gained an install-time script since the baseline — a script that was already there, or one that disappeared, is reported but does not fail the build on its own. Accept a specific package's script with --ignore name once you've reviewed it, the same way license-drift-check handles an accepted relicense.

What a risk flag does and doesn't mean

Each script is checked against three keyword patterns — pipes a download to a shell (curl ... | sh), makes a network call, and obfuscated or encoded (base64, eval) — every one of them present in at least one incident above. A flag is a reason to read the script, not a verdict: a legitimate installer can fetch a prebuilt binary too. Only the CLI's snapshot command sees the actual script text; a pasted package-lock.json only records that a script exists, because the lockfile format itself doesn't carry lifecycle-script contents.

Common questions

Doesn't --ignore-scripts just solve this?

It removes the risk, at a cost: some packages (native addons that compile on install, some CLI tools that fetch a platform-specific binary) genuinely need their install script to work at all, so blanket --ignore-scripts breaks them. This tool answers a narrower, always-safe question first — which packages have one, and did that just change — so you can decide where --ignore-scripts or a manual review is worth the friction, instead of applying it everywhere or nowhere.

Does a risk flag mean a script is malicious?

No. The flags are a keyword match against patterns real attacks have used (piping a download into a shell, an obvious network call, obfuscated code) — not a determination. A legitimate installer can also download a prebuilt binary or a font file. A flag means read this one; it is not a verdict.

Why compare two lockfiles instead of just scanning packages against a malware database?

A malware database needs someone to have found and reported the specific malicious version first — by which point it already ran on everyone who installed it in between. Comparing what a package declares now against what it declared before needs no database and no delay: a previously-clean package that suddenly carries an install script is worth a look regardless of whether anyone has flagged that exact version yet.