Ossprey raises $2.65m to fight AI-era software supply chain attacks.

Ossprey raises $2.65m to fight AI-era software supply chain attacks. Learn More

Ossprey

Ossprey raises $2.65m to fight AI-era software supply chain attacks.

Ossprey

BACK

Real World Attacks

Shai-Hulud Again: Ten Signed Releases and the State of npm 12 Adoption

Valentino Duval

Valentino Duval

28 Aug 2026

Real World Attacks

Shai-Hulud Again: Ten Signed Releases and the State of npm 12 Adoption

Valentino Duval

Valentino Duval

28 Aug 2026

No headings found in content selector: .toc-content

On August 28th, just a few days after two alleged TeamPCP members were arrested in Australia, another mini Shai-Hulud attack was observed in the Node ecosystem. The target was @7nohe/openapi-react-query-codegen, a popular OpenAPI code generator for TanStack Query.

As it turns out, this malware was just another version of the Miasma Shai-Hulud worm, a malicious package that was publicly released, derived from the original TeamPCP Shai-Hulud malware.

We've seen and reviewed these npm worms before, and have covered TeamPCP extensively, but in this post we'll discuss these attacks as a class, and what changes npm, and the broader ecosystem, have made to prevent these attacks from occurring in the future.

The target package, which pulled around 150k downloads weekly, was compromised through a pwn-request, where the privilege on offer wasn't CI secrets but the project's npm-publish identity, reached through a comment-triggered workflow that ran an untrusted fork's install script.

What that means is that an attacker simply had to raise a PR into the target repo, and comment "npm publish", and the pipeline would release a new version of the package. Since it was coming from the authorised repo and pipeline, the malicious package would also get a valid provenance tag from the pipeline, and a nice green tick on npm.

This is different from other attacks we've seen where a publisher account is compromised and a new version of a package is published directly to npm; those won't have valid provenance or a green tick. Of course, these types of security features are only useful if you actively check them before installing, and in this case there would have been no issue to detect.

This is what that looks like on the npm page.


provenance-yes-no.png
on:
  issue_comment:
    types: [created]

on:
  issue_comment:
    types: [created]

on:
  issue_comment:
    types: [created]


provenance.png

So a pull request and a comment were all it took. Over the course of 20 minutes the attacker, a GitHub user with the username p00paboot, was able to publish 10 malicious versions of the package.

The ten were 0.5.4, 0.5.5, 1.6.3, 1.6.4, 2.2.1, 2.2.2, 3.0.3, 3.0.4 and two 0.0.0-<sha> builds. All ten have since been unpublished, and latest is back to the clean 3.0.2. If you have a lockfile or a cache pinning any of them, that is the only place they still exist.

The change was simple: the inclusion of an npm install hook script. We've seen and covered plenty of these before too.

Before the attacker could push malicious versions to users, they first had to get the repository's own pipeline to publish for them.

Stage one runs on the runner. The preinstall in the pull request never touches a victim. It fires when the pipeline runs pnpm install, which blocks dependency scripts but still runs the project's own, and starts the worm on a runner that holds the repo's publish identity:

The env vars tell the worm it is in the target repo's release workflow and what to poison. It swaps the runner's OIDC token for a publish token and publishes all 10 versions, provenance and all.

Stage two ships to victims. The published versions carry a hook aimed at whoever installs them. The second batch is blunt:

The first batch has no preinstall at all. The trigger hides in binding.gyp, which node-gyp evaluates as Python and shells back to the same loader (strings hidden with Unicode and hex escapes):

