Back to blog

AsyncAPI and Miasma v3: how one unmerged workflow fix let a pull request publish malware to 2.9 million weekly downloads

· · 22 min read
AsyncAPI and Miasma v3: how one unmerged workflow fix let a pull request publish malware to 2.9 million weekly downloads

On July 14, 2026, an attacker opened 37 pull requests against the AsyncAPI generator repository on GitHub. Almost all of them added a fake charity donation page. One did not. Hidden in that noise, a single pull request exploited a misconfigured GitHub Actions workflow to steal a highly privileged token, and a few hours later four AsyncAPI packages (five versions in total) were live on npm carrying a multi-stage payload that self-identifies as Miasma v3. There is no CVE for this, because the flaw that made it possible was not a bug in a product. It was a pull_request_target "pwn request" misconfiguration in the project's own pipeline, and the fix for it had been sitting in an open, unmerged pull request for 58 days. The compromised packages carry roughly 2.9 million weekly downloads combined, with @asyncapi/specs alone accounting for about 2.7 million of them.

This breakdown comes in two layers. The first part is for people who need to decide quickly whether they are exposed and what to do about it, in plain language, no jargon. The second part reconstructs the attack the way the responding researchers did: the exact workflow trigger that failed, how a markdown file in a pull request became code execution with organization secrets, how the malware hides by running on import instead of on install, and why that last detail matters more than it looks. The article closes by looking at how the Pragma Core platform addresses exactly the class of problem that made this possible, because we shipped the module for it the day before this attack landed.


Part I. Executive breakdown

What happened

AsyncAPI is a widely used open specification for event-driven and asynchronous APIs, the async counterpart to OpenAPI. Its tooling, in particular the code generator and the specification packages, is pulled into build pipelines and developer machines across the industry. When those packages ship correctly, they are boring infrastructure. On July 14 they did not ship correctly.

The mechanics are worth understanding even without the technical detail, because the failure was not exotic. A pipeline is an automated assistant that runs whenever something happens in a repository: a push, a new pull request, a release. To do its job it holds credentials. The AsyncAPI generator had a pipeline configured to run on incoming pull requests in a mode that gave it access to the project's secrets, and then, in the same run, it fetched and executed the code from the pull request itself. That combination is the entire problem. It means an outside contributor could hand the pipeline a piece of code and the pipeline would run it, with the project's keys in reach.

The attacker did exactly that. They submitted a pull request whose real cargo was a small piece of obfuscated JavaScript hidden in a markdown file, padded with about a thousand bytes of whitespace so a human skimming the diff would not see it. The pipeline ran it. The code read the runner's environment for secrets and shipped a highly privileged token belonging to the project's automation account to a paste site. With that token, the attacker pushed a commit straight to a release branch and let the project's own, entirely legitimate, release pipeline publish poisoned packages to npm. Because the real pipeline did the publishing, the malicious versions came out with valid provenance attestations. From the registry's point of view, nothing looked wrong.

The result: anyone whose build or developer machine pulled one of the affected versions during the exposure window, and then loaded the code, ran a credential-stealing remote access trojan.

Who is affected

Package Status
@asyncapi/generator 3.3.1 Compromised. Downgrade to 3.3.0 and purge caches.
@asyncapi/generator-helpers 1.1.1 Compromised. Downgrade to 1.1.0.
@asyncapi/generator-components 0.7.1 Compromised. Downgrade to the last known-good 0.7.0.
@asyncapi/specs 6.11.2 and 6.11.2-alpha.1 Compromised. Downgrade to 6.11.1.
@asyncapi/generator 3.3.0, generator-helpers 1.1.0, generator-components 0.7.0, specs 6.11.1 Not affected. These are the clean versions to pin to.
Fresh installs after remediation Safe. The latest dist-tags now resolve to clean versions.

The number that should worry you is not the download count on its own, it is the interaction between the download count and how the payload runs. Combined weekly downloads across the affected packages exceed 2.9 million, and @asyncapi/specs alone sees roughly 2.7 million. But because the malware executes when the module is loaded rather than when it is installed, the presence of an affected version in a lockfile does not prove you were hit, and its absence from a lockfile does not fully clear you either. What matters is whether a build, a CLI, a test run, a documentation job, or a developer workflow actually loaded the poisoned module during the window. If you cannot determine that, treat the host as exposed.

Why this matters beyond AsyncAPI

