• NPM
  • Cybersecurity
  • Blockchain
  • Software Supply Chain
  • JavaScript

JavaScript Supply Chain Scare: When npm install Leads to Full Compromise

You pull in a familiar SDK on a Monday morning. The command scrolls by, a few extra lines from a preinstall script you don’t really read, and that’s it. No red flags. No flashing lights.

Published byOKcontract Labs9 min read
JavaScript Supply Chain Scare: When npm install Leads to Full Compromise

You pull in a familiar SDK on a Monday morning. The command scrolls by, a few extra lines from a preinstall script you don’t really read, and that’s it. No red flags. No flashing lights.

Somewhere inside that install step, a worm wakes up, grabs your npm token, your GitHub token, your AWS keys, and quietly uses your identity to infect hundreds of other packages.

That worm has a name: Shai‑Hulud. And this is round two.

Act I — The sandworm under your package.json

Security teams at Aikido had met Shai‑Hulud before. In September, they wrote up a campaign where a worm abused npm packages in the nx ecosystem. A few months later, their dashboards lit up again.

This time, the targets were bigger:

  • SDKs and packages from AsyncAPI, PostHog, Postman, Zapier, ENS Domains, Browserbase and others
  • ~492 packages in total
  • Around 132 million monthly downloads across those packages

On the surface, the attack looked simple: new versions published under trusted names. But the payload was anything but simple.

The infection chain

The second wave looks roughly like this:

  1. Steal a maintainer’s publishing power The attacker gets hold of real npm tokens for real maintainers. Compromised accounts push fresh versions of popular packages, signed exactly the way a normal release would be.
  2. Hide in a lifecycle script Those releases add a preinstall hook that pulls in setup_bun.js. That script ensures the Bun runtime is available, then runs a second script, bun_environment.js, where the worm logic lives. The key detail: preinstall runs as part of npm install. You don’t have to import anything. You don’t have to call any function. Install equals execute.
  3. Hunt secrets, everywhere Once the Bun environment is live, the code starts rummaging:
  • Environment variables
  • Local cloud config files
  • Files and directories that usually hold credentials
  • CI‑related folders

It even calls out to TruffleHog to do deep secret scanning, then uploads whatever it finds into attacker‑controlled public GitHub repos named things like “Sha1‑Hulud: The Second Coming”. Wiz sampled around 20k of those repos and found hundreds of valid GitHub, AWS, GCP and Azure credentials even after removing duplicates.

  1. Turn your infra into an infection vector On GitHub, the malware registers the machine as a self‑hosted runner called SHA1HULUD. Then it drops workflows such as .github/workflows/discussion.yaml that can execute arbitrary code whenever someone opens a Discussion or runs a particular job. It also adds workflows that serialize all GitHub Actions secrets to a file, upload them as an artifact, and then quietly clean up the branch. In cloud environments it goes after IAM roles and secret managers, trying to turn any key it finds into more access.
  2. Destroy data if it can’t move sideways Aikido’s analysis mentions a nasty fallback: if the worm can’t talk to GitHub or npm, it may simply wipe the user’s home directory.

So the “side effect” of installing a tainted dependency ranges from “your npm token got stolen” to “your laptop is blank”.

Act II — When a real product gets bitten

All of that might still sound abstract until you realize real SDKs you’ve probably used were carrying this thing.

PostHog’s co‑founder publicly confirmed that several versions of their JavaScript and Node packages briefly contained the malicious code:

  • posthog-node
  • posthog-js
  • posthog-react-native
  • posthog-docusaurus

Their story is the uncomfortable one many teams recognize:

  • A publishing token was compromised outside their CI
  • Malicious versions went out under their name
  • They rotated keys, unpublished the bad versions, and re‑released from a clean pipeline

Nothing in their product roadmap prepared them to become a worm host. The only “bug” they had to fix was where their secrets lived.

If you maintain any npm package, it’s hard not to map this onto your own setup:

  • Where are your publish tokens stored?
  • Could someone bypass CI and publish directly?
  • Would you even notice an unexpected version going out under your scope?

Act III — The npm install habit

Once you look at Shai‑Hulud’s behavior, you end up staring at a deeper question:

Why does npm install have the power to run arbitrary code from strangers as part of dependency resolution?

This isn’t new. npm’s lifecycle scripts have existed for years. They were meant for things like building native modules and downloading platform‑specific binaries. Over time, huge swaths of the ecosystem started relying on them. Pull in a package, run its postinstall, and magic happens.

Shai‑Hulud just treats that design as what it really is: a remote code execution pipeline glued directly to your developer machines and CI.

Engineers responding to the incident have been converging on a few ideas.

1. Stop letting fresh code sneak into CI

One thread of thinking is about time.

Most teams still do something like:

npm install

in CI, based on version ranges:

"dependencies": {
  "some-sdk": "^3.26.0"
}

If you happen to build during the hour a malicious 3.27.0 is live, you become part of the blast radius.

Mitigations people are rolling out:

  • Use npm ci or pnpm’s equivalent in CI so installs follow the lockfile exactly instead of re‑resolving versions.
  • Pin dependencies to exact versions as much as possible; treat dependency updates as explicit change events, not background noise.

And then comes the interesting idea: dependency cooldowns.

Both pnpm and Bun now have features that let you declare “don’t install a version that was just published; only accept it after some minimum age.” Your production builds intentionally lag behind npm by a few days so someone else hits the landmine first.

Python folks note you can approximate the same pattern using uv with --exclude-newer and forcing binary wheels (--only-binary=:all:) instead of running arbitrary setup.py code.

It’s a speed vs blast‑radius trade. Shai‑Hulud makes the trade feel less hypothetical.

2. Stop running scripts you don’t need

The second idea is about what install is allowed to do.

Tools like pnpm 10 and Bun can:

  • Disable lifecycle scripts by default
  • Ask you to explicitly allow scripts for specific packages
  • Let you configure which environments (dev vs CI vs prod) are allowed to run them at all

You still need scripts for some things — native modules, platform‑specific cryptography, sometimes CLI setup. But the expectation flips:

Scripts are suspicious until explicitly allowed, not harmless until proven guilty.

Combine that with sandboxed installs (Bubblewrap, containers, ephemeral runners) and you finally get closer to “install is just copying files and verifying hashes”, not “install is this wild tunnel into your infrastructure.”

Act IV — The worm that eats tokens

Shai‑Hulud doesn’t only care about code; it cares about identity.

Every credential it finds — npm tokens, GitHub tokens, AWS keys, GCP service accounts, Azure secrets — is another chance to:

  • Push more malicious packages
  • Add more backdoored workflows
  • Pivot into more infrastructure

So the story naturally shifts from package managers to key management.

Static tokens on laptops are dead

The pattern that keeps coming up is:

  • No publish tokens on developer laptops
  • Tokens scoped to a single package or small set of actions
  • Tokens restricted to run only from specific IPs or runners

Some teams go further and issue dynamic, short‑lived credentials from Vault/OpenBao or similar systems:

  • Each build or pod gets a lease and a fresh set of credentials
  • When the lease ends, the credentials are revoked
  • Dumping the env vars from one job doesn’t give the attacker a long‑term key

For a worm that scans entire file systems for juicy tokens, this turns “we’re doomed forever” into “we have a tight window to rotate and respond.”

Trusted publishing and OIDC

npm’s Trusted Publishing model moves in the same direction: instead of storing a static token in CI, a GitHub Actions workflow presents an OIDC identity (“I am workflow X in repo Y”), and npm issues a short‑lived credential for that run.

PostHog’s plan after the incident is a good example of the mindset shift:

  • Kill the compromised token
  • Move publishing to OIDC‑based workflows
  • Add alerts for new versions that don’t match expected pipelines

Does this make you invincible? Obviously not. If someone can run arbitrary code inside your CI, they can ask npm for a token just as easily. But it removes the “steal a key today, publish next month” path that worms thrive on.

Act V — Who should read all this code?

The social question lurking in all of this: who is actually responsible for understanding dependency code?

