← Back to npm Lifecycle Script Check
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.
| Package | Date | Script | What happened |
|---|---|---|---|
| ua-parser-js | 2021-10-22 | preinstall | npm account hijacked; for about four hours, malicious versions installed an XMRig cryptominer and a Windows password-stealing trojan. ~8M weekly downloads. |
| coa & rc | 2021-11-04 | postinstall | Two unrelated packages (8.8M and 14.2M weekly downloads) hijacked the same day, carrying an identical malicious postinstall payload. |
| Shai-Hulud | 2025-09-15 | postinstall | Self-propagating worm: harvested npm/GitHub credentials, then republished a trojanized version of every package the stolen token could reach. |
| Shai-Hulud 2.0 | 2025-11 | preinstall | Second 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.
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.
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.
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.
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.
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.
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.