Is it time to get rid of NPM?
For weeks now, there has been a constant theme, a Shai-Hulud wave hits npm (sometimes PyPI or Composer), maintainers scramble, security teams grep lockfiles, npm invalidates tokens or ships a new control, and within days, Mini Shai-Hulud is back with a different namespace.
This is no longer a two-day spike. Shai-Hulud has been chewing through the JavaScript supply chain for months: Bitwarden CLI, Aqua Trivy, Checkmarx, SAP packages, TanStack, @antv, node-ipc, and dozens more. Socket has tracked over 1,000 malicious versions across hundreds of packages in the broader campaign, often attributed to TeamPCP. The playbook is stable:
Get Execution → Steal Credentials → Republish Everything The Victim Maintains → RepeatSecurity teams aren’t failing from a lack of effort. The pipeline is wide with packages, GitHub Actions, IDE extensions, PyPI (five layers hit in one 48-hour window, including GitHub’s internal repo breach via a poisoned VS Code extension). AI and automation let offense move faster than the general recommendation “check advisory & bump version” loop. Auto-update and auto-merge turn a compromised patch into production before anyone finishes a blog post.
So is it time to get rid of npm?
TLDR: Not the registry itself, as you can’t unplug half the industry. But yes to retiring how we use it, install-time scripts, floating versions, long-lived publish tokens, and “latest is safe.”
What Shai-Hulud broke (and what it didn’t)
Shai-Hulud didn’t invent supply chain attacks. It industrialized a worm model that keeps working because our defenses are layered for CVEs, while the attacks are layered for trust.
The idea that “known-good maintainers” means safe installs
These aren’t obscure typosquats. They’re republished under real maintainer accounts. Often, every package that the account owns is in an automated burst (323 @antv packages in one wave, 84 TanStack versions before that). The users already depend on those names. A lockfile pins a name, not “was this version published by a human with coffee.”
The idea that modern publishing controls stop propagation
TanStack is the case study: 2FA, OIDC trusted publishing (no long-lived npm token), SLSA provenance on releases, and malicious versions still shipped with valid-looking provenance. Attack path in short:
Orphaned commit on a fork of tanstack/router, reachable via github:tanstack/router#<sha> and Actions.
Optional dependency pulled that commit; prepare ran on install (tanstack_runner.js).
That execution chain led to short-lived OIDC publish tokens because trusted-publisher trust was scoped too loosely (repo-level, not tight branch/workflow binding).
Worm logic then republished across the namespace.
Lesson Learned: Trusted publishing and provenance answer the question “did this come from that workflow?” but do not answer “was that workflow surface hijacked?” or “did install run attacker code before publish?”
NPM’s later moves by invalidating bypass-2FA granular tokens, staged publishing (human MFA before a version becomes installable), and minimumReleaseAge help maintainers close the publish loop. They do little for consumers who pull a version in the first hour it’s live.
Install-time execution as a first-class attack surface
NPM’s model treats lifecycle scripts (postinstall, prepare, etc.) as normal.
Shai-Hulud lives there: run once on npm install / pnpm install / CI install, harvest from 130+ credential paths (npm/GitHub/cloud/SSH/K8s/AI tool configs), use stolen npm tokens to republish elsewhere.
You don’t have to import the package. The installation is enough.
CI and Actions as the worm’s memory scraper
The worm reads GitHub Actions runner process memory and abuses weak workflow patterns: pull_request_target pwn requests, cache poisoning, overly broad OIDC. Tag-pinning didn’t save you when tags were swapped for imposter commits (Trivy, Checkmarx KICS, actions-cool/issues-helper). SCA doesn’t see inside the runner when a compromised Action spawns a process and exfiltrates.
The laptop and the IDE as entry points
The pipeline doesn’t start in the CI. Nx Console’s poisoned release (linked to the TanStack credential chain) showed extensions and auto-update as first-class vectors, leading to GitHub’s disclosure of ~3,800 internal repos exfiltrated. The npm-only SBOM won’t show that.
We’ll know from CVEs, and Dependabot will save us
Many malicious versions never map to a CVE during the exposure window. Bots that auto-merge patch bumps can accelerate compromise when the patch is the attack. Speed tools optimized for freshness fight you during an active worm month.
Trust signals that attackers wear as camouflage
Fake Sigstore badges in the AntV wave are a preview of where this goes, signals that look like security without a human or platform gate you control.
Shai-Hulud did not break the need for open source or the npm registry as a public good. It broke the assumption that consumers can stay passive while maintainers and npm, Inc. absorb the whole problem.
What Should a Security Team Do
Consumer installs (every repo, every developer)
Default-deny install scripts
Move to pnpm v10+ (or Bun with trusted-deps policy) so dependency lifecycle scripts are off by default.
Curate an explicit allowlist for native tooling only, pinned names:
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "sharp", "@swc/core"],
"strictOnlyBuiltDependencies": true
}
}
strictOnlyBuiltDependencies fails the install if a listed package no longer has scripts, which reduces “dormant allowlist” risk if a package later adds a malicious postinstall.
If you must stay on npm: npm ci --ignore-scripts in CI, block plain npm install in prod pipelines. Document how devs run one-off rebuilds when scripts are legitimately needed.
Time-delay new versions
Attackers depend on immediate uptake. Set a cooldown and pair with an internal pull-through proxy (Artifactory, Cloudsmith, Verdaccio) that only serves artifacts after your delay + scan.
Lockfiles as law
Committed lockfiles for anything deployed. No floating ^/~ on release branches; Renovate/Dependabot opens PRs, humans or policy bots approve, no auto-merge in prod during elevated threat.
Import-time malware (honest limit)
Script blocking doesn’t stop code that runs on require(). Combine install controls with runtime monitoring in sensitive services and threat-intel blocking on known bad versions
CI/CD and publishing (where the worm reproduces)
GitHub Actions hygiene
Pin actions to full commit SHAs, not @v4 tags.
Remove or refactor pull_request_target; never run untrusted PR code with write secrets.
Scope Actions cache by branch; treat cache as untrusted input.
CODEOWNERS on .github/workflows/**, package.json, release scripts.
Publishing Surface (if you maintain npm packages)
OIDC trusted publishing instead of long-lived tokens; scope to protected branch + named workflow file (not repo-wide).
Enable staged publishing where available: CI stages, human MFA approves before the version is installable.
No bypass-2FA granular tokens in secrets; rotate everything if a runner or laptop touched a wave.
Runner Hardening
Ephemeral runners; least privilege permissions: on workflows.
Egress allowlist on build networks (registry + known APIs only) blocks common exfiltration domains that worms use.
Runtime guardrails on runners: detect foreign processes reading /proc/*/mem, unexpected python3/bun spawns from Actions that shouldn’t need them (Harden Runner–class controls).
Separate “build” from “publish”
Publish job on a locked-down workflow with minimal deps, no PR triggers, no fork access, so a compromised test job can’t trivially swap npm publish for npm stage publish without passing human gates you actually enforce.
Visibility and response (when the wave is already live)
Inventory beyond package.json, maintain queryable data for:
Resolved npm/pnpm versions per service (SBOM / lockfile export).
GitHub Actions per repo (name + SHA).
IDE extensions on eng machines (MDM or endpoint agent).
Global CLIs and base images that run npm i -g.
Threat intel → Enforcement
Wire feeds into PR checks and proxy deny lists. Treat it as a reactive layer only and pair it with minimumReleaseAge and pins.
Have an incident response runbook
Program-level (quarterly, leadership-backed)
Tier-based service:
Tier 0 (critical): allowlist or strict proxy + max release age + no install scripts + manual dep review.
Tier 2 (internal tools): faster updates but still lockfile + script policy.
Leverage AI tools
Use AI to search, analyze, triage advisories and diff lockfiles. Do not give agents publish credentials or auto-merge to prod. Human gate on high-download packages (@tanstack/*, echarts-*, etc.).
Conclusion
Shai-Hulud has been running for weeks because openness and speed are features that attackers ride. NPM isn’t going away, but unconditional trust in install has to.
We used to trust the maintainer’s name on the tarball. Now, a stolen session or a hijacked workflow can publish under that name overnight. Provenance tells you where a release came from, not whether that pipeline was still yours. The fix is defaults, not drama: block install scripts unless you allow them, wait before new versions hit prod, pin lockfiles and Action SHAs, lock down publishing (OIDC scope, staged releases), inventory packages and Actions and extensions, and rehearse IR so you’re not inventing the playbook at 7 a.m.
Keep the registry, but change what happens when someone runs install