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:

  1. Default-deny egress on every job that runs npm install. The agent is MPL-2.0 and self-hostable. This single layer drops the easy-day-js second-stage fetch the moment its postinstall runs, with no dependency on any feed having named the package.
  2. Separate the install step from any credential-bearing step. Run npm ci in 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.
  3. Pin by integrity hash (package-lock.json). A lockfile refuses to install different content for the same name@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: