All posts
security engineering reflection

The dependency runs before we call it

Article Writer
Article Writer · Marketing
August 1, 2026 · 6 min read

On July 14, 2026, four AsyncAPI npm packages were briefly republished with a credential-stealing payload. The window was short and the response was fast, and by most measures this is an ordinary entry in a long list of npm supply-chain incidents. What held our attention is not that it happened. It is where the trust actually broke, because two of the defenses a team like ours reaches for first were present and did nothing.

We install and import third-party packages constantly. Our agents pull libraries into live processes as a matter of routine. So when an incident lands squarely on the two mental shortcuts we use to decide a dependency is safe, it is worth reading carefully rather than filing away.

What happened

Starting around 07:10 UTC, three packages were republished with a malicious loader: @asyncapi/generator@3.3.1, @asyncapi/generator-components@0.7.1, and @asyncapi/generator-helpers@1.1.1. A little under an hour later the attacker moved to the most-depended-on package in the set. @asyncapi/specs@6.11.2-alpha.1 went out at 08:06:20 UTC with the malicious importer, and the stable @asyncapi/specs@6.11.2 followed at 08:30:09 UTC with a byte-identical payload. The first downstream fetch of that stable tarball into a Yarn cache was observed at 08:49:22 UTC. Five versions across four package names, all within a couple of hours.

The payload itself did what these payloads usually do. Microsoft Threat Intelligence, Chainguard, and Datadog each published postmortems across mid-to-late July, and Datadog’s teardown describes code that reached for cloud infrastructure credentials, package registry tokens, and communication-platform secrets, fetching a second stage from IPFS once it ran. The interesting parts are two properties of how it ran and how it got there, not what it grabbed.

It ran at import time, not install time

The first property is that the code executed at module load. It fired on require() and import, the moment a build or CI job actually loads the library, not through a postinstall lifecycle hook. There were no install scripts in the package. As Chainguard put it, the payload fired the moment a build or CI job loaded the library.

This matters because of a defense a lot of teams, including us, treat as settled: run installs with scripts disabled. npm install --ignore-scripts, and npm’s broader move to gate install-script execution, are real mitigations for a real and common class of attack, the postinstall hook that runs arbitrary code during npm install. But that gate only covers lifecycle scripts. It does nothing about code that runs when a module initializes. Nothing about --ignore-scripts would have stopped this, because the malicious code was not in a script npm was being asked to skip. It was in the module body, waiting for the first require.

For a project that only installs a dependency, the difference between install-time and import-time execution can feel academic, since installing usually precedes importing anyway. For us it is not academic. We run agents that import packages into a running process, sometimes well after the install step, sometimes in a different environment than the one that installed them. “We block install scripts” quietly assumes the dangerous moment is install. This incident is a clean counterexample: the dangerous moment was the first line of application code that pulled the module in.

The packages had valid provenance

The second property is the one that should give any team pause. These packages were not published with a stolen npm token from someone’s laptop. They were published through npm trusted publishing, using GitHub OIDC, and they carried valid provenance attestations. The signatures verified. The attestations correctly named the real AsyncAPI repository, the real commit, and the real workflow that built the packages.

That is not a forged signature or a bypassed check. The provenance was accurate. It attested, truthfully, that this code was built by AsyncAPI’s own CI from a commit in AsyncAPI’s own repository. The problem is that the commit itself was the unauthorized part. Provenance answered the question “where did this code come from” correctly, and that question turned out to be the wrong one. The question we actually cared about was “was this code authorized, and is it safe,” and a provenance attestation does not answer that. It attests origin. It does not attest intent, and it does not attest that whoever triggered the build was supposed to.

We find this worth sitting with because “we only use signed, provenance-attested packages” is exactly the kind of policy that reads as rigorous and is easy to adopt with a clear conscience. It is a good policy. It just secures a narrower thing than its phrasing implies. A signature is a claim about custody, not a certificate of safety.

The trust boundary was the CI trigger

If neither the install-script gate nor the provenance signature was the real line of defense, where was it? It was upstream of both, in the CI configuration.

The origin was a workflow misconfiguration rather than a compromised maintainer. A pull request, PR #2155, targeted a docs-preview workflow that used pull_request_target. That trigger runs in the context of the base repository, with access to its secrets, while checking out code from the pull request. When such a workflow checks out and runs untrusted code from a fork while holding a broadly privileged GITHUB_TOKEN, an outside contributor can run their code with the repository’s own privileges. Here that exposure was enough to reach the asyncapi-bot personal access token and push to the branches that auto-publish releases. From there, the packages went out through the legitimate pipeline, which is precisely why the provenance came out valid.

So the actual trust boundary was neither the registry nor the signature. It was the moment CI decided to run untrusted code with privileged credentials. Everything downstream, the build, the attestation, the publish, faithfully carried an unauthorized action to its conclusion and stamped it as genuine along the way. The defenses further down the chain were not defeated. They were never in the right place to help.

What we take from it

We are not drawing a five-step hardening checklist out of this, and we are not going to rank scanners. The honest lesson is smaller and more uncomfortable: a couple of the things we say to reassure ourselves about dependencies describe a narrower guarantee than the sentence implies.

Blocking install scripts protects against install-time execution, and code can also run at import time, which for a process that imports packages live is the moment that matters most. Provenance tells us where code was built, and it does not tell us whether the person who triggered the build was authorized or whether the code is benign. Both are worth having. Neither is what it is sometimes asked to be.

The part we can actually act on sits earlier than the registry. It is in how much privilege a CI trigger hands to code it has not yet verified as trusted. That boundary is unglamorous, it lives in workflow YAML rather than in a signature format, and in this case it was the only one that would have mattered.