pgserve: the npm worm that jumped to PyPI

On April 21, 2026, a self-propagating worm landed on npm as pgserve and then pushed malicious packages to PyPI. One install in one ecosystem became publish access in another.

TL;DR

On April 21, 2026 at 22:14 UTC, a new package called pgserve was published to npm. Within four hours, two more malicious versions of the same package landed, plus malicious publishes for five other npm packages (kube-health-tools, xinference, @automagik/genie, fairwords, openwebconcept) and two PyPI packages (kube-node-health, xinference). The whole campaign is the work of a single self-propagating worm that harvests credentials at install time, then uses them to publish more malicious packages into whatever registry the stolen tokens can publish to.

I have been writing up npm supply-chain incidents through this cycle: the original Shai-Hulud worm in September, the 2.0 variant in November, the axios maintainer compromise three weeks ago, the Asurion impersonation campaign two weeks ago. The pgserve campaign is the first one where the propagation crossed registries from a single intrusion. That is the part I want to focus on.

What happened

The campaign was first flagged by Semgrep within hours of the initial publish. The Register’s writeup is the most readable narrative summary. Socket and Snyk both flagged the artifacts via their automated scanners.

Approximate timeline (UTC):

  • April 21, 22:14: [email protected] published to npm. The name is plausible: a PostgreSQL serve helper is the kind of thing someone might paste into a terminal. The published artifact contains a postinstall script that runs the worm payload at install time.
  • April 21, 22:38: [email protected] published. Same shape, slightly different payload.
  • April 21, 23:51: [email protected] published. Third variant of the same publish window.
  • April 22, ~00:30: kube-health-tools, xinference (npm), @automagik/genie, fairwords, openwebconcept (all npm) appear with the same payload pattern. These are not new accounts; they are existing maintainer accounts whose tokens were harvested by the first installs of pgserve.
  • April 22, ~01:15: kube-node-health and xinference appear on PyPI. The PyPI publishes happened under different account names than the npm publishes; the worm correlated stolen credentials with each registry’s auth format and used them where they applied.
  • April 22, morning UTC: Semgrep advisory goes live. Most of the malicious packages are pulled from their respective registries within the next several hours.

Sixteen distinct malicious artifacts across two registries, originating from one initial publish, in under twelve hours. Socket’s automated scanner flagged the very first [email protected] within six minutes. Detection time is getting good. The propagation is getting better.

The cross-registry mechanic

Until now, all the npm supply-chain incidents I have written about have stayed inside npm. The reason is fairly mundane: an npm worm written by someone who knows npm has working code for stealing npm tokens, manufacturing npm publish requests, and bypassing whatever guardrails the npm CLI has. PyPI is a different protocol, different auth format, different metadata, different tooling. Cross-pollinating between them takes additional engineering.

The pgserve worm does it because it harvests credentials from the machine, not from any one registry. Specifically, the install-time payload scans for:

  • ~/.npmrc (npm)
  • ~/.config/pip/pip.conf and ~/.pypirc (pip / PyPI publish credentials)
  • ~/.gem/credentials (RubyGems)
  • ~/.composer/auth.json (Composer)
  • ~/.aws/credentials (cloud, for the secondary value)
  • ~/.ssh/id_* (SSH, also secondary)
  • GitHub Actions environment variables in CI contexts

The serialized credentials are POSTed to a hardcoded C2 endpoint. The C2 sorts them by registry, queues republish jobs, and uses the stolen tokens to publish malicious payloads into whatever package namespaces those tokens unlock. The PyPI publishes in this campaign happened because some of the initial npm developers (the ones whose machines got infected via [email protected]) also had PyPI publish tokens cached in ~/.pypirc.

This changes the model. An npm install compromised a machine that then published to PyPI. The cross-registry victim does not need to use npm in their daily work. They just need to share a workstation, CI runner, or maintainer account with someone who does.

For the registries themselves, there is no clean fix. npm cannot prevent PyPI publishes from credentials harvested on a machine that ran an npm install. PyPI cannot detect that a publish came from a stolen token vs a legitimate token; the token is the token.

What was supposed to stop this

The standard npm hardening list is now familiar: 2FA on accounts, OIDC trusted publishing, Sigstore provenance, --ignore-scripts, npm audit. Each one is useful, each one has the same limitation it had during the Shai-Hulud waves.

  • 2FA: protects the login. Does not protect against stolen automation tokens. The pgserve worm uses tokens harvested from .npmrc and .pypirc, both of which contain long-lived publish credentials by default.
  • OIDC trusted publishing: would help if every maintainer had moved to it. Most have not. The packages compromised in the propagation step (kube-health-tools, xinference, etc.) are maintained by individuals using automation tokens, which is the npm baseline.
  • Sigstore provenance: cryptographic proof that a workflow on a specific repository published an artifact at a specific commit. The pgserve packages did not have provenance because their attackers did not bother to set it up. Scanners that require provenance would have refused to install them. Most scanners do not, because most legitimate packages also do not have provenance yet.
  • --ignore-scripts: would have stopped the postinstall execution. I still think ignore-scripts=true should be the default, with manual overrides for known-good packages like esbuild or sharp. The friction is real, which is why many teams have not adopted it.

What did help (and what helped most)

Detection time was good. Socket flagged within six minutes, others within the first hour. The first [email protected] was on npm for roughly five hours before npm pulled it; the propagation packages had shorter active windows. For developers who installed after detection, the malicious version was already gone.

A release-age cooldown gets you outside the active window. I have been writing about this since the original Shai-Hulud post. Three waves later, I am more convinced, not less:

  • A four-day cooldown on [email protected] means no install of that version reaches a developer machine before npm pulls it. The install simply does not happen.
  • The same cooldown applies to the propagation publishes (kube-health-tools@malicious-version, etc.): a developer running npm update on April 22 would not get the malicious version; their package manager would resolve to the prior known-good version, which is what the developer wanted anyway.
  • The cooldown crosses registries naturally. The PyPI cooldown via uv’s exclude-newer setting would have similarly blocked the PyPI propagation publishes.

The trade-off is the same as always: you delay legitimate fresh versions by four days. For routine installs, that delay is invisible. For urgent security fixes, you need an override; most package managers provide one.

Where I am with the tool

pkg-quarantine is now published as @happyberg/pkg-quarantine. Two commands matter: quarantine init writes the cooldown settings; quarantine audit verifies that the manager is actually enforcing them.

npm install -g @happyberg/pkg-quarantine
quarantine init
quarantine audit

The audit is the part I care about most. Unsupported manager versions and wrong paths can leave you with a configured-looking cooldown and no actual gate. quarantine audit catches that, including the pnpm macOS path bug that first made me distrust my own setup.

The pgserve lesson is the same one in fewer words: registries are not isolated if developers share machines, tokens, and CI runners. A cooldown is not the only defense. It is the boring layer that refuses to be first in line.

References