Back to research

The npm malware was removed. The desktop backdoor stayed live.

An attacker with Ethersphere's release-bot credentials shipped the same loader to npm and to Swarm Desktop. The npm release came down in 75 minutes; the signed desktop release stayed live for nearly eight hours after that.

In this article

On 9 November 2026 an attacker holding Ethersphere’s release-bot credentials published @ethersphere/bee-js@13.3.0 to npm with valid SLSA provenance, then thirty minutes later shipped Swarm Desktop v0.56.2 through GitHub Releases, with the macOS build signed and notarized under the project’s own Developer ID. Both carried the same twenty-line loader. Both loaders used readable, unobfuscated code.

The npm package was gone in 75 minutes; a watcher I run had flagged it 14 minutes after publish. The desktop release stayed marked latest, where the auto-updater could offer it, for nearly eight hours after the npm takedown, even though the evidence was sitting in the same GitHub organization.

Supply chain incidents tend to move at two speeds. On the registry side, something flags a tarball, someone opens an issue and the package gets yanked. The write-ups, with C2 teardowns and indicator lists, land a day or two later. The dangerous stretch is in between: the first package is dead, the public thread reads as contained, and the same actor is still shipping through a repository that isn’t an npm package at all.

I ended up on both sides of that gap, in two different ways. At 00:23 UTC my watcher sent an automated alert on 13.3.0; it’s reproduced below. At 03:26 UTC I posted a manual finding, the swarm-desktop release, into the bee-js thread, and Ethersphere’s advisory later credited it. Both came from structural checks rather than an entropy score or a trust signal.

The timeline, in UTC

All times are 9 November 2026 unless noted. Sources are the project advisory, the two issue threads, the release pages and my own logs.

Highlighted rows are my own alert and public comment.
Time (UTC)EventSource
6 NovDomain frameworkmetrics[.]xyz registeredbee-js #1295
8 Nov 23:59:54Commit de2f74e "chore(upcoming): release 13.3.0" pushed by bee-worker, unsigned, no PRGHSA-9f32-98m9-4jx3
00:09:50bee-js@13.3.0 live on npm, provenance validadvisory
00:23:45Automated alert from my watcher: first install hook in 177 settled versionsmy inbox, alert reproduced below
00:36:31swarm-desktop v0.56.2 released by bee-worker, commit 2de7270, unsigned, no PR#1295
00:43 to 00:45Windows x64 and Apple Silicon clients could be offered 0.56.2 by the updaterSocket
00:53Public issue #1294 openedadvisory
01:25Issue #1295 opened; 13.3.0 removed from npm, latest back to 13.2.0, GHSA-hh7j-5m3j-23pf issuedadvisory
03:26My comment on #1295 names the swarm-desktop release, the loader and the auto-updater exposurecomment
07:00:25swarm-desktop issue #581 opened by another reporterSafeguard timeline
08:27Release download counters at 65, including 22 of the darwin-arm64 zip; upper bounds including researchersSocket
09:04v0.56.2 tag deletedSocket; Safeguard timeline
about 09:14v0.56.2 release no longer publicSocket
12:52v0.56.3 published, "replace compromised 0.56.2 release"release
13 NovAdvisory GHSA-9f32-98m9-4jx3 published, CVSS 9.6, finder creditadvisory

What the loader looked like

A lot of npm malware detection leans on obfuscation: score the Shannon entropy, look for eval(atob(...)), flag hex arrays and packed strings. I didn’t run an entropy check on this package, so I can’t say how any particular one would have scored it. The loader wasn’t obfuscated, though, and the detection described below didn’t depend on entropy at all.

The actor committed the same logic to bee-js@13.3.0 (scripts/generate-version.cjs) and to swarm-desktop@v0.56.2 (src/index.ts). It imports the built-in node:https module and sends a plain GET to api.frameworkmetrics[.]xyz, at /v1/analytics for bee-js and /v1/electron for the desktop app, with a custom header X-Ethersphere: 1. The response body goes to new Function('require', 'console', 'process', body), which is then called. That’s the whole first stage: about twenty lines of readable CommonJS and TypeScript, with nothing encoded. Stage two never touched disk in the package. The attacker’s server sent it on demand, and the advisory notes other payloads may have been served to different callers.

The trust signals checked out, too. bee-js@13.3.0 had genuine npm provenance from the repo’s own GitHub Actions publish workflow, the same trusted publisher as the previous 69 releases. The macOS build of swarm-desktop@v0.56.2 was signed and notarized under the project’s Developer ID, and the Windows build carried an Authenticode signature from the same Apple Developer ID certificate used for the macOS build, because CI built and signed it exactly as it builds every release. The attacker didn’t forge any of this. They held the release-bot identity, so the pipeline did the signing for them.

My checks looked at something else: whether this version changed the package’s shape in a way it had never changed before.

What fired at 00:23: a lifecycle key moved

The attacker didn’t add a new file to bee-js. They edited an existing build helper, scripts/generate-version.cjs, and changed one key in package.json:

- "prepare": "node scripts/generate-version.cjs"
+ "postinstall": "node scripts/generate-version.cjs"

For anyone installing the published package from the registry, that one-line move is the whole attack surface. prepare runs when the maintainer builds and publishes, and in a few other cases such as installing from a git URL or a local path; it doesn’t run when a consumer installs the published tarball from npm. postinstall does run on a consumer’s npm install of that tarball, whether or not the library is ever imported, subject to the installer’s script policy. npm 11 and earlier run dependency install scripts unless --ignore-scripts is set. npm 12, released in July 2026, blocks them unless the package is allow-listed, and pnpm 10 and Bun block them by default as well. So whether this hook executed on a given machine came down to that machine’s npm version and configuration.

Here’s the automated alert as it reached my inbox, with internal paths removed. The mail header says Mon, 09 Nov 2026 3:23:45 AM +0300, which is 00:23:45 UTC.

@ethersphere/bee-js@13.3.0   LOOK FIRST
Lead type: install-hook warning (published 2026-11-09 00:09 UTC)
Why it was flagged: 1 warning sign
  - An install hook appears for the first time (install-introduced):
    postinstall (node scri....cjs) present; no earlier version
    (settled 24 h: 177 versions) had an install hook
  - Also noted (not a warning on its own): static:S1-loader-in-hook-target:
    hook target scri....cjs (2510 B) has a network sink (http-request,
    url-literal) and an execution sink (Function)
Compared with: 13.2.0
  - Publisher: GitHub Actions, trusted publishing, provenance present.
    Same account and provenance for the last 69 releases, since 10.1.2
  - Install hooks: NEW: the first install hook this package has ever had
    (postinstall "node scripts/generate-version.cjs")
  - Dependencies: none added
  - Files: 4 added, 0 removed, 13 changed, 318 unchanged; size up 225.5 KB
Install checks: static check S1 (loader-in-hook-target) FIRED
Not checked: dependency code; known advisories (OSV)
evaluated 2026-11-09 00:23 UTC

This alert covers the npm package only, fourteen minutes after publish. It says nothing about the desktop release; I found that by hand three hours later.

The check itself is simple. It diffs the registry metadata of version N-1 against version N, with a baseline long enough to mean something. A package that has gone 177 versions without an install-time hook and suddenly grows one is anomalous, whatever the script contains. The content check on the hook target helps, but the structural change alone is enough to page someone.

I’d already written this rule up as a GuardDog contribution before the incident. It’s merged into GuardDog’s v3 branch as the new_install_script metadata detector (PR #856) and is waiting on the next release. The logic is deliberately narrow: fire only on the transition from no install scripts to some, require a minimum number of earlier script-free versions, ignore empty script values, and ignore versions on a different semver line. On 13.3.0 it would have fired for the same reason my watcher did.

The registry side closed fast. 13.3.0 was published at 00:09:50, two public issues were open by 01:25, and by about 01:25 npm had removed it and restored 13.2.0, with GHSA-hh7j-5m3j-23pf issued at the same time. For most people following along, that looked like the end of it.

The pivot at 03:26: follow the identity, not the package

After the yank, the thread’s reasoning was that no other Ethersphere npm package had been published after 13.3.0. That was true, but npm was the wrong place to look.

The attacker hadn’t stolen an npm token. They held bee-worker, the GitHub release-automation identity for the whole organization, and that credential isn’t limited to npm. So I moved from the package artifact to the actor and to commit provenance, and checked what else bee-worker had touched across every ethersphere repository since 23:00 UTC the night before.

The second hit turned up right away: ethersphere/swarm-desktop, an Electron app distributed through GitHub Releases and its own auto-updater, with no npm involvement at all. Release v0.56.2, titled “emergency patch release”, was created by bee-worker at 00:36:31 UTC, thirty minutes after bee-js.

Legitimate Ethersphere releases land through pull requests and carry commit signatures. 2de7270 (“chore(master): release 0.56.2”) was a direct, unsigned commit on top of 0.56.1 with no pull request. Its diff showed src/index.ts importing node:https inside main() and running the same new Function('require', 'console', 'process', body) loader on every app launch, pointed at /v1/electron.

What made it urgent was distribution. CI had built and attached installers for every platform (.exe, .nupkg, .dmg, .deb, .rpm) and marked the release latest. The auto-update reach was narrower than the artifact list, per Socket’s analysis. Swarm Desktop uses update-electron-app, which polls every ten minutes on macOS and Windows. Apple Silicon Macs could be offered 0.56.2 from 00:44 UTC, with a build that was signed and notarized. Windows x64 clients could be offered it from 00:43; its Authenticode signature was made with the same Apple Developer ID certificate used for the macOS build, so it is mathematically valid but not one Windows would normally trust. Intel Macs were never offered it, because the release workflow has no Intel macOS job. Linux packages were unsigned and don’t auto-update. bee-js needed someone to run npm install at midnight UTC; on the two eligible platforms, this release came to the victim.

The RELEASES manifest that the Windows updater polls had already been downloaded several times when I looked. Socket counted 65 release downloads by 08:27 UTC, 22 of them the darwin-arm64 zip the macOS updater fetches, as upper bounds that include researchers. The advisory later described what the second stage did on a machine that ran it: read Swarm node key files, exfiltrate them, and install a remote-access agent with persistence on Linux, macOS and Windows.

I posted the finding and a containment list straight into the live thread instead of saving them for a write-up. The comment, timestamped 03:26 UTC, said in short: the same actor shipped a backdoored Swarm Desktop release and it is still the latest GitHub release; here is the release, the commit, the loader and the endpoint; the Windows RELEASES file has already been fetched so existing installs may update into it; delete or unmark v0.56.2 so 0.56.1 is latest again, revoke bee-worker, and check the other ethersphere repos for direct commits or releases by that account since 23:00 UTC.

The swarm-desktop repo got its own issue at 07:00 UTC from another reporter. Per Socket’s analysis, the v0.56.2 tag came down at 09:04 and the release was no longer public by about 09:14. v0.56.3, same code as 0.56.1 under a new signing certificate, shipped at 12:52. Ethersphere’s advisory on 13 November lists the release-bot credentials as revoked, the tags and releases deleted, and the pipeline moved behind protected environments, and credits the finding.

So the malicious release was identified in public, with commit-level evidence and a fix list, about five and a half hours before the tag came down. The maintainers were handling a credential compromise across an organization in the middle of their night, and I’m not faulting them for that. The gap I care about is in my own tooling: the watcher covers the registry, and the pivot across repositories was done by hand.

There’s also a lot I can’t see from outside. The download counts measure exposure; I have no evidence of how many machines actually updated. And I can’t say what other systems saw, or didn’t see, in that window.

What should be automated next

Four changes came out of this incident. All four are deterministic checks that don’t need a model, a sandbox or an entropy threshold.

  1. Diff lifecycle hooks between versions. A package whose history has no preinstall, install or postinstall and suddenly gains one is a structural event. Flag the transition, with a baseline long enough to trust. That’s what fired at 00:23, and it’s what new_install_script does in GuardDog’s v3 branch.
  2. Look past valid provenance and signatures. Both artifacts here had valid trust, but neither had the project’s normal commit shape. A release built from an unsigned direct commit with no pull request, in a repo where every prior release went through a signed PR, is provenance drift, and it’s visible from the commit API without reading a line of code.
  3. Use a flagged registry publish as a starting point. When a package trips a rule, the next automated step should be to resolve the publishing identity and query that identity’s activity across the whole organization: /orgs/{org}/events, /users/{actor}/events, recent releases and tags in every sibling repo, including the ones that ship Electron apps, CLI binaries and container images rather than npm packages. In this case that query could have surfaced swarm-desktop v0.56.2 within minutes of its creation. I ran it by hand three hours later.
  4. Post containment before analysis. At 03:26 the maintainers needed a release name, a commit hash, an endpoint and three actions. The diagrams and the C2 teardown could wait a week. Finding the second artifact only helps if it reaches the people who can pull it while it’s still live.

If you run a release bot, your signing pipeline will sign whatever that bot commits. Watch the bot’s commit shape and the hooks it introduces; a valid signature only tells you the pipeline ran.

Sources