Engineering · eBPF

Replaying an npm supply-chain attack under eBPF: what the kernel sees that your scanner doesn't

In 2026, a run of npm supply-chain attacks (TanStack, Axios, and the Shai-Hulud family) shared one uncomfortable trait: the malicious package versions passed static scanning. Nothing in the registry metadata, the dependency graph, or a pre-install scan flagged them. The only signals that would have caught them in time were behavioral, and they only existed once the code was already running in production.

That's the gap we built Goodman to close: an open-source eBPF sensor that watches what a dependency actually does at runtime, attributes every security-relevant syscall to the exact npm package and version that caused it, and alerts the moment a package's behavior drifts from what it has always done before.

This post walks through a real replay of that attack pattern, the same scenario our end-to-end test suite runs on every change to the sensor, and shows exactly what shows up in the kernel, second by second.

The pattern, in one sentence

A dependency that has read only its own files and made zero network calls for its entire observed lifetime suddenly, in a patch version bump, starts reading credential files and opening a connection to a host it has never talked to. No CVE. No malicious string in the diff that an npm audit would catch. Just new behavior.

What we replayed

We used Goodman's own end-to-end harness, the same one that has to pass before we merge any change to the sensor or the attribution logic. It's not a simulation layered on top of the product; it's the product's own regression test, and anyone can run it:

git clone https://github.com/hi-heisenbug/goodman
cd goodman
sudo make e2e

The setup: a tiny Node HTTP service requires a package called good-pkg and calls into it on every request, so every syscall that package makes originates from inside its own stack frame, exactly like a real dependency in a real service.

[email protected], the baseline, reads one file inside its own package directory and returns. That's the entire behavioral fingerprint. No network. No secrets. No child processes.

[email protected], same public API, same version bump you'd approve without a second look, adds two behaviors:

// NEW BEHAVIOR #1: read a credentials file
secret = fs.readFileSync(FAKE_CRED, "utf8");

// NEW BEHAVIOR #2: outbound connect to an exfil sink
exfiltrate(secret);

This mirrors the attack shape across TanStack, Axios, and Shai-Hulud: a trusted, frequently-updated package gets a routine-looking patch release that quietly adds a credential read and a new outbound connection.

What the kernel saw

Goodman learned [email protected]'s baseline over several minutes of live traffic; the collector needs enough observations before it promotes a fingerprint to “known good.” Once promoted, we swapped the dependency to 1.0.1 and hit the same endpoint again. Here's the tail of goodmanctl alerts:

$ goodmanctl alerts tail

CRITICAL  dependency behavior drift
service   · demo-workload
package   · good-pkg  1.0.0 → 1.0.1

+ NEW READ  /tmp/goodman-fake-secrets/credentials
+ NEW CONNECT 127.0.0.1:9999

baseline learned over 6 rounds of live traffic · 0 prior anomalies
→ attributed to good-pkg in <3s. static scanning would have shown nothing:
  the package.json diff for 1.0.0 → 1.0.1 has no suspicious strings.

Two things worth pulling apart here:

The syscalls alone aren't the hard part.Any eBPF-based runtime security tool (Falco and similar) can tell you that a process opened a new file or made a new connection. That's necessary but not sufficient: in a service with 40+ dependencies, “something in this process did something new” doesn't tell an on-call engineer where to look.

The attribution is the hard part. Goodman resolves the user-space stack at the moment of the syscall through V8 perf maps (--perf-basic-prof --interpreted-frames-native-stack) down to the deepest node_modules/<pkg>/frame, and maps that to the package's package.jsonversion. That's how the alert above names good-pkgspecifically, not just “node process 41213.” When the stack doesn't resolve cleanly (a native addon, a bundler that flattens frames, a worker thread), Goodman reports <unknown> rather than guess. A wrong package name sent to an incident channel is worse than no name at all; it sends the on-call engineer somewhere wrong while the actual package keeps running.

Why this needed to happen at the kernel, not somewhere higher up

Everything upstream of runtime had a chance to catch this and didn't, by design:

That's the layer Goodman adds. It's explicitly complementary to scanning and CI hardening, not a replacement; you want all three. But 2026 showed what happens when only the first two exist.

Try it yourself

The whole thing above is reproducible without root, using a synthetic driver instead of live eBPF:

git clone https://github.com/hi-heisenbug/goodman
cd goodman
make demo

Open http://127.0.0.1:8844. The dashboard seeds baseline fingerprints and the drift alert above, so you can click through the same alert we just walked through. For the real eBPF path against your own workload:

sudo make e2e

Source, docs, and the Helm chart for Kubernetes are all on GitHub, Apache-2.0.

We're taking a small number of design partners to run this against real production traffic. If runtime dependency drift is something your team has thought about since the 2026 wave of attacks, we'd like to hear where this breaks. [email protected]