One dropper, 140+ packages: the Mastra npm compromise and the egress block that ends it
On 2026-06-17, 140+ packages in the @mastra npm organization were republished
with one new dependency: [email protected] (our detection record: LWA-2026-5633). Socket documented the compromise; their writeup has the full
package list, the attacker account, the timeline, and the IOCs, and our own detection
record has the decoded dropper behaviour. This post is not a
malware analysis. For what the package is and does, read those. This post is about
one thing: how a default-deny egress policy on the CI runner stops this attack, without
needing to know any of it.
What is worth drawing out is the shape of this attack, because it slips past
review that stops at the dependencies you declared. This was not a package with a
payload in it. It was a dependency injection: the @mastra packages stayed legitimate-looking, and the
malicious code rode in through a single sibling dependency that each of them now
pulled. If you depended on @mastra/core, you never typed easy-day-js and you never
saw it in your own package.json. It arrived as a transitive dependency of a package
you had every reason to trust, and its postinstall hook ran on your runner.
The one behaviour the block depends on
We need exactly one fact about the dropper, and it is the one both writeups above
already establish. As documented by Socket and in our detection
record, [email protected] ships a postinstall hook
(node setup.cjs) that, on install, makes a single outbound call to a hardcoded address
to fetch a second stage and then runs that stage detached. Everything else the dropper
does is downstream of that call: no first call, no second stage, no attack.
To confirm precisely what the egress layer has to stop, we detonated the real package in
our sandbox. The agent’s eBPF egress hook recorded the postinstall process reaching for
its C2, and the sandbox’s fake-network layer captured the exact request it tried to make:
{ "comm": "node", "binary_path": "/usr/local/bin/node",
"ip": "23.254.164.92", "port": 8000,
"action": "packet_observed" } { "kind": "http_capture", "method": "GET", "tls": true,
"host": "23.254.164.92:8000", "path": "/update/49890878",
"headers": { "User-Agent": "node", "Accept": "*/*" } } That is the trigger in two lines: node, run from the postinstall hook, fetching /update/49890878 from 23.254.164.92:8000 over TLS (the dropper disables certificate
verification to reach a bare IP). Note the C2 is an IP literal, no domain, no DNS.
That detail matters below.
(This capture is from our detonation pipeline, which runs the agent in observe mode on purpose: detonation exists to record the full behaviour, so it lets the call proceed to a fake network rather than dropping it. The point here is what the agent sees. What it does in enforcing mode is the next section.)
The block
The agent that produced that capture is the same MPL-2.0 Community Edition agent you run on
a GitLab runner. In its default mode, block, it attaches a cgroup_skb/egress BPF program
to each CI job’s cgroup with a per-job allowlist (your registry, your git host, your
caches). Anything else is dropped at the kernel before a byte leaves the runner.
23.254.164.92 is not on any sane CI allowlist. So in block mode the postinstall call
that fetches the second stage is dropped at the kernel: the GET to /update/49890878 never
completes, the second stage never downloads, and the attack ends at the point it tries to
start.
We ran exactly that. Same sample, same sandbox, but this time the baked agent is in its
default block mode with a narrow allowlist (the npm registry and DNS, nothing else). The postinstall node process tries its C2 reach seven times over seven seconds and is
dropped at the kernel every time:
{ "action": "block", "container_id": "detonate-d4c…", "bytes_sent": 0,
"violations": [
{ "comm": "node", "binary_path": "/usr/local/bin/node",
"ip": "23.254.164.92", "port": 8000, "action": "packet_dropped",
"timestamp": "2026-06-18T20:17:26.519Z" },
{ "comm": "node", "binary_path": "/usr/local/bin/node",
"ip": "23.254.164.92", "port": 8000, "action": "packet_dropped",
"timestamp": "2026-06-18T20:17:27.548Z" }
// … 5 more, every retry dropped …
]
} { "msg": "enforcement detached", "violations": 7,
"dns_entries": 0, "bytes_sent": 0 } bytes_sent: 0 is the whole post in one field. The postinstall hook executed, it tried
to reach 23.254.164.92:8000, and not one byte left the runner. No second stage came back,
because the request that fetches it never completed. The agent retried-and-dropped on every
attempt and then reported the block; the job’s own code ran, but its network was a wall.
The mechanism is not new and we have shown it before, against a different npm wave, with the full kernel trace: Mini Shai-Hulud, blocked. What this incident adds is the delivery path: the egress hook does not care that the malicious code arrived as a transitive dependency of a trusted package rather than as a package you chose. It does not inspect the dependency graph at all. It enforces what the job is allowed to talk to.
Why this is the layer that holds
Three things about this attack make the egress block the defence that does not depend on luck:
It is delivery-agnostic. Dependency injection through a trusted org is designed to slip
past “did I install something suspicious?” You did not. Egress enforcement asks a different
question: “is this job allowed to talk to 23.254.164.92?” The answer is no, whether the
code came from your direct dependency, a transitive one, or a postinstall script.
It is harvest-agnostic. A dropper like this fetches a second stage to steal secrets, whether from environment variables, credential files, or process memory. It does not matter which: if the stolen data cannot leave the runner, the technique used to gather it is moot. The block is on the way out.
It does not depend on anyone having detected the package yet. This is the part worth being honest about. In the window between a poisoned version going live and the first public advisory naming it, every feed, ours included, is blind to it by definition. A default-deny egress policy is blind to it too, and blocks it anyway, because it never needed to know the package was malicious. Detection is a race you can lose, and we do not always win it. Enforcement is not running the race.
If you run npm in CI
In order of effort:
- Default-deny egress on every job that runs
npm install. The agent is MPL-2.0 and self-hostable. This single layer drops theeasy-day-jssecond-stage fetch the moment itspostinstallruns, with no dependency on any feed having named the package. - Separate the
installstep from any credential-bearing step. Runnpm ciin a job with no cloud, registry, or git tokens in scope; run the release in a job that has them. In GitLab CI the mechanism is environment-scoped variables. Anything the install does runs against an empty credential set. - Pin by integrity hash (
package-lock.json). A lockfile refuses to install different content for the samename@version. It does not help once a range resolves to a poisoned version, but it freezes the past.
Leitwacht brings this default-deny egress enforcement to GitLab Runner and self-hosted Kubernetes, open-source and EU-hosted.
Sources:
- Socket: 140+ Mastra npm packages compromised (full malware analysis: package list, attacker account, timeline, IOCs)
- Our detection record: LWA-2026-5633 (decoded dropper behaviour and IOCs)
- Our earlier kernel capture against an npm wave