Strip away the specific project and this is a story about two trust boundaries that most organizations do not treat as boundaries at all.

The first is the pipeline that builds outside contributions. The pull_request_target trigger exists precisely so that a workflow can run with repository privileges on pull requests from forks, which is useful for things like labeling or preview deployments. The danger is entirely in what you do next: the moment such a workflow checks out and runs the pull request's own code, you have handed a stranger the keys. This is a known, named, documented failure mode, and it keeps landing because the safe pattern and the dangerous pattern differ by a couple of lines in a YAML file that nobody reviews the way they review application code.

The second is the trust you place in a package because it came from a reputable namespace with valid provenance. Provenance attestations tell you a package was built by the project's real pipeline. They do not tell you the project's real pipeline was under the project's control at the time. When the pipeline itself is the thing that gets compromised, every signature it produces is genuine and every signature it produces is worthless.

Recommended actions

  1. Pin to the clean versions now. Downgrade to @asyncapi/generator 3.3.0, @asyncapi/generator-helpers 1.1.0, @asyncapi/generator-components 0.7.0, and @asyncapi/specs 6.11.1. Remove the compromised versions from manifests, lockfiles, caches, internal mirrors, and build images.
  2. Hunt for execution, not just presence. Search for any build, CI runner, CLI invocation, test job, or developer machine that could have loaded an affected version between roughly 07:10 UTC on July 14 and your remediation time. A lockfile entry is a lead, not a verdict.
  3. Look for the persistence artifact. Check for sync.js under the per-user NodeJS data directory (~/.local/share/NodeJS/ on Linux, ~/Library/Application Support/NodeJS/ on macOS, %LOCALAPPDATA%\NodeJS\ on Windows) and for a miasma-monitor systemd user service, Windows Run key, or modified macOS shell startup files.
  4. Assume secret exposure and rotate from a clean machine. Any credential reachable from an affected host or runner should be treated as leaked: npm and other registry tokens, GitHub and cloud credentials, SSH and signing keys, browser sessions. Rotate them, do not just invalidate the obvious one.
  5. Audit your own pipelines for the same trigger. Grep your workflows for pull_request_target (and its equivalents on GitLab, Azure Pipelines, Bitbucket, and CircleCI) and confirm none of them check out and run untrusted pull request code while holding secrets. This is the actual root cause, and you almost certainly have instances of it.
  6. Watch outbound traffic for the network tells. Node processes reaching IPFS gateways, Nostr relays, Ethereum RPC endpoints, or DHT bootstrap nodes, and any HTTP request carrying an X-Miasma-Spawn-Chain header, are high-signal indicators.

Part II. Technical breakdown

The trust model of pull_request_target

To see why this attack worked you need the mental model of two GitHub Actions triggers that look similar and behave completely differently.

A workflow triggered by pull_request runs in the context of the fork. It does not get the base repository's secrets, precisely because the code it might run came from outside. That is the safe default: untrusted code, no secrets.

A workflow triggered by pull_request_target runs in the context of the base repository. It gets the base repository's secrets and a token with write scope, and it runs the workflow definition from the base branch, not the fork. That is intentional and, on its own, safe: the workflow that runs is yours, so an attacker cannot change what executes just by opening a pull request.

The invariant that keeps pull_request_target safe is simple: never combine base-repository privileges with untrusted, attacker-controlled code in the same job. The whole point of the elevated context is that the code is trusted. The moment a pull_request_target workflow explicitly checks out the pull request's head and runs it, that invariant is broken, and the workflow becomes a remote code execution primitive available to anyone on the internet with a GitHub account.

The vulnerability: a preview workflow that checked out the fork

The AsyncAPI generator repository had a workflow, a Netlify preview job, that triggered on pull_request_target and then checked out the incoming pull request's code rather than the base branch. In skeleton form, the dangerous shape looks like this:

# the unsafe pattern, reconstructed
on:
  pull_request_target:        # runs with base-repo secrets
    types: [opened, synchronize]

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # <-- attacker's code
      - run: npm install && npm run build                   # <-- executed with secrets in scope

Two lines are doing all the damage. The ref on the checkout pulls the fork's head commit, which is fully attacker-controlled. The build step then runs that code inside a job that, because of the pull_request_target trigger, has the repository's secrets in its environment. Nothing here is a parser bug or a memory corruption. It is a configuration that places untrusted input and privileged secrets in the same room.

What makes this case sting is that it was not a surprise. On April 29, 2026, a contributor opened a pull request demonstrating this exact issue with a proof-of-concept payload. On May 17, they followed up with a proposed fix that split the workflow into two separate jobs, isolating the step that needs secrets from the step that runs untrusted code. That fix was still open and unmerged when the attacker struck 58 days later. The vulnerability was not undiscovered. It was documented, understood, and waiting in the review queue.

From a markdown file to a stolen token

At 05:08 UTC the attacker opened pull request #2155. It contained a markdown file with obfuscated JavaScript hidden after roughly 1,000 bytes of whitespace, positioned so the payload scrolls off the right edge of most editors and diff views. When the preview workflow checked out the pull request and ran the project's build, the injected code ran with it.

Its job was narrow: enumerate the GitHub Actions runner's environment for secrets and exfiltrate them to a dead-drop URL on a paste site. The workflow completed at 05:16 UTC, and by then the attacker had what they came for: a personal access token belonging to asyncapi-bot, a service account with access across the AsyncAPI organization. Automated review did flag the obfuscated payload, and the pull request was never merged, but merging was never the point. The workflow runs on pull_request_target before any merge. The execution, and the theft, had already happened.

Turning a stolen token into signed, provenanced malware

This is the part defenders most need to internalize, because it defeats the reflex that provenance solves supply chain trust.

At 06:58 UTC, using the stolen token, the attacker pushed a malicious commit directly to the generator's next branch. That push triggered the project's real release workflow, which at 07:10 UTC published the first three compromised packages to npm. Because the publish came from the genuine GitHub Actions pipeline through npm's OIDC trusted-publishing mechanism, the malicious versions carried valid provenance attestations pointing at a real GitHub-hosted runner and a real source commit. The attacker never needed to steal an npm token. They compromised the thing that npm trusts to publish, and let it publish for them.

The attacker then pivoted to a second, separate repository, asyncapi/spec-json-schemas, which publishes @asyncapi/specs, pushing 11 commits between 07:51 and 08:28 UTC and getting two more malicious @asyncapi/specs versions out. This second repository was compromised independently, which has a concrete consequence for responders: rotating only the generator repository's CI secrets is not enough. The write access used against spec-json-schemas has to be rotated as well.

The payload: on-load execution, then a staged descent into IPFS

All five malicious versions carry the same idea, injected into different legitimate source files (a validator, a utility module, an error handler, an index). The injection sits on the first line, whitespace-padded to hide off-screen, and it is roughly 7.7 KB of obfuscated launcher.

The design decision that matters most is when it runs. This is not an install-hook attack. There is no preinstall or postinstall script. The code executes when Node.js loads the module, on require or import. That choice is almost certainly deliberate: npm's newer default of blocking package lifecycle scripts does nothing against code that runs at load time, and it means that merely having the package in a lockfile or cache is not proof of execution. Exposure depends on whether something actually imported the poisoned module.

Once triggered, the launcher spawns a detached, hidden node -e child process and downloads a second stage from an IPFS gateway over HTTP, writing it to a per-user directory named to look mundane:

# per-OS drop location for the persisted payload
~/.local/share/NodeJS/sync.js                     # Linux
~/Library/Application Support/NodeJS/sync.js       # macOS
%LOCALAPPDATA%\NodeJS\sync.js                       # Windows

The second stage is an encrypted bundle of roughly 8.25 MB retrieved from IPFS, which decrypts into the real runtime: a large, modular malware framework, reported at around 92,000 lines and about 3 MB of bundled Node.js, that self-identifies as Miasma v3. Splitting the attack this way, a tiny trigger in the npm package and a much larger payload off-registry, keeps the suspicious footprint inside the published package small and lets the operator swap the runtime payload without ever publishing a new npm version.

What Miasma v3 does once resident

The framework is a hybrid credential stealer and remote access trojan. Its collection targets are the usual high-value set for a developer-focused campaign: saved browser passwords and cookies across Chrome, Brave, Firefox, and Edge; SSH keys; npm, GitHub, and other registry tokens; AWS credentials; the macOS Keychain; and cryptocurrency wallets.

