Why —ignore-scripts didn’t stop the AsyncAPI npm compromise

On 2026-07-14 an attacker pushed to two AsyncAPI repositories and the projects’ own automated release workflows published four malicious npm packages, each carrying valid, signed SLSA provenance. The payload fires when the module is require()d, not on install, so npm install --ignore-scripts does not stop it. Our firehose flagged all four provenance-blind; we detonated the payload, and Leitwacht CE’s default-deny egress policy blocked its second-stage fetch regardless of what triggered it.

Status: removed from npm. The four malicious versions were live on 2026-07-14 (still latest at 09:25 UTC that day) and have since been pulled. As of 2026-07-15 the latest dist-tag on each is a safe release (@asyncapi/[email protected], [email protected], [email protected], [email protected]), checked directly against the registry. If a build pulled one of the bad versions on 2026-07-14, pin to a safe version and rotate any credential that was in scope during that build. Timestamps and IOCs are anchored to when we captured them; re-check the sources before acting on them.

On 2026-07-14, four packages across two AsyncAPI repositories were republished with a malicious payload. Lidor Machluf of Upwind Security reported the compromise, and StepSecurity published a full malware writeup. This post is not a malware analysis. For the payload internals, the Miasma RAT breakdown, and the complete IOC set, read those. This post is about two things: the trigger, which is what makes this wave worth a separate note, and the one control that stops it without needing to know any of the above.

The shape: it runs when you import it, not when you install it

The reflex advice after every npm supply-chain wave is “run with --ignore-scripts.” That advice is aimed at the common case: a malicious postinstall hook that fires during npm install. It is good advice, and here it does nothing.

The AsyncAPI payload was not planted in an install hook. It was injected into a source file that the package runs during normal use, one file per package:

PackageBad versionSafe versionInjection point
@asyncapi/generator3.3.13.3.0lib/templates/config/validator.js
@asyncapi/generator-helpers1.1.11.1.0src/utils.js
@asyncapi/generator-components0.7.10.7.0lib/utils/ErrorHandling.js
@asyncapi/specs6.11.26.11.1index.js

The code fires when the poisoned module is require()d, during a build, a test run, or a code-generation step, per StepSecurity’s analysis. --ignore-scripts governs lifecycle scripts (preinstall, postinstall, and friends). It has no bearing on what a module does when your own code imports it. The malicious versions also keep the legitimate functionality intact, so the package works and nothing looks wrong.

The trigger matters because it widens the blast radius past CI install steps to anywhere the package is imported, and because it neutralises the single most repeated piece of hardening advice. What it does not change is the network behaviour, which is where the block lands.

Valid provenance, again

No npm credentials were stolen. The attacker pushed a commit with a throwaway git identity ("Your Name" <[email protected]>) to the non-default next branch in the generator monorepo, and separately to spec-json-schemas. Each repo’s automated release workflow then published to npm over GitHub Actions OIDC, the trusted-publisher path, which means the malicious versions carry valid, signed SLSA provenance. They pass an npm audit signatures check. Provenance attests where a build came from; it says nothing about what the build does. We wrote this up at length when the same failure mode hit the @redhat-cloud-services scope; this campaign is tracked as the same “Miasma” family.

Publish times, read first-hand from the npm registry:

What our firehose saw

Our publish firehose flagged all four versions on behavioural signal alone, before any feed we track had named them, and without ever trusting the provenance:

AdvisoryPackageFlagged (UTC)
LWA-2026-6766@asyncapi/[email protected]09:00:11
LWA-2026-6767@asyncapi/[email protected]09:00:13
LWA-2026-6768@asyncapi/[email protected]09:00:16
LWA-2026-6769@asyncapi/[email protected]09:18:21

The signal was the same in each: an obfuscated, detached child_process.spawn that runs an encoded second stage via node -e with detached: true, stdio: 'ignore', and windowsHide: true, then unrefs it, identical injected code across the three generator packages, published within seconds and sharing a gitHead. None of that reasoning involves the signature on the package. A provenance check would have waved all four through.

We then detonated @asyncapi/[email protected] in an isolated sandbox to confirm the trigger dynamically (2026-07-14, 17:56 UTC). Importing the module ran the injected main() and fired spawn("node", ["-e", <second stage>], { detached: true, stdio: "ignore", windowsHide: true }) at require-time. The detached second stage then requested https://ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9 over TLS and wrote ~/.local/share/NodeJS/sync.js; our sandbox sinkholed the fetch, so no third stage arrived. The CID matches the one Socket reported. We did not exercise the C2 reach in our run.

The one control that holds

Whatever the trigger, the payload cannot do anything until it fetches its second stage over the network: a download from an IPFS gateway, then a reach to a hardcoded C2 (85.137.53.71, per the writeups). That fetch is the choke point.

A default-deny egress policy on the CI runner stops it. The Leitwacht community-edition agent (agent-ce, MPL-2.0) gives each job a DNS resolver and a cgroup_skb/egress BPF program on the job’s cgroup, both governed by one narrow allowlist (your registry, your git host, your caches). A name that is not on the list does not resolve; a packet to an address that is not on the list is dropped at the kernel before it leaves the runner. An IPFS gateway and a bare C2 IP are on nobody’s sane CI allowlist, so the second-stage fetch never completes and the attack ends where it tries to start.

We ran @asyncapi/[email protected] once more, this time with agent-ce enforcing that allowlist (default-deny, npm registry only), and captured the block first-hand (2026-07-15). Requiring the module fired the same detached node -e second stage, but when it tried to resolve the IPFS gateway the agent denied the lookup. The downloader failed with getaddrinfo ENOTFOUND ipfs.io and wrote no sync.js; the registry still resolved, so npm itself was unaffected. The fetch never reached a connection, so no third stage and no C2 attempt followed. That is the whole attack stopped at the point it reaches for the network, with the agent knowing nothing about the package.

The important property here is that the block is trigger-agnostic. It does not care whether the outbound call came from a postinstall hook or, as in this case, from a require() deep in a build step. It does not inspect the dependency graph or the lifecycle at all. It enforces what the job is allowed to talk to. We have shown the same policy dropping egress at the packet layer, with full kernel traces, in earlier waves: the Mastra postinstall dropper and an earlier Shai-Hulud variant. Here the lookup was denied a layer earlier, at DNS, but the allowlist and the outcome are the same.

And it does not depend on anyone having detected the package yet. In the window between a poisoned version going live at 07:10 UTC and the first advisory naming it, every feed is blind by definition, ours included. A default-deny egress policy is blind to it too and blocks it anyway, because it never needed to know the package was malicious.

If you run npm in CI

In order of effort:

  1. Default-deny egress on every job that runs or imports third-party npm code. This drops the second-stage fetch the moment the payload runs, at install time or require-time, with no dependency on any feed having named the package. The Leitwacht agent is MPL-2.0 and self-hostable.
  2. Do not rely on --ignore-scripts alone. It is worth keeping, but this wave runs at import, not install; a control that only governs lifecycle scripts leaves the require-time path wide open.
  3. Separate the install and build steps from any credential-bearing step. Run untrusted code in a job with no cloud, registry, or git tokens in scope; run the release in a job that has them. Anything the untrusted code does then runs against an empty credential set.
  4. Pin by integrity hash (package-lock.json). A lockfile freezes name@version to known content. It does not help once a range resolves to a poisoned version, but it stops silent substitution of the past.

Leitwacht brings this default-deny egress enforcement to GitLab Runner and self-hosted Kubernetes, open-source and EU-hosted.

Indicators

First-hand, from the npm registry (bad versions confirmed latest on 2026-07-14 09:25 UTC; removal confirmed 2026-07-15):

  • Malicious versions (published 2026-07-14, since removed from npm): @asyncapi/[email protected], @asyncapi/[email protected], @asyncapi/[email protected], @asyncapi/[email protected] (and 6.11.2-alpha.1). If one was installed, pin to a known-good version (3.3.0, 1.1.0, 0.7.0, 6.11.1). The current latest on all four is safe again (generator-components moved forward to 1.0.0; the others reverted).
  • Trigger: obfuscated code in a source file (not an install hook) that spawns a detached node -e second stage on require().
  • Second stage, observed in our detonation (contained): a TLS GET to https://ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9, dropped to ~/.local/share/NodeJS/sync.js.

Reported by Upwind Security and documented by StepSecurity and Socket. We detonated [email protected] and observed the require-time spawn and the IPFS fetch first-hand (contained in our sandbox); the C2 reach (85.137.53.71) and the RAT internals we did not exercise, and come from those writeups.

Sources: