Your Install Step Is a Runtime, Not a Download: Reading the Zod Supply-Chain Commit

Somewhere in a commit labeled “Fix bugs and improve performance,” the package manifest for Zod grew one new line. It added a preinstall hook pointing at a file called meta.test.mjs. The version numbers bubbled up by one, which is what a normal release looks like. Nothing asked you a question. Nothing showed you a prompt. When you typed your next install or upgrade command, that file ran on your machine, as you, before a single line of Zod’s actual source was ever loaded.
That is the part worth sitting with. Not the blockchain novelty in the payload. The fact that the delivery mechanism was the manifest — the file every reviewer skims and no reviewer reads.
What the script actually did
Stripped of the obfuscation, the file does four things:
- It calls a smart contract through one of three public Ethereum RPC endpoints, listed as fallbacks. The contract hands back a list of domain names.
- It fetches a loader path from each domain in turn, expecting JSON that contains a setup script and a payload.
- It writes the payload into a temporary directory and points an environment variable at it.
- It imports the setup script from a data: URL — meaning it executes code that came off the network, not off your disk — then awaits a global function that setup script defines. Afterwards it deletes the temp directory.
Every step swallows its own errors, so a partial failure looks like nothing happened. Three RPC providers means blocking one is not enough. A contract that can be updated means taking down a domain costs the attacker nothing: they repoint it and never touch the npm package again.
Two design choices make this sharper than a typical typosquat. First, the file is named meta.test.mjs — you expect tests in a package, and tests are boring. Second, and more important, it doesn’t need your user to do anything wrong. It just needs the install to succeed.
Every control you already have said yes
Here is the uncomfortable arithmetic. Suppose the maintainer account had two-factor authentication on, which it may well have. Suppose the package you installed carried a provenance attestation — the cryptographic receipt that a tarball was built by a specific pipeline from a specific repository. Suppose your lockfile pinned the exact version and integrity hash. Suppose a human reviewed the project’s source code last year.
None of that blocks this. A signature or attestation tells you who produced the artifact, not what the artifact does. If the attacker holds the maintainer’s token, the attestation is valid and the malware is attested. Lockfile pinning freezes whatever you first resolved — which is excellent against a package being swapped later, and useless if the poisoned version is what you pinned. And code review looks at src/. Nobody reads the extra files a tarball ships or the scripts block that runs them.
So the honest framing is this: npm install is curl piped into a shell, running as you. The registry transport is encrypted, but the install step is execution, and it has whatever permissions you have.
What an install hook can reach
Install hooks — preinstall, install, postinstall, prepare — run during installation of the package. Everything below is inside their reach:
- Environment variables. Registry tokens, CI secrets, cloud credentials, connection strings. Anything your shell or pipeline exports.
- Credential files. SSH keys, cloud config directories, the npm token file, cached git credentials.
- The network. Outbound calls to anywhere reachable, which is how the payload arrived in the first place.
- Your working tree and your other dependencies. It can rewrite files, including other packages’ manifests.
- Persistence. Anything it plants can outlive the install. This is the property that turns an incident into a cleanup project rather than a revert.
That is a lot of authority for a step most teams treat as plumbing. Which is why the goal isn’t “audit every package.” It’s to decide, in advance, which of that authority you’re willing to hand over.
A four-question framework for any install script
1. Does it need the network?
Almost never. Build scripts for native code compile local sources with local tools. A script that reaches out during install is either downloading a binary you didn’t ask for, reporting telemetry you didn’t approve, or doing something worse. Network access is the flag that most deserves a default of no.
2. Does it need secrets?
No. There is no legitimate reason to read a registry token or a cloud key while compiling a C++ binding. If your installs run with a full environment, you’ve merged your install trust boundary with your deployment trust boundary. Keep them separate.
3. Does it need to persist?
If the answer is yes, then a compromised install is not something you clean — it’s something you rebuild and rotate. Deciding this per-runner, before an incident, is what makes the difference between a bad afternoon and a bad month.
4. Who reads the diff of the scripts field?
If nobody, that’s your real answer. The scripts block in a package manifest is a security-relevant file. Treat a new or changed hook as a change to your production behavior, because it is one.
The controls that actually hold up
- Default-deny install scripts and rebuild explicitly. Run installs with scripts disabled, then rebuild only the packages that genuinely need it. This is the single highest-leverage change. The cost is real: image libraries, browser downloads, database clients and compiler wrappers can all depend on their hooks.
- Make the allowlist a reviewed file, not a memory. Keep the list of permitted packages in your repository, and fail the build loudly when a package with scripts isn’t on it. Silently skipping is worse than blocking — you get a confusing runtime failure 20 minutes later and nobody connects it to the install.
- Install inside a container with no credentials and blocked egress. This is the layer that holds when an allowlisted package itself gets compromised, which is exactly the case an allowlist cannot cover.
- Alert on scripts appearing where there were none. A dependency that never had a hook and suddenly ships one is a high-signal event, and it’s cheap to detect in the lockfile and manifest diff you already generate.
- Have a rotation order. If a hook like this runs: registry tokens first, then CI secrets, then cloud credentials, then SSH and deploy keys — and assume build artifacts produced on that machine are dirty.
This is not an npm problem
Every ecosystem with a lifecycle concept has the same permission class. Python source distributions run build backends during install. Ruby gems compile native extensions. The names differ; the authority doesn’t. If your mental model is “npm is where the risk lives,” you’ve relocated the problem instead of removing it.
The trade-off you should pick on purpose
There are two honest strategies, and they fail differently.
Blocking — default-deny plus an allowlist — is a policy. It’s cheap to start and it catches the blunt cases, but the list creeps. Six packages becomes sixty, nobody re-reads the definitions, and the control quietly becomes paperwork.
Isolating — install in a throwaway container with no secrets and no egress, then copy only the built artifacts out — is a boundary. It costs you a bit of pipeline complexity and it survives the case your allowlist can’t help with.
I’d take isolation first and pin an allowlist on top, not the reverse. There’s a legitimate middle path too: stop treating “does this script deserve trust” as the question and start asking “what does this machine have that I’d hate to lose?” A build runner with no keys and no route out is a boring target, and boring is the goal.
The lesson isn’t that a package author found a novel way to ship a payload. It’s that the install step is the last place in your pipeline where code you have never read runs with credentials you can’t take back — and the thing that smuggled it in was a one-line change to a file everyone skims.
Key Takeaways
- An install hook is arbitrary code execution with your identity. The manifest is the attack surface, not just the source.
- Signatures, 2FA, lockfiles and provenance attest who published an artifact — never what it does. All of them can be satisfied by a malicious release.
- Default-deny install scripts, allowlist the few that truly build native code, and fail loudly when an unlisted one appears.
- Run installs with a scrubbed environment and blocked egress. Isolation covers the case an allowlist can’t: a trusted package that gets compromised.
- If a hook like this executes, cleaning up is not enough. Rotate registry, CI, cloud and SSH credentials, in that order.
- Python sdist builds and Ruby native extensions carry the same authority. Fix the category, not the ecosystem.