- 1
npm install
Lifecycle hooks: preinstall, install, postinstall
Used by: Shai-Hulud (Sep 2025), axios via plain-crypto-js (Mar 2026), @antv (May 2026), keyv and cacheable (Aug 2026), tensorlake (Oct 2026)
npm 12: blocked by defaultpnpm 10+: blocked by defaultnpm 10 and 11: runs - 2
npm install
Implicit node-gyp rebuild for any package that ships binding.gyp
Used by: Miasma "Phantom Gyp" waves (Jun 2026). No script appears in package.json.
npm 12: blocked by defaultnpm 10 and 11: runs - 3
npm install
Git dependency prepare script
Used by: the @antv/setup git dependency in the @antv wave (May 2026)
npm 12: allow-git=nonenpm 11: allow-git=all - 4
require / import
The package's own code, in Node.js or in a browser bundle
Used by: the Qix compromise of chalk and debug (Sep 2025), which swapped wallet addresses in end users' browsers
No install setting helps - 5
Opening the repository
.vscode/tasks.json folder-open tasks, .claude/settings.json SessionStart hooks
Used by: keyv and cacheable (Aug 2026) and Miasma, committed to victims' source repositories
Not an npm code path
npm Security
npm security is the practice of keeping vulnerable and malicious npm packages out of JavaScript projects, from publish through install and runtime.
Also known as npm vulnerabilities, npm vulnerability, npm malware, npm supply chain attack, npm supply chain security, npm package security
npm security is the work of keeping two different threats out of JavaScript projects: npm vulnerabilities, which are bugs in legitimate packages tracked as advisories, and npm malware, which is code published to the registry on purpose to run on your machine. Most npm malware executes at install time, before any advisory exists.
The largest npm supply chain attacks of the past year exploited no bug. They used the registry as designed: a valid maintainer credential published a new version, every matching caret range resolved to it on the next fresh install, and npm ran the package's install hooks with the installing user's privileges. Our position is that npm security has to be decided before install, from what a package's tarball actually contains, not afterward from an advisory feed. npm 12 removed the worst default in July 2026 by no longer running dependency install scripts. As of October 2026, no Node.js release bundles it.
How npm decides which code runs on your machine
#npm install does three things that matter for security: it resolves version ranges, fetches tarballs, and executes lifecycle scripts.
Resolution happens against the registry as it exists at that moment. A dependency declared as "axios": "^1.13.5" accepts any 1.x release at or above 1.13.5, and npm resolves it to the newest matching release, normally the one tagged latest. A committed package-lock.json freezes the exact version and integrity hash of everything already resolved. It does nothing for a dependency you add, update, or run through npx.
Fetching goes to the npm registry, which keeps one document per package listing every version, its tarball URL, its integrity hash, and its publish time. The registry authorizes a publish by credential. Whoever holds a maintainer's token, browser session, or trusted-publishing identity can publish as that maintainer.
Execution turns a bad package into a compromised machine. npm runs a dependency's preinstall, install, and postinstall install scripts as the installing user, with the full environment: NPM_TOKEN, cloud credentials, CI secrets. Two implicit paths also run code. A package that ships a binding.gyp file gets an automatic node-gyp rebuild, and a git dependency runs its prepare script.
Each step encodes a trust assumption. The newest version in a range is assumed safe. A publish from a maintainer's credential is assumed to come from the maintainer. Code a dependency asks to run at install is assumed necessary. Developers rarely see any of this, because most installed code arrives as a transitive dependency nobody on the team chose. During the axios compromise, the Twilio, Contentful, and Slack Web API SDKs all declared axios as ^1.13.5, a range that resolved straight to the malicious release.
Figure 1 lists the five places a dependency's code can execute. Three happen during npm install: lifecycle hooks (preinstall, install, postinstall), the implicit node-gyp rebuild triggered by a binding.gyp file, and the prepare script of a git dependency. npm 12 blocks all three by default; npm 10 and npm 11 run them, and pnpm has blocked lifecycle hooks since version 10. The fourth is the package's own code running when it is imported, in Node.js or in a browser bundle, which no install setting can stop. The fifth is outside npm: .vscode/tasks.json folder-open tasks and .claude/settings.json SessionStart hooks committed to a source repository, which run when someone opens the project. The figure maps each point to the incidents in the table further down.
npm vulnerabilities and npm malware are different problems
#An npm vulnerability is a flaw in a package published in good faith. npm malware is a package version built to harm whoever installs or runs it. The two need different data, different timing, and different responses.
npm vulnerabilities are tracked as advisories, mostly in the GitHub Advisory Database, which is what npm audit reads. npm sends the name and version of every package in the lockfile to the registry's bulk advisory endpoint and gets back every advisory whose range matches. We posted the two malicious axios versions to that endpoint on October 26, 2026:
$ curl -s -X POST https://registry.npmjs.org/-/npm/v1/security/advisories/bulk \
-H 'content-type: application/json' \
-d '{"axios":["1.14.1","0.30.4"]}' \
| jq -c '.axios[] | select(.cwe | index("CWE-506")) | {id, url, title, severity, vulnerable_versions, cvss: .cvss.score}'
{"id":1115702,"url":"https://github.com/advisories/GHSA-fw8c-xr5c-95f9","title":"Malware in axios","severity":"critical","vulnerable_versions":"=0.30.4","cvss":0}
{"id":1115703,"url":"https://github.com/advisories/GHSA-fw8c-xr5c-95f9","title":"Malware in axios","severity":"critical","vulnerable_versions":"=1.14.1","cvss":0}Without the jq filter, the response holds 60 entries covering 37 distinct advisories. Thirty-six describe ordinary bugs such as prototype pollution and ReDoS. One says the version itself is malware, tagged CWE-506, embedded malicious code, with severity critical and a CVSS score of 0. So npm audit does report npm malware, once someone has written the advisory. GHSA-fw8c-xr5c-95f9 was published on March 31, 2026, the day of the attack, and its guidance is blunt: "Any computer that has this package installed or running should be considered fully compromised."
A malware advisory is a record of an incident, not a defense against it. By the time it exists, the install hook has already run. This is why CVE and advisory scanning alone misses most supply chain attacks: malware rarely gets a CVE, and the malware advisories it does get arrive after the window that mattered.
npm malware comes in two shapes. The first is a package built to be malicious from its first version: typosquats, fake SDKs, and the take-home coding tests of North Korea's Contagious Interview campaign, which had published 338 malicious npm packages with 50,000 downloads by October 2025. Those need a victim to pick them. The second is a malicious version of a package people already trust, published after a maintainer account takeover. The second shape matters more to most teams, because semver ranges install it for you.
Seven npm compromises and where each one executed
#Six of these seven npm compromises, from September 2025 to October 2026, executed during npm install. The table covers the Qix compromise of chalk and debug, the first wave of the Shai-Hulud worm, the axios compromise, the @antv wave of Mini Shai-Hulud, the Miasma "Phantom Gyp" wave, the keyv and cacheable compromise, and the tensorlake compromise.
| Date (UTC) | Incident | Initial access | Where the code ran | Scale |
|---|---|---|---|---|
| Sep 8, 2025 | Qix compromise: chalk, debug, and 17 more | Phishing email from npmjs.help spoofing a 2FA reset | At runtime, in browsers, by hooking fetch and XMLHttpRequest to swap crypto wallet addresses | Packages with 2 to 3 billion weekly downloads |
| Sep 14 to 16, 2025 | Shai-Hulud worm, first wave | Stolen npm tokens, then self-propagation | postinstall: node bundle.js | 526 package versions |
| Mar 31, 2026 | axios compromise | Social engineering of the lead maintainer, then a hijacked npm session | postinstall of a new dependency, plain-crypto-js@4.2.1 | About 100 million weekly downloads |
| May 19, 2026 | Mini Shai-Hulud, @antv wave | Hijacked maintainer account atool | preinstall hook plus a git dependency | 639 versions of 323 packages in about an hour |
| Jun 24, 2026 | Miasma "Phantom Gyp", LeoPlatform packages | Compromised publisher accounts | binding.gyp command expansion during node-gyp configure, with no script in package.json | 23 package versions in one burst |
| Aug 4, 2026 | keyv and cacheable compromise | Compromised maintainer account and release workflow | preinstall, plus .vscode and .claude hooks committed to the source repository | Tens of millions of weekly downloads, shipped with valid provenance |
| Oct 8, 2026 | tensorlake compromise | Not disclosed; same loader and payload files as the keyv compromise | preinstall | About 12,000 weekly downloads, flagged 11 minutes after publish |
The pattern is consistent. Initial access is a person, not a bug: a phished maintainer, a hijacked session, a stolen token. Execution happens at install. Only the Qix payload waited for runtime, and it targeted end users' wallets in the browser rather than developers' machines. The Shai-Hulud family shows where install-time execution leads: every infected machine that holds a publish token republishes the payload into every package that token can reach.
The axios compromise, minute by minute
#The axios compromise shows that npm security is mostly a question of timing. The attacker staged a clean copy of crypto-js as plain-crypto-js@4.2.0, added a postinstall dropper in 4.2.1, then published axios releases on both the 1.x and 0.x lines that depended on it.
plain-crypto-js@4.2.123:59 to 03:25, 3 h 26 min
axios@1.14.100:21 to about 03:40, about 3 h 20 min
axios@0.30.401:00 to about 03:40, about 2 h 40 min
| Time (UTC) | Event | Source |
|---|---|---|
| Mar 30, 05:57:32 | plain-crypto-js@4.2.0 published, a clean copy of crypto-js | npm registry time record |
| Mar 30, 23:59:12 | plain-crypto-js@4.2.1 published with a postinstall dropper | npm registry time record |
| Mar 31, 00:05:41 | Socket's scanner flags plain-crypto-js@4.2.1 as malware | Socket |
| Mar 31, 00:21:58 | axios@1.14.1 published, depending on plain-crypto-js | npm registry time record |
| Mar 31, 01:00:57 | axios@0.30.4 published | npm registry time record |
| Mar 31, 03:25:11 | npm replaces plain-crypto-js with a security holding package | npm registry time record |
| Mar 31, about 03:40 | npm removes both axios versions | Datadog Security Labs |
| Mar 31, time not published | GHSA-fw8c-xr5c-95f9 published; npm audit reports it from then on | GitHub Advisory Database |
The registry still records every publish, even for versions it has removed. Anyone can reproduce the timeline:
$ curl -s https://registry.npmjs.org/axios \
| jq -c '{published: .time["1.14.1"], still_served: (.versions | has("1.14.1"))}'
{"published":"2026-03-31T00:21:58.168Z","still_served":false}
$ curl -s https://registry.npmjs.org/plain-crypto-js | jq -c '{time, description}'
{"time":{"created":"2026-03-31T04:26:32.907Z","modified":"2026-03-31T04:26:35.731Z","4.2.0":"2026-03-30T05:57:32.867Z","4.2.1":"2026-03-30T23:59:12.278Z","0.0.1-security":"2026-03-31T03:25:11.636Z","0.0.1-security.0":"2026-03-31T04:26:33.055Z"},"description":"security holding package"}Socket flagged the payload 16 minutes before axios@1.14.1 existed. That is the case for analyzing the tarball at publish time: the malicious dependency was detectable on its own contents, before the popular package that pulled it in was even poisoned.
A committed lockfile protected projects that already had axios locked and installed with npm ci. It did not protect anything that resolved fresh while the bad versions were live: new projects, lockfile refreshes, global installs, and CLIs run through npx. In our analysis of the hidden blast radius, one CI pipeline pulled the payload through a transitive axios range while running a CLI with npx. A clean lockfile checked afterward proves little, because once npm removed the versions, every reinstall resolved clean again.
A one-day dependency cooldown would never have resolved either malicious axios version, because both were gone within four hours. npm 12's script blocking would have stopped the postinstall dropper as well, but npm 12 shipped on July 8, 2026, three months later.
binding.gyp is an install script with no script
#A package can run code during npm install without declaring any install script. When a package ships binding.gyp and declares no install or preinstall script, npm runs node-gyp rebuild for it. gyp evaluates <!(...) command expansions while it configures the build, so any shell command in that file runs at install time. We built a three-file package to show it:
{ "name": "gyp-demo", "version": "1.0.0", "main": "index.js" }{ "targets": [ { "target_name": "x",
"sources": [ "<!(node -e \"require('fs').writeFileSync('/tmp/gyp-ran', process.env.npm_config_user_agent || 'ran')\")" ] } ] }The first file is the whole package.json, with no scripts field. The second is binding.gyp. We installed the packed tarball with three npm versions on Linux on October 26, 2026:
$ npm --version
11.19.1
$ npm install ./gyp-demo-1.0.0.tgz
added 1 package, and audited 2 packages in 676ms
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts gyp-demo@1.0.0 (install: node-gyp rebuild)
$ cat /tmp/gyp-ran
npm/11.19.1 node/v22.23.4 linux x64 workspaces/false
$ rm /tmp/gyp-ran
$ npm --version
12.2.1
$ npm install ./gyp-demo-1.0.0.tgz
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts gyp-demo@1.0.0 (install: node-gyp rebuild)
$ cat /tmp/gyp-ran
cat: /tmp/gyp-ran: No such file or directory| npm version | How it is distributed | Did the binding.gyp command run? | What npm printed |
|---|---|---|---|
| 10.9.9 | The npm in our Node.js 22.23.4 test image | Yes | Nothing about the script |
| 11.19.1 | Bundled with Node.js 24.22.0 LTS, released October 14, 2026 | Yes | A warning, after the code had already run |
| 12.2.1 | Installed separately; latest on the registry | No | "install scripts blocked" |
| 10.9.9 with --ignore-scripts | Flag set per command or in .npmrc | No | Nothing |
Two details matter for defenders. npm 11 prints a warning that reads like a block, but the command has already executed. And a scanner that only reads the scripts field sees nothing: npm query ':attr(scripts, [install])' returns an empty array for this package on npm 12.2.1, while npm install-scripts ls lists it. The Miasma waves used exactly this trigger in June 2026, two weeks before npm 12 shipped, to slip past tools that watch only preinstall and postinstall.
How do you protect against npm supply chain attacks?
#You protect against npm supply chain attacks by keeping unreviewed code from executing at install, refusing versions too new to have been vetted, and resolving dependencies only from a reviewed lockfile. npm 12 makes the first part the default. npm added min-release-age in the 11.x line for the second. The rest is configuration:
# npm 12. Skip versions published in the last day (the value is in days).
min-release-age=1
# Internal packages can update immediately.
min-release-age-exclude[]=@your-org/*
# allow-git and allow-remote already default to none in npm 12.# CI: the npm bundled with Node.js 24 is 11.x, so install 12 explicitly.
npm install -g npm@12
npm ci --strict-allow-scripts
# Run tools only from the lockfile, never fetched on demand.
npx --no --offline eslint .With --strict-allow-scripts, an unreviewed install script fails the build instead of being skipped with a warning:
npm error code ESTRICTALLOWSCRIPTS
npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:
npm error esbuild@0.25.10 (postinstall: node install.js)Approvals live in package.json. npm install-scripts approve esbuild pins the approval to the installed version, and npm install-scripts deny core-js records a denial:
"allowScripts": {
"esbuild@0.25.10": true,
"core-js": false
}Keep the pin. A name-only approval such as "esbuild": true is a standing grant to whoever next obtains publish rights for that package, which is exactly what a worm needs. pnpm users already had this posture: pnpm 10 blocked dependency lifecycle scripts by default in January 2025, and pnpm 11 delays versions younger than 24 hours by default. Running dependency install scripts by default was a design mistake, and both package managers have now corrected it.
For the paths a lockfile cannot cover, check packages at the moment they are fetched. Socket Firewall does this by wrapping the package manager: sfw npm install proxies the registry requests, including transitive ones resolved on the spot, and refuses tarballs Socket has confirmed as malware. Its documentation lists the limits plainly. Versions flagged by AI but not yet confirmed by a human get a warning, not a block, and packages already in the local cache never touch the network, so the firewall never sees them.
| Control | Stops | Does not stop | Cost |
|---|---|---|---|
| npm 12 script blocking with pinned approvals | Install-time payloads, including binding.gyp and git prepare: Shai-Hulud, axios, @antv, Miasma, keyv, tensorlake | Runtime payloads like Qix, repository-open hooks, a new version of a package approved by name | Every upgrade of a native package needs a fresh approval |
| Committed lockfile plus npm ci | New malicious versions of dependencies already locked | Added or updated dependencies, npx, global installs, lockfile refreshes | Updates become a deliberate step |
| min-release-age of 1 day | Versions detected and pulled within a day, like the axios releases, gone in under four hours | Malware that stays up longer than the window | Security fixes wait too; npm audit fix refuses a fix newer than the window |
| Install-time malware check | Confirmed malware at fetch time, including transitive dependencies resolved by npx | Unconfirmed detections, cached packages, private registries | A network dependency in the install path |
| Staged publishing, on the publisher side | Publishes from stolen tokens or poisoned CI with no human 2FA | An attacker who controls the maintainer's machine and session | Opt-in, and a human approval for every release |
Our verdict: run npm 12 everywhere, with pinned approvals and npm ci from a committed lockfile. That combination would have stopped every install-time payload in the incident table, provided no one had approved those packages' scripts by name. Add a release-age window and an install-time malware check for what a lockfile cannot cover: new dependencies, upgrades, and npx. If you publish packages, turn on staged publishing. It protects your users, not you, and it assumes the attacker does not control your machine. In the axios case, maintainer Jason Saayman said the attacker's access would likely have been "complete irrespective of what was setup."
How do you fix npm vulnerabilities?
#You fix npm vulnerabilities by moving each affected package to a patched version, through a compatible update, a parent upgrade, or an override, after deciding which findings your code can actually reach. Here is a project pinned to express@4.17.1, audited with npm 12.2.1 on October 26, 2026:
$ npm audit
path-to-regexp <=0.1.12
Severity: high
path-to-regexp outputs backtracking regular expressions - https://github.com/advisories/GHSA-9wv6-86v2-598j
path-to-regexp contains a ReDoS - https://github.com/advisories/GHSA-rhx6-c78j-4q9w
path-to-regexp vulnerable to Regular Expression Denial of Service via multiple route parameters - https://github.com/advisories/GHSA-37ch-88jc-xwx2
fix available via `npm audit fix --force`
Will install express@4.22.3, which is outside the stated dependency range
node_modules/path-to-regexp
...
7 vulnerabilities (3 low, 4 high)
$ echo $?
1The seven findings are six transitive packages, body-parser, cookie, path-to-regexp, qs, send, and serve-static, plus express itself, flagged for depending on them. npm audit fix only applies updates allowed by your declared ranges, so it does nothing here. npm audit fix --force would move express itself, which is a real upgrade to test. The narrower tool is an override. npm explain shows why a version is in the tree, and overrides replaces it everywhere:
$ npm explain path-to-regexp
path-to-regexp@0.1.7
node_modules/path-to-regexp
path-to-regexp@"0.1.7" from express@4.17.1
node_modules/express
express@"4.17.1" from the root project"overrides": {
"path-to-regexp": "0.1.13"
}$ npm install && npm ls path-to-regexp
demo2@1.0.0 /root/demo2
`-- express@4.17.1
`-- path-to-regexp@0.1.13 overridden
$ npm audit | tail -1
6 vulnerabilities (3 low, 3 high)An override replaces a transitive version across the whole tree, so test it like a direct upgrade. If you set min-release-age, npm's documentation says npm audit fix keeps a package at its vulnerable version when the fix is newer than the window, warns, and exits non-zero. Add the package to min-release-age-exclude to take the fix. --audit-level=high only changes the exit code; it does not filter the report.
The harder question is which findings matter. All seven trace to HTTP handling code: denial of service in body parsing, ReDoS in route patterns, prototype pollution in query-string parsing, unvalidated cookie attributes, and template injection in redirects. Whether any is exploitable depends on which express features the application uses. Most CVE alerts are noise for the code that actually runs, and reachability analysis, not the CVSS score, should set the order of the fix queue.
How do you scan for npm security risks?
#You scan for npm security risks at three layers: advisories matched against the resolved tree, signatures and provenance checked against the registry, and behavior analysis of what each tarball contains.
Advisory scanning is npm audit and the tools that read the same databases. It needs a lockfile, matches on name and version, and only knows what someone has already reported. It is the right tool for npm vulnerabilities and a lagging one for npm malware.
npm audit signatures verifies the registry's ECDSA signature on each installed tarball against the keys published at /-/npm/v1/keys, and checks Sigstore provenance attestations where packages have them. Provenance is necessary but not sufficient: it tells you which pipeline built a package, not whether its code is safe. keyv@6.0.0 shipped with a passing attestation because the maintainer's real release workflow built source that was already trojanized.
Behavior analysis reads the tarball itself. The signals are concrete: a new install hook, a binding.gyp in a package with no native code, a new dependency appearing in a patch release (axios@1.14.1 added exactly one), obfuscated strings, and network or environment access at install time. Socket raises alerts on signals like these, including Install scripts and Known malware. Across the @antv wave it flagged most of the 639 versions with a median detection time of about 6.7 minutes.
The registry now does a version of this too. Since July 28, 2026, npm scans every newly published package before it becomes installable, which typically adds about five minutes. That is a welcome backstop, not a replacement for your own check. The keyv and cacheable compromise reached the public registry a week after scanning launched, and tensorlake about ten weeks later.
What npm still has not fixed
#The npm that ships with Node.js still executes install scripts by default. Node.js 24.22.0 LTS, released October 14, 2026, bundles npm 11.19.1. The Node.js Release Working Group decided not to land npm 12 on Node.js 22, 24, or 26. Node.js 27 enters alpha on October 28, 2026, and gets npm 12 only if it lands on the main branch first. Until a CI image installs npm 12 explicitly, the default posture of most JavaScript builds is the one every install-time incident in this entry exploited.
Publishing is the other open end. npm expects 2FA-bypass tokens to lose direct publishing around January 2027. Every default control added since 2025 acts after a valid credential has published. The one control that holds a publish for a human, staged publishing, is still opt-in. We think it should be the default for every package with meaningful downloads. Until it is, npm security is enforced by consumers at install time, one lockfile and one pinned approval at a time.