Persistence is per-user and cross-platform: a miasma-monitor systemd user service on Linux, a Run key on Windows, and modified shell startup files on macOS. Command and control runs primarily over HTTP to a dedicated server, with an unusually deep bench of decentralized fallbacks, IPFS, Nostr relays, Ethereum smart contracts, and a libp2p or DHT mesh, so that blocking a single domain or IP does not sever control. The channel encryption derives a unique key per victim using ECDH, HKDF-SHA256, and AES-256-GCM. One convenient side effect for defenders is that its traffic can carry an X-Miasma-Spawn-Chain HTTP header, a high-signal network indicator worth alerting on.

Affected and fixed versions

Compromised:

Clean versions to pin to:

The latest dist-tags have since been repointed to clean releases, so fresh installs resolve safely. Existing installs and lock files from the exposure window do not, and must be remediated by hand.

Timeline

Date and time (UTC) Event
2026-04-29 Contributor opens a pull request documenting the pull_request_target pwn-request issue, with a proof of concept.
2026-05-17 Contributor proposes a fix splitting the workflow into two jobs to isolate secrets from untrusted code. It is left unmerged.
2026-07-14 05:08 Attacker opens PR #2155 with obfuscated JavaScript hidden in a whitespace-padded markdown file, amid 37 decoy PRs.
2026-07-14 05:16 Preview workflow completes; the asyncapi-bot token has been exfiltrated to a paste site.
2026-07-14 06:58 Attacker pushes a malicious commit directly to the generator next branch using the stolen token.
2026-07-14 07:10 Release workflow publishes the first three compromised packages to npm with valid provenance.
2026-07-14 07:51 to 08:28 Attacker pivots to spec-json-schemas, pushing 11 commits; two malicious @asyncapi/specs versions are published.
2026-07-14, same day Multiple research teams detect the compromise within roughly 30 minutes of publication; C2 indicators are added to block lists and latest tags are restored to clean versions.

A note on the discovery methodology

Detection here was fast and came from automated malicious-package monitoring rather than a victim report: several research teams flagged the poisoned releases within about half an hour of publication, and at least one ran the packages inside an isolated, monitored CI job to capture the payload's real network behavior directly, which is how the C2 and fallback infrastructure were enumerated so quickly. The meta-lesson is not that the payload was clever, though it was. It is that the highest-leverage detection sat at the two automatable choke points: watching what packages a namespace publishes, and watching what a pipeline actually does when it runs. Both are machine-checkable. The 58-day-old unmerged fix is the human choke point that failed.

A word on attribution, because the naming invites a confident answer that the evidence does not support. The payload self-identifies as Miasma in several places (a miasma-train-p1 campaign string, miasma-monitor persistence, a miasma-test-org configuration value, an M-RED-TEAM v6.4 runtime tag). But researchers have also noted that some indicators, such as the paste-site slug used for exfiltration, match naming patterns from a different pull-request-based campaign that has not been linked to Miasma, and that beyond the references and the initial obfuscation method, this payload is a full command-and-control trojan rather than the self-propagating worm earlier Miasma waves used. Attackers know their code is read. Wearing a known campaign's name is a cheap way to mislead the people reading it. No definitive attribution has been made, and that restraint is the correct posture.


What we should learn from this compromise

  1. pull_request_target plus checking out the pull request is a remote code execution primitive, full stop. The elevated trigger is safe only as long as the code it runs is yours. Any workflow that combines it with a checkout of github.event.pull_request.head and then executes that code is exploitable by anyone, and this is grep-able across your entire estate today.

  2. Provenance proves the pipeline built it, not that the pipeline was yours. Valid OIDC attestations on these packages were completely genuine and completely useless, because the attacker compromised the pipeline itself. Signature and provenance verify origin, not integrity of control. Treat a compromised build system as capable of signing anything.

  3. On-load execution is the new default, and it breaks lockfile-based reasoning. Because the payload runs on require/import rather than on install, "it is not in our lockfile" is not an all-clear, and install-script blocking does not stop it. Exposure is about what got loaded, which is harder to answer and needs to be answerable in advance.

  4. A known bug in the review queue is an unmitigated bug. The fix existed 58 days early. The gap between "a contributor has demonstrated this" and "this is merged and deployed" is exactly the window attackers live in. Security-relevant pipeline fixes need a faster lane than ordinary pull requests.

  5. Decoy volume is now part of the tradecraft. Thirty-six harmless-looking pull requests existed to bury the one that mattered. Review processes that fatigue under volume, or that trust automated flags to be someone else's problem, are a targetable weakness, not just an annoyance.


How Pragma Core addresses this class of problem