[c for c in ().__class__.__base__.__subclasses__()
   if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os')

[c for c in ().__class__.__base__.__subclasses__()
   if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os')

[c for c in ().__class__.__base__.__subclasses__()
   if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os')

Clean-looking package, no install script in sight, runs on install anyway.

They never got write access to the repo, only its ability to publish, and only for one workflow run.

Why a worm is so dangerous

At its core this malware would steal as many tokens and secrets from its environment as possible. Think npm publish tokens, GitHub PATs, cloud credentials (AWS/GCP/Azure) and secrets for HashiCorp Vault, Kubernetes, Docker, Terraform and Ansible, to name a few.

That's only in a runner or production environment. On a personal or engineer machine, it would also steal AI tokens, crypto wallets, password vaults, and messenger cookies.

A worm does that and then uses what it stole to publish itself somewhere new. That difference is the whole game, because it turns a linear problem into an exponential one, and install-time execution is what makes it possible on npm.

Think about why npm in particular is such fertile ground for this. A maintainer's laptop or CI runner almost always holds the one credential that matters: a token that can publish. Packages depend on packages depend on packages, so a single compromise sits upstream of thousands of trees it has never heard of. Install runs code automatically, so the worm does not need anyone to open or call anything. And the registry itself will now hand out a short-lived publish token to any CI job that asks with the right OIDC identity, which means the worm does not even need to steal a long-lived secret to republish; it borrows the pipeline's own trust for the length of one build. Compromise leads to credentials, leads to publish, leads to compromise.

npm knows this, the community knows this, and so something had to be done.

The fix


npm-tweet.png

First of all, after the majority of these worm-based attacks, npm has been revoking publish tokens to contain the spread of attacks. This is a manual process, unsustainable and painful for both npm and developers who rely on automated workflows to deploy their packages.

There have also been changes regarding the usage of certain tokens for automated publishing, and importantly a major breaking change: the disabling-by-default of npm install hook scripts.


npm-warning.png

On 8 July npm shipped v12, and v12 turns off install scripts by default. No more preinstall, install, or postinstall running automatically on npm i, not from your direct dependencies and not from the transitive native module you have never heard of.

GitHub, which now owns npm, has called install-time lifecycle scripts "the single largest code-execution surface in the npm ecosystem", which is the kind of sentence you write after a very bad year, and we are inclined to agree.

I want to give the change its due before I spend the rest of this post being a pessimist about it.

Killing the automatic run is exactly right for the people we are trying to protect, and v12 goes further: it flips the defaults for git dependencies and remote-URL tarball dependencies too. node-gyp compile installs are also disabled by default.

This attack, pointed at a machine on npm 12 with a default config, does not run. Job done? Not quite.

The catch is that this is a breaking change, and plenty of us rely on install scripts without knowing it. fsevents compiles from source with node-gyp rebuild, and it reaches you through chokidar, which sits under most dev servers and watchers. bufferutil and utf-8-validate build on install too, pulled in as optional dependencies of ws.

That is also why the rollout has been slow. A breaking change like this is inevitably going to take time to be adopted.

Who uses npm 12?

A fix is only as good as its distribution.

Node.js is not shipping v12

Almost nobody installs npm directly. You install Node, and you run whatever npm came with it. So the single biggest lever for getting v12 into the world is Node bundling it.

The tracking issue, nodejs/Release#1161, "Plans for npm 12", was opened by the npm team in June and closed as completed on 13 August 2026.

The bot pull request to actually bundle it, nodejs/node#64882, "deps: upgrade npm to 12.0.2", was closed on 12 August 2026 without being merged.

As I write this, every Node branch still bundles npm 11.19.0 or older, main included.

The plan of record is that v12's defaults are not being backported to Node 22, 24 or 26; they target Node 27, whose line does not even begin until late 2026, though this is subject to change.

In 2026, npm 12's install-script default reaches you only if you go out of your way to run npm i -g npm. The bundled-npm majority does not get it this year at all. So when I show you how few people are on it, keep in mind that a chunk of that gap is not a rollout in progress.

The Rollout

There is an on-ramp, with deprecation warnings, the approve flow and the chance to publish an allowlist before you switch to v12. Unfortunately, these warnings only appear on npm versions 11.16.0 or newer, published on 27 May 2026, six weeks before v12.

To figure out what the distribution actually looks like, we decided to collect some data. Let's take a look.

Node publishes daily download totals by version, and we mapped each Node version to its bundled npm. Apply the 11.16.0 cutoff and only 27.9 percent of Node downloads carry an npm new enough to even warn about v12.


warning-scoreboard.png


coverage-over-time.png

The registry reports per-version download counts for the npm package itself, a different crowd that types npm i -g npm or bakes it into a Docker layer. Even here, npm 12 is only 7.1 percent.


registry-versions.png

Docker tells its own story. With nearly 7 billion total downloads, the latest tags do now include npm versions that warn about the changes and notify of an upgrade. However, Node 20, despite being EOL, remains overwhelmingly popular, and is still an environment where a malware attack using install scripts would run.


dockerfile-pins.png

While we are on the right track, adoption remains slow, even stalled. Security that is accompanied by breaking changes, version bumps and active changes by your engineers and security teams will always be slower and more painful. We built Ossprey to fill this gap: package scanning irrespective of infrastructure, environment, and configuration.

Other package managers

npm is in a difficult position; as the largest and default package manager for the Node.js runtime, its changes affect the largest user base. As such, we should expect them to be slower and more cautious when it comes to implementing changes.

Other package managers, such as Bun, Yarn, and pnpm, have disabled install hook scripts for quite some time now.

Datadog found that 54 percent of JavaScript organisations adopt a new library version less than a day after it is published, and that 1.6 percent of npm organisations pulled at least one confirmed-malicious dependency in a year. This change is certainly welcome and will protect many organisations as a baseline.

Malware evolution

Despite this change, malware developers continue to adapt and, in many cases, are one step ahead of defenders. As one door closes, another opens.

Six days after v12 shipped, @asyncapi/generator@3.3.1 and four sibling packages went out carrying an obfuscated dropper that fired at module-load time, when your code does import or require, not in a postinstall hook. v12's install-script block does exactly nothing against that, because it never touches an install script.

Around the same time several jscrambler packages shipped hidden native binaries that run during install and use, using stolen publish credentials. v12 does stop that one. The block works where it applies, it just does not apply to everything.

As we continue to improve detection both on ecosystem and off, so too will attackers continue to develop their techniques.

Let's look at some practical changes you can make to your organisation and setup to reduce risk and impact immediately.

What you can do today

  • Find out what your CI's npm actually is. Even if you can't change it, being aware of this as a risk is important.

  • Update your Docker Node.js versions. Version 20 is EOL and version 22 is superseded by version 24. Neither of these will ever show a warning for npm 12 or provide an update path.

  • Audit your own release workflows for comment and fork triggers. issue_comment and pull_request_target can hand your publish identity to anyone who can open a pull request.

And the part these steps won't cover, the transitive package that gets compromised tomorrow and ships with perfect provenance, is the part you can't mitigate with configuration.

That is the layer Ossprey is for: we scan packages as they are published, match the behavioural signatures of run-on-install, import-time and self-propagating payloads, and flag the malicious release before it reaches your install, independent of whatever npm version your runner happens to be frozen on.

This npm change has raised the floor, but security is a continuous process, and attackers are already adapting.


SHARE

Subscribe Now

Subscribe Now

Subscribe Now

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.