Security folks have a harsh answer: if your pipeline takes arbitrary third‑party code and pushes it to production without anyone ever looking at it, that’s a choice, not an accident.

Developers push back: in attacks like Shai‑Hulud, the code runs during install, possibly on a developer machine before any review or test. You can’t realistically expect every engineer to reverse‑engineer every dependency’s lifecycle script before they run npm install.

Some more realistic patterns emerge:

  • Treat dependency intake like vendor intake. Someone — security, platform, or a rotating “librarian” — owns the decision to bring in a new library.
  • Use central registries and features like PyPI’s “report project as malware” so deep audits can benefit entire ecosystems.
  • Add light process: design docs briefly justifying new dependencies, plus automated checks (SCA, license, known malware indicators).

The world where “library = magic box you never think about” is gone. The new world looks more like “libraries are vendors, with contracts and due diligence, even if the vendor is a solo maintainer on GitHub.”

Act VI — What this looks like in blockchain

Now zoom into the blockchain frontend world, where these patterns get amplified.

Dapps often ship:

  • Heavy JS frameworks
  • Several blockchain client libraries (web3.js, ethers.js, viem, chain‑specific SDKs)
  • Extra tooling for wallets, hardware devices, analytics, on‑chain indexing, graph clients

The result is… chunky.

A few data points:

  • One developer measured their web3 trading app at 1.2 MB of JavaScript, with web3.js alone contributing ~540 KB (45%) of the total and a 12.3‑second time to interactive on 3G.
  • A comparison of web3.js and ethers.js puts web3.js at roughly 620 KB bundle size by itself.
  • The Play2Dev dapp frontend roadmap bluntly describes web3.js as “legacy” with “several MB bundle size” and recommends avoiding it for new projects.
  • On the Polkadot side, a very simple dapp built with @polkadot/api, just initializing the API and fetching a balance, weighs in at around 1 MB before compression, still quite large even after gzip.
  • A Blocknative case study shows the Ledger Ethereum package weighing about 1.2 MB, with careful dynamic import needed just to trim a single module’s share of a wallet provider bundle.

This is before you add:

  • UI frameworks
  • Routing
  • State management
  • Analytics
  • “Just one more” helper SDK

From a performance point of view, that hurts users. From a supply‑chain point of view, each extra library is another potential Shai‑Hulud host. Web3 frontends are exactly the kind of apps that:

  • Live in the browser
  • Have direct access to wallets and signing
  • Need to be trusted by users to show accurate balances and transactions

Yet many of them ship multiple megabytes of JavaScript pulled from deep dependency trees that few people have ever read carefully.

Act VII — A quieter way: small, boring, zero‑dep libraries

That’s why at OKcontract Labs, we’ve been taking a different route for a while: zero‑dependency libraries wherever we can get away with it.

Not because dependencies are evil in themselves, but because:

  • Fewer dependencies mean fewer attack surfaces and fewer lifecycle scripts.
  • Smaller, well‑scoped libraries are easier to audit once and keep on a short leash.
  • A 5 kB helper you wrote and understand tends to be safer than a 600 kB dependency that drags in 20 other packages.

When you work in blockchain, that matters twice:

  1. Users already live in an environment that’s fragile and expensive (gas, latency, wallet UX).
  2. The blast radius of a compromise isn’t “someone saw your cat photos”, it’s “someone drained your treasury”.

We’re not purists; sometimes a dependency is the right call. But Shai‑Hulud is a strong reminder that “just npm install it” is no longer a neutral act. Every package is a trust relationship.

If you’re building in this space, it might be time to ask:

  • Can we swap a giant web3 client for a slimmer one?
  • Can we push more logic into audited contracts and expose smaller, simpler client code?
  • Can we replace stacks of convenience libraries with a few well‑tested utilities we actually understand?

That’s the direction we’re pushing toward at OKcontract Labs: high‑quality, low‑dependency building blocks that you can drop into frontends without dragging half of npm in behind them.

Shai‑Hulud won’t be the last worm to crawl through the registry. But we can choose how much food we leave out for the next one.

Originally published on Medium.

Related reading