Pragma Core is built for exactly this shape of problem: the kind where the danger is not a single bad line but how privilege, untrusted input, and trust boundaries combine across a system. A classical scanner reading the AsyncAPI generator's application code would have found nothing here, because the vulnerable artifact was a workflow file, and the failure was a relationship between a trigger, a checkout, and a set of secrets. Two things had to happen: the pipeline had to be auditable as its own attack surface, and the compromised dependency had to be traceable across every place it might load. Pragma Core does both, and the timing is not rhetorical, we shipped the pipeline module for this the day before this attack.

CI/CD Pipeline Security would have flagged the root cause directly

The enabling flaw was a pull_request_target workflow that checked out and ran fork-supplied code while holding repository secrets. That is precisely the dangerous-trigger pattern Pragma Core's new CI/CD Pipeline Security module is built to detect, across GitHub Actions, GitLab CI, Azure Pipelines, Bitbucket Pipelines, and CircleCI. It reads pipeline definitions the way an attacker does, as a description of what runs, with what privileges, triggered by whom, and it raises a finding for triggers that grant untrusted contributors a privileged execution context. The fix here was known for 58 days and lived in a pull request nobody merged. A continuous check that re-runs on every change turns "someone filed a PR about this in April" into a standing, severity-ranked finding on the board that does not go away until the pipeline is actually fixed.

Cross-repository search to size the blast radius in minutes

The attack hit two separate repositories, and the compromised packages sit somewhere inside thousands of downstream dependency trees. The first question every responder asks is "where do we have this, and where did it get introduced." Pragma Core's dependency tracking lets you search a package across every connected repository at once, so instead of grepping repos one at a time you get the full list of affected projects, branches, and the pull requests that introduced the version. When the malicious version is a transitive dependency three levels down, that difference is the difference between an afternoon and a week.

Continuous third-party tracking so the alert finds you

@asyncapi/specs is the kind of dependency that is often present invisibly, pulled in by tooling rather than chosen deliberately. Pragma Core tracks every third-party package and version across all connected repositories and surfaces compromised or vulnerable versions with fix paths the moment they are known. Rather than learning about this from a news feed and then scrambling to check exposure, the affected repositories light up on their own, with the clean version to pin to already identified.

SBOM per repository to answer "which version, where" instantly

Remediation here is version-precise: 3.3.1 is malware, 3.3.0 is fine. The teams that struggled on July 14 were the ones that could not quickly answer which of their builds carried which version. Pragma Core generates a complete, exportable inventory of libraries and exact versions per repository, so the question that usually stalls incident response, "do we have the bad one, and where," is a lookup rather than an investigation.

Call graphs to answer the question on-load malware forces

Because Miasma v3 executes on import, exposure hinges on whether the poisoned module was actually loaded, not merely installed. Pragma Core's interactive call graphs show how code connects, which builds, CLIs, and application paths actually reach a given package. Overlaying a compromised dependency on that graph turns "is this in our tree" into "does anything we run actually load it," which is the more precise and more actionable version of the question.

Autonomous investigation and human-guided research for the follow-through

Confirming a specific host executed the payload, tracing which secrets a given runner could see, and reasoning about downstream propagation are exactly the kind of multi-step, context-heavy questions Pragma Core's autonomous agents and expert-guided research module are designed to drive, using the repository and pipeline context already in the workspace. It is the same investigation a responder does by hand on incident day, run systematically and before incident day.


Closing thoughts

This is not, fundamentally, an AsyncAPI problem, and it is not really an npm problem either. It is a story about two boundaries that modern engineering has quietly stopped treating as boundaries. The pipeline that builds outside code is a privileged execution environment that we let strangers submit input to, and we review its configuration far less carefully than the code it builds. And the provenance we attach to packages tells us who built something, which we have started to confuse with whether we can trust it. When the builder is compromised, both assurances evaporate at once, and every signature stays perfectly valid.

The same two patterns live in almost every organization shipping software today: a pull_request_target or equivalent somewhere in the estate that runs untrusted code with secrets, and a dependency graph nobody can fully enumerate on demand. The difference between reading this as someone else's incident and using it as a trigger to audit your own pipelines and dependencies is a question of AppSec maturity and code visibility, not luck.

Organizations that want to move from "we scan our code and hope our pipelines are fine" to "we know what runs, with what privileges, triggered by whom, and where every dependency loads" can reach Pragma Core at pragma-core.com for a demo.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →