Skip to content

bee-js 13.3.0 had valid provenance, a real release bot, and a wallet stealer. GitHub Actions did exactly what it was told

bee-js 13.3.0 Compromise: Trusted Publishing Hijack, Swarm Desktop 0.56.2

bee-js 13.3.0 had valid provenance, a real release bot, and a wallet stealer. GitHub Actions did exactly what it was told

on November 9 someone used Ethersphere’s own GitHub release automation to publish a malicious @ethersphere/bee-js 13.3.0 to npm and a trojanized, Apple-notarized Swarm Desktop 0.56.2. no npm token was stolen. none was needed. the legitimate workflow, with trusted publishing and genuine SLSA provenance, built and signed the attacker’s commit. every badge was green. every badge was technically correct. this is the GitHub Tools column, and today the tool is your CI.

Defensive use only. this post covers lockfile and host checks, network IOC matching, and a heuristic audit of your own GitHub Actions workflows. the kit is offline and read-only, contacts nothing, and contains no malware or exploit code. the loader snippets quoted in the research are not reproduced here. indicators come from Socket and StepSecurity.
The short version. bad: @ethersphere/[email protected] on npm (live about 75 minutes, 00:09:50 to 01:24:58 UTC on November 9, per Socket) and Swarm Desktop 0.56.2 on GitHub (public about eight and a half hours). good: bee-js 13.2.0 and Swarm Desktop 0.56.3. the npm package added a postinstall hook that fetched and ran JavaScript from api[.]frameworkmetrics[.]xyz; later stages stole the Swarm node’s config.yaml and swarm.key and installed persistence. GitHub advisory: GHSA-hh7j-5m3j-23pf. Socket ties the infrastructure to the earlier SleepyDuck / EtherDuck Open VSX campaign through a shared Ethereum smart contract.

what got hit

@ethersphere/bee-js is the JavaScript client for Bee, the node software of the Swarm decentralized storage network. Socket puts it at about 470 npm downloads a day in early November, used by Ethersphere’s own swarm-cli, bee-dashboard and create-swarm-app, plus third-party Swarm apps. Swarm Desktop is the Electron app that installs and runs a Bee node, including the node’s Ethereum wallet.

small numbers, high value targets: developers who keep crypto wallets on the same machine as their dependency tree. which is to say, developers.

how it got in, per Socket

Socket’s analysis (November 9) says two GitHub accounts were compromised: nugaon, a long-time maintainer who still had force-push rights to an unprotected master on swarm-cli, and bee-worker, the release automation identity. Socket says the origin of both compromises predates November 6 “and remains unexplained.” StepSecurity agrees the evidence points to misuse of the bee-worker identity and says it “does not establish which credential was used or how it was obtained.” so: we do not know how the keys walked out. nobody does yet.

there was a dress rehearsal. on November 6, the same actor pushed workflows that dumped the full secrets and vars contexts to webhook[.]site collectors, then edited swarm-cli’s existing check.yaml eleven times in 72 minutes, narrowing from all secrets to PATs, then code-signing material, then NPM_TOKEN. Socket assesses “with moderate confidence that nothing was exfiltrated” from that attempt. three days later the actor stopped trying to steal the publishing credentials and simply used the publishing pipeline.

the npm side: a version script that learned to phone home

an unsigned commit, de2f74e, authored as bee-worker and titled to look like a release-please release (“chore(upcoming): release 13.3.0”), changed four files. the important edits, per both Socket and StepSecurity:

  • package.json added scripts to the shipped files list and renamed the existing prepare hook to postinstall. Socket notes this was the first install-time hook in the package’s history across 177 other versions.
  • scripts/generate-version.cjs gained a block at the top that requests /v1/analytics from api[.]frameworkmetrics[.]xyz with the OS name as the User-Agent and an X-Ethersphere: 1 header, then runs whatever comes back through new Function with real require and process.

the comments called it an “analytics ping.” the request has no environment, CI, or time check. importing the library is not required. installing it is enough, if install scripts run.

which clients run them? per Socket: npm 11 and earlier, Yarn Classic, and Yarn Berry before 4.14.0 run dependency postinstall scripts by default. npm 12, pnpm 10 and later, Yarn Berry 4.14.0 and later, Bun and Deno skip unreviewed dependency scripts by default. if your CI image is on npm 11, it ran.

one sharp detail: Socket found that a failed install is not evidence you were spared. request-level errors are swallowed, but errors after the response starts become an unhandled rejection that fails the install with exit code 1. by then the request has already gone out.

the CI side: the part every maintainer should read twice

at 00:06:01 UTC on November 9, bee-worker published a GitHub release for an unsigned tag v13.3.0 pointing at de2f74e. that release event started the project’s normal publish workflow. Socket’s read of that workflow:

  • tests, checks and entrypoint jobs ran only on push. the publish job explicitly accepted release events with those jobs skipped.
  • nothing checked that the released commit was on the protected upcoming branch.
  • the publish environment had no required reviewers and no tag policy. “The deployment went from creation to success in 73 seconds with no approval step.”
  • because the build calls the rewritten script, the loader ran three times inside the publishing job, which had id-token: write for trusted publishing. the C2 answered the runner with JavaScript. what it did beyond printing a line “cannot be determined from public data.”

and the provenance? Socket verified it end to end, offline: signatures, Fulcio chain, SCT, Rekor inclusion, tarball digest. all genuine. the certificate correctly names the workflow, the tag, the commit, the trigger and the environment. “The legitimate 13.2.0 attestation has exactly the same shape.” Socket’s conclusion is the sentence of the week: provenance “proves where a package was built, not that the source was reviewed.”

we have spent two years telling people to “check provenance.” that advice was not wrong. it was incomplete, and this is what incomplete looks like.

the desktop side: signed, notarized, auto-updated

about 30 minutes later, the same identity created a branch emergency/0.56.2, published a full (not draft) GitHub release v0.56.2 titled “emergency patch release,” and dispatched pre-release.yml, a manually triggered workflow that takes a free-form branch input, has no environment protection, and hands the build the Apple and Windows signing certificates and the Apple ID used for notarization. per Socket, that workflow had been broken since March because it never installed pnpm. the attacker fixed it. the fix commit carries a Co-authored-by: Cursor trailer and was pushed through the nugaon account. we will leave that one there.

the result: every 0.56.2 package Socket unpacked had the same loader in the Electron main process, running at every launch before anything else. the macOS build was signed with the project’s Developer ID and notarized by Apple. Swarm Desktop uses update-electron-app, which ignores drafts and pre-releases, which is why publishing a full release mattered: Windows x64 and Apple Silicon users could be offered 0.56.2 automatically. Socket counted download counters at 65 by 08:27 UTC, an upper bound that includes researchers.

deleting a release does not roll anyone back. machines on 0.56.2 stayed there until 0.56.3 shipped.

what the later stages do

summarized from Socket and StepSecurity. we are describing behavior for detection, not reproducing code:

  • stage 2: with the gate header, the server returns per-OS JavaScript that looks for the Swarm Desktop data directory and posts config.yaml and data-dir/keys/swarm.key to /v1/swarm/report. the key is an encrypted Ethereum keystore. the config holds its password in plain text. together, that is the wallet.
  • stage 3, Linux and macOS: a Go backdoor in ~/.solidity/, launched by .run.py or .run.cjs, persisted as systemd user unit solidity-langsupport-runner.service or LaunchAgent com.solidity.langsupport.runner. Socket says it reads its hub address from an Ethereum contract through public RPC gateways and gives the operator a shell.
  • stage 3, Windows: per Socket, a PowerShell loader that elevates, adds a Defender exclusion, and silently installs a genuine ConnectWise-signed ScreenConnect client configured for relay 185[.]232[.]84[.]169:8041, running as the service ScreenConnect Client (5cbe79c9c25524df). StepSecurity, which did not retrieve the Windows binaries, was more cautious about the elevation mechanism. so are we.

