Zod, nub and standard-schema repositories took malicious install-hook commits from a stolen git push credential (2026-09-21); no zod release reached npm; nub release blocked
TL;DR
On 2026-09-21 six commits pushed with a stolen git credential landed on main of colinhacks/zod, and the same morning on standard-schema/standard-schema and nubjs/nub. They added an install-time loader, wired it into a preinstall hook, added it to the published file list, bumped zod and @zod/mini to 4.6.6 and, in zod's case, changed the release workflow from a manually approved run to automatic publishing on any push to main that touched a package.json. The loader read a value from a public blockchain contract to find a server, downloaded JavaScript, ran it with the installing user's permissions and deleted its temporary files. Nothing malicious was published for zod or standard-schema: zod's release needs an environment-scoped trusted-publishing run the push credential could not start, and the commits were reverted the same day. @nubjs/nub@0.9.4 did reach npm's publishing pipeline, because nub's release workflow fired on a tag, and npm's publish-time malware scanning held it before it was installable. The maintainer, Colin McDonnell, says the credential was probably an SSH key taken from his machine during a test of an OS-level sandbox for nub's package manager in which an agent ran a compromised npm package; he reinstalled the OS, rotated every credential on it, moved every package to staged publishing that needs 2FA for each release, and in October rewrote zod's history to remove the commits, which cost v4.6.6 its provenance; v4.6.7 followed on 2026-10-12. The incident became widely known on 2026-10-12 through a Hacker News thread titled "Zod 4.6.6 compromised by malware?", a title the facts do not support: zod 4.6.6 on npm, published 2026-10-11, was built from a clean tree. If you cloned zod's main or a branch between 2026-09-21 and the rewrite and ran an install from it, treat that machine as having run the loader.
What happened
Timeline (UTC, from GitHub's write-up and the maintainer)
| When | What |
|---|---|
| 2026-09-21 11:56 | Loader and install hooks committed to colinhacks/zod main |
| 2026-09-21 12:37 | Loader and hook committed to standard-schema/standard-schema |
| 2026-09-21 12:54 and 13:00 | nubjs/nub's install script replaced with the loader, lifecycle changed to preinstall, the loader renamed, version bumped to 0.9.4 |
| 2026-09-21 13:09 | npm's static scanning flagged @nubjs/nub@0.9.4 as high risk and blocked it; GitHub contacted the maintainers |
| 2026-09-21 | The zod commits reverted in pull request #6620, the same day |
| October (before 10-11) | zod's main history rewritten to drop the six commits |
| 2026-10-11 16:18 | zod@4.6.6 and @zod/mini@4.6.6 published to npm from the post-revert tree; the GitHub release page carries a note on the incident and the autogenerated commit list still shows the reverted commits |
| 2026-10-12 | Hacker News thread; 2026-10-12 16:29, zod@4.6.7 and @zod/mini@4.6.7 published |
What the commit did, at the level a defender needs
The zod commit, titled as a bug fix, changed nine files. It added a script with a test-like name under both the zod and @zod/mini package paths, added a preinstall entry to both package.json files pointing at it, added the script to the files list so it would ship in the tarball, bumped the versions to 4.6.6, and replaced the release workflow's manual trigger and maintainer approval with automatic publishing on pushes to main that modify either package.json. The loader itself queried a public blockchain contract for a configuration value, used it to locate a server, downloaded a payload, wrote it to a temporary directory, ran it with dynamic import() and removed the directory. Because the final code was fetched at install time, the package would never have contained the behaviour an analyst would look for, and the behaviour could change without a new release. One thread commenter observed that in zod's commit the preinstall key sat at the root of package.json rather than under scripts, which would have stopped it running; that is a commenter's reading of a removed commit and is recorded as such. Blockchain-resolved loaders are the pattern of Reactime, indexed-btree and the PolinRider cluster; no source attributes this one.
Why zod's registry stayed clean and nub's did not
GitHub's write-up: the zod and standard-schema changes "never entered npm's publishing pipeline," while @nubjs/nub@0.9.4 did and was held. The maintainer's account of why: zod's release workflow uses environment-scoped trusted publishing with a protected GitHub environment and a manual trigger, so a push credential alone cannot start a release, and the attacker could not trigger it (the commit that tried to make releases automatic on push was reverted before any push to main with a package.json change went through the changed workflow). standard-schema had token-based publishing removed from npm the previous December. nub's release CI could be started by creating a tag, which the push credential could do, and that is the one release npm's scanner had to catch.
The maintainer disputes GitHub's framing. In the Hacker News thread he says the write-up "strongly implies" that npm's scanner caught malicious zod and standard-schema versions, which it did not, since nothing of theirs reached the pipeline, and asks for more accurate wording; he calls the nub block "a significant win." GitHub's post says the zod and standard-schema changes "were flagged by automated systems." Both statements are in the file because they are the two primaries and they describe the same morning differently: the registry operator counts a repository-level detection as scanning doing its job, the maintainer counts only publish-time interception as the registry's doing. A reader deciding what protected zod should credit the release workflow, not the scanner.
Cause, by the maintainer's account
"A git push credential of some kind," probably an SSH key, was taken from his computer. He links it to a test run of an OS-level sandbox for nub's package manager, during which an agent most likely executed a compromised npm package; he calls that speculation, says the forensic work with GitHub is still in progress, and says future work on that feature will run in an isolated VM. The npm and GitHub tokens were not what the attacker had. That is the lesson this corpus keeps meeting from the other side: an agent that installs packages on a developer's own machine is an install of whatever the package carries, and the credentials on that machine are the blast radius.
The history rewrite
The maintainer rewrote main in October to remove the six commits, after saying in the thread that he had found scrubbing them "shady" and was not sure it improved security; the reason he gives for doing it is git bisect and similar tooling, which would otherwise check out the loader on a developer's machine. The cost is that v4.6.6's npm provenance points at a pre-rewrite copy of the release commit, with identical contents, and v4.6.7 was cut to have clean provenance. Thread critics argued that rewriting hides evidence and that the commit was pushed to main without review in the first place. The PolinRider and Reactime files record attackers rewriting history as cover; here the maintainer rewrote it as cleanup, and the thing to know is that the removed commits still exist in any clone or fork made before the rewrite and on GitHub's release page's commit list.
Numbers
zod had 346,095,287 downloads in the week of 2026-10-05 to 10-11 and @zod/mini 29,338 (npm download API, 2026-10-13). @nubjs/nub's registry timeline shows 0.9.3 on 2026-09-19, a canary on 2026-09-20, 0.9.5 on 2026-09-25 and 0.9.6 on 2026-10-01; the 0.9.4 endpoint returns "version not found," which is consistent with the hold. @standard-schema/spec's last publish is 1.1.0 on 2025-12-15. The one-line test of the whole incident is that none of these timelines has an entry on 2026-09-21.
Am I affected?
Most readers are not. The published zod, @zod/mini and @standard-schema/spec versions are clean, and no @nubjs/nub@0.9.4 was ever installable.
# Installed from the registry: nothing to do beyond being on a current version
npm ls zod @zod/mini @standard-schema/spec @nubjs/nub 2>/dev/null
# Installed from a git clone, fork or git-dependency of colinhacks/zod between 2026-09-21 and October?
# A pre-rewrite clone still carries the commits; the artefact is a preinstall hook in the package manifests
git -C /path/to/zod log --since=2026-09-20 --until=2026-09-23 --oneline 2>/dev/null
grep -n '"preinstall"' /path/to/zod/packages/zod/package.json /path/to/zod/packages/mini/package.json 2>/dev/null
# In node_modules of a project that used a git dependency on zod in that window:
grep -rln '"preinstall"' node_modules/zod/package.json node_modules/@zod/mini/package.json 2>/dev/null
A preinstall entry in zod's manifests is not something any release has ever had. Finding one proves the checkout is a pre-rewrite revision. Whether the loader ran from it is a separate question with two unknowns this file cannot settle: GitHub's write-up describes the zod change as wired into install hooks, while a thread commenter read the removed commit as placing the preinstall key at the root of package.json rather than under scripts, where npm would ignore it; and an npm install leaves no reliable record of which lifecycle scripts ran (the debug logs record the invocation, not the hooks). So: a clone that was only read, bisected or built without an install did not run it; an install made with --ignore-scripts or an ignore-scripts=true config did not run it, but only evidence from that time counts (shell history, CI logs, endpoint telemetry), since the configuration in force now says nothing about a September install; and a scripts-enabled install from the revision is unknown, to be treated as compromise unless that evidence shows otherwise.
# Which revision is checked out, and did the loader file exist in it?
git -C /path/to/zod log --since=2026-09-20 --until=2026-09-23 --oneline 2>/dev/null
git -C /path/to/zod ls-files | grep -E 'packages/(zod|mini)/.*meta\.test\.mjs' 2>/dev/null
A pre-rewrite revision with no scripts-enabled install from it is cleanup (rebase or re-clone); a scripts-enabled install is the case the next section is for.
If you are affected
- If an install with lifecycle scripts enabled ran from a pre-rewrite clone on a machine and you cannot show the hook did not run: treat the machine as compromised. Rotate every credential on it, starting with git push credentials and SSH keys, then npm and GitHub tokens, then cloud and AI-provider keys: playbooks/if-you-ran-malicious-postinstall.md, playbooks/if-your-npm-token-leaked.md, playbooks/rotating-cloud-credentials.md.
- If you maintain a fork of zod: do not
git rebasea branch that descends from the pre-rewritemain, because the rebase replays the six commits and their reverts onto the new history and recreates the loader in your fork. Start a fresh branch from the rewrittenmain, cherry-pick only your own audited commits onto it, delete the old branch, and force-push nothing that still contains the window; the six commits should not survive anywhere you publish from. - If you own packages whose release fires on a tag push: the nub path is the one that reached the registry. Move to a manually triggered, environment-scoped publish: prevention/ci-cd-hardening.md.
Prevention
- The release workflow is the control that held. A push credential could change
mainbut could not run zod's trusted-publishing environment. Requireworkflow_dispatchplus an environment with reviewers and 2FA for every publish, and never let a tag push alone release: prevention/npm-hardening.md, prevention/ci-cd-hardening.md. - Protected branches without an admin bypass, and review before merge. The commit went to
maindirectly. The tensorlake fix list is the same template: no direct pushes, signed commits, a second reviewer on the publish environment. - Run agents that install packages in a VM you can throw away. The maintainer's own account is an agent executing a compromised package during a sandbox test on his workstation. The credentials on the workstation were the loss: prevention/agent-sandboxing.md, prevention/credential-hygiene.md.
- Install with lifecycle scripts off where you can, and treat install-time network access as the signal. npm 11 still runs install scripts by default; npm 12 changes that. A
preinstallhook that resolves a server from a blockchain and runs what it downloads is the whole payload, whatever the package is: prevention/package-vetting-checklist.md. - A thread title is not an advisory. "Zod 4.6.6 compromised by malware?" reached 180 points; the published 4.6.6 was clean. Check the registry's
timefield and the maintainer's release note before rotating anything on the strength of a headline.
Sources
- GitHub Community — Security advisory: malicious npm package changes affecting zod, nub, and standard-schema (discussion #208477) — fetched 2026-10-13: the timeline with timestamps, the three repositories, the loader's behaviour described at the level above, the statement that only
@nubjs/nub@0.9.4reached the publishing pipeline and was held, the "flagged by automated systems" line for zod and standard-schema, the maintainer actions, and the maintainer's follow-up comment disputing the framing. - colinhacks/zod — Release v4.6.6 — fetched 2026-10-13: dated 11 Oct, the security note ("Six malicious commits pushed to
mainon September 21 with a compromised git credential," reverted the same day in #6620, removed from history, none published to npm, provenance pointing at a pre-rewrite copy with identical contents), and the autogenerated commit list, which includes the staged-publishing and release-approval workflow changes (#6622, #6638). - colinhacks/zod — commit d821685 (pre-rewrite copy of the 2026-09-21 commit) — fetched 2026-10-13: the nine-file diff summarised above (test-named loader under both package paths,
preinstallentries,fileslist, release workflow switched to publish on push, version bumps); no comments. The page is the removed commit as linked from the Hacker News thread; its content is reported here only at the level of what was changed. - Hacker News — Zod 4.6.6 compromised by malware? — fetched 2026-10-13: 2026-10-12, 180 points; the maintainer's comments on the credential ("probably an SSH key"), the sandbox test and the agent running a compromised package, the nub release and npm's block, the dispute with the write-up's wording, the history rewrite and its reason, v4.6.6 provenance and v4.6.7, the OS reinstall, the rotation, the move to staged 2FA publishing; the commenter observation on the
preinstallkey placement; the npm 11 versus 12 install-script discussion. - colinhacks/zod security advisories — fetched 2026-10-13: empty. colinhacks/zod issue #6658 — fetched 2026-10-13: "This issue has been deleted."
- DEV Community — Zod Got Backdoored. Your Lockfile Didn't Save You, Luck Did. — fetched 2026-10-13: 2026-10-12 pickup whose body says no malicious zod version reached npm while its title says otherwise; cited as pickup, and as the source of the GMS-2026-451 pointer below, which is a different incident.
- GitLab Advisory Database — GMS-2026-451, @tanstack/zod-adapter — fetched 2026-10-13: 2026-05-19, the Mini Shai-Hulud wave; a package that depends on zod, compromised through a different maintainer's account five months earlier, in this corpus under the May wave. Not part of this incident.
- Registries, checked 2026-10-13:
npm view zod time(4.6.5 on 2026-09-13, 4.6.6 on 2026-10-11 16:18 UTC, 4.6.7 on 2026-10-12 16:29 UTC, nothing on 2026-09-21),npm view @zod/mini time(same dates),npm view @nubjs/nub time(0.9.3 2026-09-19, 0.9.5 2026-09-25, 0.9.6 2026-10-01),registry.npmjs.org/@nubjs/nub/0.9.4("version not found"),npm view @standard-schema/spec time(1.1.0 on 2025-12-15),api.npmjs.orglast-week downloads (zod 346,095,287; @zod/mini 29,338). - Related in this corpus: tensorlake (a release that did go out from the project's own workflow with valid provenance), Reactime (repository-only compromise, blockchain-resolved loader), PolinRider, indexed-btree.