zod is the kind of dependency you add without thinking about it. The schema validation library sits in millions of TypeScript projects; commenters in GitHub’s incident thread put its weekly downloads above 280 million. On Sunday, September 21, 2026, a commit landed in the public colinhacks/zod repository with the message Fix bugs and improve performance.

Within the same hour, the same change appeared in two more repositories: nubjs/nub and standard-schema/standard-schema. The commits did not fix bugs. Each one added a loader to a package installation hook. The loader was built to fetch and run remotely controlled JavaScript on every machine where the package was installed.

TL;DR

  • Malicious commits reached three public repositories on 2026-09-21: colinhacks/zod, nubjs/nub, and standard-schema/standard-schema. All three used the commit message Fix bugs and improve performance.
  • All three added the same loader: an install-time hook that resolved a remote delivery location through public Ethereum services, then downloaded and executed attacker-supplied JavaScript.
  • Only @nubjs/nub@0.9.4 reached npm’s publishing pipeline.
  • npm’s publish-time malware scanning held the version in validation, classified its risk as High, and blocked it at 13:09 UTC — nine minutes after the commit that completed the package update.
  • The zod and standard-schema changes never reached npm publishing. Hardened release workflows stopped them at the same boundary.
  • GitHub’s advisory states that no available evidence establishes that a downstream system executed the malicious content.

Sources: GitHub’s security advisory, the npm publish-time scanning changelog, and maintainer comments in the advisory thread.

The Loader

In the analyzed nub package, the malicious file was npm/nub/preinstall.js. The package manifest configured it to run automatically through npm’s preinstall lifecycle hook. The related commits disguised the same loader under other paths:

Repository Malicious file paths
colinhacks/zod packages/zod/meta.test.mjs, packages/mini/meta.mjs
nubjs/nub npm/nub/postinstall.js, later renamed to npm/nub/preinstall.js
standard-schema/standard-schema meta.mjs, packages/utils/meta.mjs

The file followed six steps:

  1. Contact public Ethereum RPC services.
  2. Query a fixed smart contract for a delivery location.
  3. Contact the returned website and request additional content.
  4. Download remotely supplied JavaScript and payload data.
  5. Execute the JavaScript with the permissions of the installation process.
  6. Remove temporary files after execution.

The loader contacted three legitimate public RPC endpoints:

https://eth.llamarpc.com
https://ethereum-rpc.publicnode.com
https://cloudflare-eth.com

These services are shared public infrastructure. They are not malicious by themselves. They matter only when correlated with this incident.

The delivery domain the smart contract pointed to was dsakldjklsaj33112[.]com, with the loader endpoint https[://]dsakldjklsaj33112[.]com/loader. A response captured during the investigation printed only game over. The response was inactive when captured. That capture does not establish what the service returned earlier, or what it could return later.

This design matters more than the payload. Because the delivery location lives in a smart contract, the threat actor can change the delivered content without publishing a new package version. The tarball that a registry scans is a downloader, not the payload. The reviewed artifact and the executed artifact are different objects.

Timeline

Timestamp (UTC) Event
2026-09-21 11:56:52 Public colinhacks/zod commit d821685 adds the loader and installation hooks
2026-09-21 12:37:19 Public standard-schema/standard-schema commit 92703a5 adds the loader and an installation hook
2026-09-21 12:54:48 Public nubjs/nub commit da01135 replaces the install script with the loader and moves it to preinstall
2026-09-21 13:00:18 Follow-up nubjs/nub commit d38da4f completes the 0.9.4 package update
2026-09-21 13:09:47 npm’s static scanning detects @nubjs/nub@0.9.4, classifies it High risk, and blocks it from public availability

The Entry Point Was Release Automation

The malicious commits did not come through the package review path at all. According to the zod maintainer, a git push credential of some kind was compromised. The attack consisted of malicious commits and tagged pushes to main. Commit metadata does not establish who controlled the credential, device, or publishing process — but the target is clear: the release pipeline, not the code review process.

The three repositories responded differently:

  • zod had already moved to trusted publishing with a dedicated GitHub environment for releases. A tag push could not trigger a publish. The malicious commits landed in the repository, but the release flow could not be tricked into shipping them.
  • standard-schema had not been vulnerable to this type of attack since npm removed token-based publishing flows the previous December.
  • nub had a release pipeline triggerable by tag creation. That is the one path that worked — and it delivered the malicious version into npm’s publishing pipeline, where the scanner was waiting.

Where the Block Happened

npm’s publish-time malware scanning, enabled in July 2026, holds every newly submitted version before it becomes available for installation. Depending on the scan result, a version is published normally, held for additional review, or blocked.

For @nubjs/nub@0.9.4, the hold worked as designed. The version never became installable. That single decision prevented exposure to:

  • developers running npm install against the latest tag;
  • automated dependency tools, which would have opened pull requests that human reviewers routinely approve;
  • CI/CD systems that install dependencies on every build;
  • downstream mirrors and caches, which replicate published versions and keep serving them after a registry takedown.

GitHub’s advisory names the shift: from publish, detect, and remove to scan, assess, and prevent. The same incident under the old model is a familiar one — a malicious version ships, downloads accumulate, security vendors write it up, and the registry removes it days later while mirrors keep serving the tarball. Under the new model, the incident ends at 13:09 UTC with zero installs.

The Honest Scorecard

The obvious narrative — “npm’s scanner saved zod” — is overstated, and the zod maintainer said so directly in the advisory thread. The malicious zod and standard-schema changes never reached npm’s pipeline. Trusted publishing and hardened release workflows stopped them before any scanner was involved. The scanner’s confirmed save was nub, and that is the correct framing: a big win, for the version that actually needed it.

What the incident actually demonstrates is defense in depth at a single location. Two different controls — a hardened publish path and publish-time inspection — sit at the same boundary, and the attack had to defeat both. It defeated neither. Every registry that hosts packages faces the same boundary, and this incident is now the clearest public evidence for what belongs there.

One Scanner Is Not a Strategy

The npm block is the strongest third-party validation we have seen for scanning at the registry boundary. It is also a single data point from the one registry with the resources to build it. Three gaps remain.

The payload rotates. The loader resolved its delivery location from a smart contract, so the tarball contains a downloader rather than a final payload. A scanner that reasons about capability — lifecycle script, network access, dynamic code retrieval at install time — catches this pattern. A scanner that only matches known-bad content does not. Commenters in the advisory thread asked npm for machine-readable install-risk signals for exactly this reason.

Most registries cannot build this. npm runs publish-time malware scanning because GitHub can staff a malware research team. The average package registry, IDE marketplace, or internal artifact store faces the same class of threat with a fraction of the resources. The economics do not favor every registry inventing its own scanning stack.

Verdicts are trapped in silos. A blocked package, a scanner finding, and a maintainer incident report currently describe the same threat in incompatible formats. The evidence that would let registries and defenders share what they caught does not have a common shape.

The SCINTX Proposal

The control point npm proved is the right one. The contract that implements it should be open.

SCINTX — the Supply Chain Intelligence Exchange, proposed to the Eclipse Foundation and currently in the feedback stage — defines a vendor-neutral interchange layer between registries and security providers. A registry submits an artifact once. A gateway routes the submission to any conforming provider. Findings from different scanners are normalized into a common verdict model. A policy layer decides allow, hold, or block, with finding-linked rationale. The lifecycle is emitted as signed CloudEvents, so registries can poll or receive webhooks. The reference implementation is on GitHub under EPL-2.0.

Harbor already standardized pluggable scanning for container registries, with seven vendor adapters. SCINTX is the same idea generalized to every package ecosystem, with the parts Harbor left out: multi-provider orchestration, policy decisions with rationale, and event delivery.

This is not a theoretical position for us. Argus scans every extension submitted to Open VSX, and that work is what earned Yeeth Security a place in the Open VSX Security Hall of Fame. A registry does not need npm’s budget to run this control. It needs a contract that lets it plug in the scanners that already exist.

SCINTX is a proposal, not a finished standard. The community review is open, and it needs participants, not only reviewers. Registries can test the reference gateway against their own upload flows. Scanner providers can expose their findings through a common verdict model. Maintainers can weigh in on what install-time risk signals should look like. The contract that a community writes together is the one registries will actually adopt — join the review and help write it.

Outlook

  • Threat actors will keep targeting the release path — tag-triggerable CI, push credentials, and publish tokens — because the ecosystem hardened the package faster than the pipeline around it.
  • Dead-drop loaders will remain attractive because they separate the reviewed artifact from the executed payload. Expect the delivery locations to rotate faster after this incident.
  • Maintainer hardening will spread. By September 24, the zod maintainer had migrated every package to environment-scoped staged publishing that requires 2FA, so a compromised token alone can no longer publish a version.
  • Registries without publish-time scanning become the softer target. The technique did not fail; only one of the three delivery paths was closed before it could ship.

What Defenders Should Take From This

The general principle: the install hook is an execution boundary. A package that runs code in a lifecycle script grants itself execution on the developer machine before the package is even usable. Review of a package that changes an install script deserves the same scrutiny as review of a dependency version bump to an unknown publisher — more, because the script runs without the package ever being imported.

For maintainers: use trusted publishing, scope release environments, require 2FA on every path that can produce a version, and treat a push credential as a publish credential.

For registries: hold versions in validation before availability, and scan them there. A block at publish time costs one automated decision. A takedown after publication costs every downstream mirror, cache, and dependency bot that already shipped the tarball.

For developers: review what a package’s install hooks do before upgrading, and consider --ignore-scripts in environments where you do not need lifecycle execution.

Indicators

Published by GitHub in the security advisory:

Type Indicator
Malicious file SHA-256 (preinstall.js) 5ed518aaff5aebc729545b8eb223ec31927cd1bfa53169517f09cdbcc9fd5a0b
Delivery domain dsakldjklsaj33112[.]com
Loader endpoint https[://]dsakldjklsaj33112[.]com/loader

Behavioral signal: a package install hook that queries Ethereum RPC services for a delivery location, then downloads and executes JavaScript from the returned address.