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
latestat 09:25 UTC that day) and have since been pulled. As of 2026-07-15 thelatestdist-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:
| Package | Bad version | Safe version | Injection point |
|---|---|---|---|
@asyncapi/generator | 3.3.1 | 3.3.0 | lib/templates/config/validator.js |
@asyncapi/generator-helpers | 1.1.1 | 1.1.0 | src/utils.js |
@asyncapi/generator-components | 0.7.1 | 0.7.0 | lib/utils/ErrorHandling.js |
@asyncapi/specs | 6.11.2 | 6.11.1 | index.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:
@asyncapi/[email protected],[email protected], and[email protected]: 2026-07-14 07:10:42 to 07:10:48 UTC, within six seconds of each other.@asyncapi/[email protected]: 08:06:20 UTC;@asyncapi/[email protected](taggedlatest): 08:30:09 UTC.
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:
| Advisory | Package | Flagged (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:
- 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.
- Do not rely on
--ignore-scriptsalone. 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. - 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.
- Pin by integrity hash (
package-lock.json). A lockfile freezesname@versionto 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](and6.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 currentlateston all four is safe again (generator-componentsmoved forward to1.0.0; the others reverted). - Trigger: obfuscated code in a source file (not an install hook) that
spawns a detached
node -esecond stage onrequire(). - Second stage, observed in our detonation (contained): a TLS
GETtohttps://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:
- Upwind / AsyncAPI generator advisory (issue #2184) and spec-json-schemas advisory (issue #656)
- StepSecurity: compromised AsyncAPI generator packages (full malware analysis)
- Socket: malicious packages in the AsyncAPI namespace (payload internals and IOCs)
- Our detection records: generator, generator-components, generator-helpers, specs
- Valid provenance, malicious package: the Red Hat npm compromise (the same provenance failure mode and Miasma family)