the “Solidity language support” names make no sense for Swarm users. Socket says they carried over unchanged from the EtherDuck Open VSX campaign, along with the contract 0xf8a900Db50b3331be6B768bA460BB59f3E40C344, its owner wallet, six payload hashes, the ScreenConnect instance and the relay. the operator went from fake VS Code-style extensions you had to install yourself to a real project’s release pipeline. same targets. better distribution.

step 1: did it touch you?

the kit’s repos mode walks a directory of checked-out projects and flags any npm, Yarn or pnpm lockfile resolving @ethersphere/bee-js 13.3.0 or carrying the malicious tarball’s integrity hash. host mode checks a home directory for ~/.solidity, the systemd user unit, the LaunchAgent plist, and a bee-desktop.log line reading “Bee Desktop version: 0.56.2”, which Socket says each launch writes before the loader runs. netlog greps DNS, proxy or firewall logs for the C2 domain and the hub and relay addresses.

Kali terminal running beejs-check.py repos over three lab projects and flagging wallet-ui's package-lock.json for resolving bee-js 13.3.0 while swarm-tools is OK on 13.2.0, then host mode finding ~/.solidity, its launcher and Linux binary, the solidity-langsupport-runner systemd user unit, and a bee-desktop.log line showing 0.56.2 launched, then netlog mode flagging a DNS query for api.frameworkmetrics.xyz and a connection to 193.57.9.155 port 4912.
Step 1: synthetic repos, home directory and DNS log. one bad lockfile, a full set of persistence artifacts, and the C2 lookup. this lab machine is having a bad week.

if anything hits, Socket’s and StepSecurity’s advice lines up: treat the machine or CI job as having run attacker code, isolate and rebuild from a trusted image, rotate every credential reachable from it (including cloud roles reachable through CI OIDC trust), and move Swarm node funds, chequebook and stake to a new key from a clean machine. removing the package does not remove the persistence.

step 2: audit your own pipeline before someone else does

here is the uncomfortable part for everyone who is not Ethersphere. the gaps Socket describes are common:

  • a publish job that fires on a release event without checking the tagged commit is on a protected branch
  • tests that only run on push, so the release path skips them
  • a publishing environment with no required reviewers and no tag restrictions
  • a manually dispatched workflow that takes any branch name and receives signing certificates
  • a release-bot token treated like a convenience instead of a publishing credential

the kit’s workflows mode is a deliberately dumb heuristic pass over .github/workflows that flags the first, fourth, and missing-environment cases. it cannot see your environment reviewers or rulesets, because those live in repository settings, so it tells you to go check. for a real static analysis of Actions workflows, run zizmor, an open-source GitHub Actions auditor, as well. it is better at this than our 60 lines of regex, and we are fine admitting it.

Kali terminal running beejs-check.py workflows against a lab repository, warning that pre-release.yml combines manual dispatch with a free-form branch input and signing secrets and has no environment, and that publish.yaml lets a release event publish without a protected-branch check, then running beejs-harden.sh in dry-run mode proposing npm ignore-scripts, an exact 13.2.0 pin, and npm audit signatures, with a note that 13.3.0 had valid provenance.
Step 2: three warnings on a lab copy of the pattern Socket described, plus package-manager hardening in dry-run mode. the box shows npm 11.21.1, which runs dependency install scripts by default.

Socket’s maintainer recommendations, condensed, because they are good:

  1. do not let a release event publish without proof the commit is on a protected branch or came from a merged release PR. check it inside the publishing workflow.
  2. put the publishing environment behind required reviewers and restrict which tags can deploy to it. use rulesets to restrict tag and release creation.
  3. do not skip tests on the release path.
  4. do not expose signing certificates and notarization credentials to manually dispatched workflows that accept an arbitrary branch.
  5. scope, rotate and alert on the release bot’s token like the publishing credential it is.

step 3: consumer-side hardening

  • turn off install scripts by default. npm config set ignore-scripts true at user or CI level, then allowlist the few packages that truly need them. or move to a client that already defaults to skipping them, per the list above.
  • pin exact versions for anything that touches wallets or secrets. "^13.0.0" is how projects that never chose 13.3.0 got it anyway. StepSecurity lists swarm-cli 3.7.0 (^13.2.0) and bee-dashboard 0.37.1 (^13.0.0) as examples whose ranges admitted it.
  • purge caches and mirrors. Socket found a public npm mirror still serving the malicious tarball with matching checksums hours after npm removed it. your internal Artifactory probably did the same thing.
  • keep using npm audit signatures. it verifies registry signatures and provenance. just understand that 13.3.0 would have passed, because its provenance was real.

the industry part

supply-chain security spent the last few years building better locks for the front door: no long-lived tokens, OIDC, provenance, signed builds, notarization. all good. all present here. the attacker came in through the release bot and used the locks as a delivery service.

the boring controls are the ones that would have stopped it: branch protection on the branch you release from, a human approving the publish environment, tests that run on every path to production. none of those have a logo or a conference talk. all of them would have turned a 73-second deployment into a “why is there a release nobody approved” message.

download the kit

beejs-13-3-0-compromise-kit.zip contains beejs-check.py (repos, host, netlog and workflows modes, with the Socket and StepSecurity IOCs built in), beejs-harden.sh (user-level npm hardening, dry run by default), a README, and the synthetic lab files from the screenshots. good versions are printed in the output: bee-js 13.2.0, Swarm Desktop 0.56.3. offline, read-only, no exploit code.

if nobody on your team can say which of your workflows can publish or sign without a human approving it, that is a short review with a long payoff. Book a slot with Dark Edge Consulting.

indicators (defanged)

  • packages: @ethersphere/[email protected]; Swarm Desktop v0.56.2
  • commits: bee-js de2f74e784b2eafa0d66fbb6d88b42ea11ee34cf; Swarm Desktop 2de727046706062d38edfbbf8340f32d1eb2bdfb, dc7d7455c95b3092eb14bff2bd93315b84b066fa
  • domain: api[.]frameworkmetrics[.]xyz (paths /v1/analytics, /v1/electron, /v1/swarm/report, /v1/assets/)
  • hubs: 193[.]57[.]9[.]155:4912 (current per Socket), 193[.]57[.]9[.]17, 178[.]16[.]54[.]222, 107[.]189[.]27[.]223, 107[.]189[.]27[.]46, 91[.]108[.]240[.]156, 107[.]189[.]16[.]215; ScreenConnect relay 185[.]232[.]84[.]169:8041
  • contract: 0xf8a900Db50b3331be6B768bA460BB59f3E40C344 (getter selector 0x02d91518 returns the hub)
  • bee-js-13.3.0.tgz SHA-256: d01fdf241aa9e0a1ba6ebc19f8f667af1bf652745021c5050126cb7036f7ce58
  • Swarm Desktop 0.56.2 dist/desktop/src/index.js SHA-256: f81315968e679b48f6bdff1c9b928bb223230d07f44d7948412c2250c2a14deb
  • stage 3 linux_amd64: 9b73e7cd4e1425e770392549d8df46c706139ef476f4f7b9ac405165dc8d9696; linux_arm64: b601776817363b96295119f7221338a122d5247a35a77a64b04f286e1bbcc565
  • persistence: ~/.solidity/, solidity-langsupport-runner.service, com.solidity.langsupport.runner, service ScreenConnect Client (5cbe79c9c25524df)

full hash lists for every platform package are in Socket’s post.

sources