Tracing the @ethersphere/bee-js 13.3.0 Compromise Into Downstream Builds

Tracing the @ethersphere/bee-js 13.3.0 Compromise Into Downstream Builds

pr0h0••20 min read•tech
AI Usage (92%)
On this page

Introduction

The question worth asking about @ethersphere/[email protected] isn't "was a package compromised?" It's whether the compromised tarball ever executed inside a build you own, and what that build had in scope when it did. Both have answers, and this post traces the path from the published version through npm's resolution rules into downstream builds — lockfiles, CI caches, container layers, and mirror proxies — with the commands that settle the question for your own repositories.

The public record on this one is thin, and I'd rather leave it thin than invent payload details. Step Security published a write-up titled "@ethersphere/bee-js 13.3.0 Compromised"; the feed entry carrying it is dated 9 November 2026. That much the source establishes. Everything else here about how a compromise like this propagates comes from ordinary npm publishing mechanics, and I've labelled it as inference.

📝

I could not verify the payload, the intrusion vector, or the current registry state of 13.3.0 from the material provided. Where those specifics matter, this post hands you the command that answers the question instead of guessing at the answer.

What @ethersphere/bee-js Is and Why Its npm Release Channel Matters

Where @ethersphere/bee-js sits in the Swarm/Bee stack

@ethersphere/bee-js is the JavaScript client for Bee, the node implementation of the Swarm decentralised storage network. Application code uses it to talk to a Bee node: uploading and downloading content-addressed chunks, managing postage batches, reading and writing feeds, and handling the signing and encryption helpers around that traffic. It ships as a library consumed by Node services and by browser bundles alike.

That puts it in an unusually exposed position. It isn't a leaf utility that formats dates. It sits in the request path of code holding node endpoint credentials, batch identifiers and — depending on the deployment — signing material.

Why a client-library compromise reaches further than its install count suggests

Weekly download counts measure direct installs. They undercount three things that matter here:

  1. Transitive reach. Client libraries get pulled in by a CLI, a dashboard, or a framework wrapper. Your package.json may never name it while your lockfile is full of it.
  2. Build-time execution. Any dependency can run code at install time through preinstall, install, or postinstall, and imported code runs in whatever process loads it — including a CI job with a publish token, a cloud credential and a GITHUB_TOKEN sitting in the environment.
  3. Distribution amplification. For a browser-facing library, a hostile entry point doesn't stop at the developer who installed it. It ships, minified, to every user of the built application.

Install count is a popularity metric. Blast radius is a function of where the package runs and what is ambient in that process.

What the Step Security Report Establishes, and What It Leaves Open

Confirmed facts attributed to the report

ClaimStatus
A Step Security write-up exists titled "@ethersphere/bee-js 13.3.0 Compromised"Confirmed by the supplied source
The feed entry for that write-up is dated 9 November 2026Confirmed by the supplied source
The affected artifact is the 13.3.0 release of the package @ethersphere/bee-jsConfirmed by the supplied source
The word "compromised" is the report's characterisation of that releaseConfirmed by the supplied source

That is the entire confirmed set I can responsibly assert. Look at what's missing from it: no CVE identifier, no payload description, no statement about the last known-good version, no statement about whether npm has unpublished or deprecated the release.

Open questions the public write-up does not resolve

Each of these changes the response, so each is worth resolving before you decide how hard to react:

  • Intrusion vector. Stolen publish token, hijacked maintainer session, compromise of the CI workflow that publishes, or a malicious insider. The distinction matters: token theft implies other packages under the same identity are suspect.
  • Execution mechanism. A postinstall script, a modified main/module entry, an added dependency, or a payload behind a conditional that only fires in CI. A postinstall script means code ran at install time; an entry-point change means code ran at import time. Different evidence, different containment.
  • Registry state. Whether 13.3.0 is still installable, deprecated, or unpublished, and whether npm revoked the publisher's tokens.
  • Scope. Whether other versions or sibling packages from the same publisher identity were touched.
  • Whether provenance attestations were present on the release. For downstream consumers this is the most valuable single question, because it tells you whether a signature check would have caught anything.

How to read this post's confirmed-versus-inferred split

I use three markers:

  • Confirmed — stated by the supplied primary source, or reproduced by a command shown here that you can run yourself.
  • Inferred — my reasoning from how npm's registry, lockfiles and install lifecycle work. Generic mechanics, not incident-specific claims.
  • Untested — plausible, but I have not verified it against this package, and I say so.

Where I show command output, I say whether it came from a synthetic input or from the real package. Fabricated terminal output in a supply-chain post isn't a stylistic quirk; it is the exact failure mode the post is about.

The Publish Path: How a Legitimate Release Becomes a Hostile One

Credential and session theft versus registry-side compromise

Three families of cause produce the same symptom — a version that exists, resolves, and is malicious:

  1. Publisher credential theft. A long-lived npm token leaks from a .npmrc, a CI secret, a laptop, or a malicious postinstall in another package the maintainer installed. The attacker runs npm publish as the maintainer. Registry-side, nothing looks wrong.
  2. Publish pipeline compromise. The release job builds from a branch or tag the attacker can influence. This is the nastiest variant, because if the pipeline attests provenance, the attacker gets a valid, cryptographically correct attestation for malicious code.
  3. Registry-side or CDN tampering. Rarest, and the only family where signature verification is a clean win: the tarball's digest no longer matches what the publisher signed.

Distinguishing them from the outside takes three fields: whether an attestation exists, whether the publish timestamp lines up with the git tag it claims to come from, and whether the tarball contents match the repository at that commit.

The publish surface an attacker actually controls

A publish is not "a version bump". It is a set of changes an attacker can make without ever touching your code:

SurfaceWhat changesWhen it bites
Install scriptspreinstall / install / postinstall in package.jsonAt npm install, with your CI env in scope
Entry pointsmain, module, exports, browser fieldsAt import, in the build or the runtime
Added dependenciesNew names in dependenciesAt install, transitively, possibly as a typosquat
Bundled filesExtra files inside the tarball not present in gitAt import, via a relative path
dist-tagslatest moved to an arbitrary versionOn any un-pinned range resolution
MetadataREADME, repository URL, maintainer listPhishing, or redirecting review to the wrong repo

The asymmetry to hold onto: you review the repository; you install the tarball. Those are different artifacts, and only one of them is what your build executes.

Why version-number trust is a weak gate

A caret range is a standing authorisation to accept future publishes. "@ethersphere/bee-js": "^13.2.1" permits 13.3.0, because a minor bump is, by semver, non-breaking — and therefore the least-scrutinised class of update in most review processes.

So the reachable population isn't "people who typed 13.3.0". It's:

  • anyone with a caret or tilde range who ran a fresh install or npm update while 13.3.0 was latest;
  • any CI job that installs without a committed lockfile (RUN npm install in a Dockerfile is the classic);
  • any project pulling it transitively where the intermediate package's range permitted the bump;
  • any environment resolving through a registry mirror that had already cached the tarball.

Version numbers are a naming convention, not an integrity control.

Tracing 13.3.0 Into a Downstream Build

Step 1: Enumerate where the package appears in your dependency graph

Start with the resolved tree, not package.json:

npm ls @ethersphere/bee-js --all

Illustrative shape from a synthetic project (not from the real package):

[email protected] /srv/app
├─┬ @ethersphere/[email protected]
│ └── @ethersphere/[email protected]
└─┬ [email protected]
  └── @ethersphere/[email protected] deduped

The deduped marker is the bit people miss: one hostile copy satisfies every consumer in the tree. With pnpm and Yarn workspaces the equivalents are pnpm why @ethersphere/bee-js and yarn why @ethersphere/bee-js.

In a monorepo, grep the lockfiles directly. It's faster, and it catches workspaces the resolver skipped:

rg -n --hidden -g 'package-lock.json' -g 'pnpm-lock.yaml' -g 'yarn.lock' \
  '@ethersphere/bee-js' .

Then widen the search. package.json files alone won't show it, but SBOMs, requirements-style inventories, and vendored node_modules committed to the repo will.

Step 2: Pull the published tarball and diff it against the previous release

This is where a headline turns into evidence. Fetch both releases as tarballs and diff the extracted trees:

mkdir -p /tmp/beejs && cd /tmp/beejs

npm pack @ethersphere/[email protected] --silent
npm pack @ethersphere/bee-js@<last-known-good> --silent

tar -xzf ethersphere-bee-js-13.3.0.tgz       && mv package a
tar -xzf ethersphere-bee-js-<last-known-good>.tgz && mv package b

diff -ru b a | head -200

Two notes on picking <last-known-good>. First, don't assume 13.2.9 — list what actually exists:

npm view @ethersphere/bee-js versions --json
npm view @ethersphere/bee-js time --json

The time map gives you publish timestamps per version, which is exactly what you need to spot an anomalous publish window. Second, if there is any doubt about which prior version is clean, diff against several and look for the delta that only shows up in 13.3.0.

What you're looking for, in priority order: a new postinstall/install/preinstall entry; a changed main/module/exports/browser target; a new name in dependencies; and any file present in a with no counterpart in b. That last one is the classic shape of a payload tucked into a file review never opens — dist/, a minified bundle, or a .js file nobody imports directly at source level.

Also check whether the release carries attestations at all:

npm view @ethersphere/[email protected] dist --json

An attestations object with a provenance predicate tells you the tarball was built by a specific workflow from a specific commit. Its absence tells you a signature check would have had nothing to verify — inferred, but a mechanically sound inference.

Step 3: Read the lockfile as forensic evidence, not just configuration

A lockfile entry for a resolved package contains three forensic fields:

  • integrity — a sha512 digest of the exact tarball. If the registry ever served different bytes under the same version, your lockfile digest and the served tarball diverge and npm fails with EINTEGRITY.
  • resolved — the URL the tarball came from. A mirror or proxy host here changes your cache-invalidation plan.
  • hasInstallScript — in lockfile v3, npm records whether the package declares install scripts. This tells you whether "did code execute at install time?" is even possible.

Treat an EINTEGRITY failure in CI history as a clue, not noise. It's one of the few signals that separates "the tarball changed under us" from "we installed it normally".

A small scanner over package-lock.json (lockfile v3) for the affected name and version, plus a broader sweep for anything that would run scripts:

const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
const packages = lock.packages ?? {};

const affected = [];
const scripted = [];

for (const [path, meta] of Object.entries(packages)) {
  if (!path) continue;

  if (meta.name === "@ethersphere/bee-js" || path.includes("@ethersphere/bee-js")) {
    affected.push({ path, version: meta.version, integrity: meta.integrity,
                    resolved: meta.resolved, hasInstallScript: meta.hasInstallScript === true });
  }

  if (meta.hasInstallScript === true) {
    scripted.push(`${meta.name ?? path}@${meta.version}`);
  }
}

console.log("affected:", affected);
console.log("install-script packages in tree:", scripted);

The second list is the one nobody remembers to ask for. If nothing in your tree runs install scripts — confirmed by hasInstallScript being absent or false everywhere — then a postinstall-based payload never executed, and your exposure is limited to import-time behaviour. That is a materially narrower incident.

For pnpm, the same data lives in pnpm-lock.yaml under snapshots; grep for the package name and read the adjacent resolution.integrity value.

Step 4: Check CI caches, container layers, and registry mirrors for stale copies

Cleaning your lockfile does not clean the places tarballs accumulate. Four to check:

Local and CI npm cache. npm's content-addressable cache keeps extracted tarballs keyed by URL:

grep -rl "@ethersphere/bee-js" ~/.npm/_cacache/index-v5

A hit means a cached copy exists. npm cache verify won't tell you which packages are in there by name; the index grep does.

CI cache actions. If your workflow caches ~/.npm or node_modules keyed on a lockfile hash, an entry from before the pin can be restored after it. Invalidate the key defensively rather than trusting the hash.

Container layers. A node_modules baked into an earlier layer persists even if a later layer reinstalls:

docker history --no-trunc your-image:tag | grep -i -E 'npm (ci|install)'
docker run --rm --entrypoint sh your-image:tag -c \
  'find / -path "*@ethersphere/bee-js/package.json" 2>/dev/null'

Registry mirrors and proxies. Verdaccio, Nexus and Artifactory all cache upstream tarballs. If 13.3.0 was fetched once, it gets served from cache to every consumer behind that proxy until purged. Purging it is a separate task from pinning it.

Provenance, Signatures, and the Limits of Attestation

What an npm provenance attestation actually binds to a build

A provenance attestation is a Sigstore-signed statement about how a tarball was produced. It binds three things together: the digest of the published tarball, the identity of the build workflow (repository, workflow file, ref, commit SHA), and the builder that ran it. Verification answers one precise question — "was this exact artifact built by this workflow from this commit?"

That is genuinely valuable. It kills the "attacker swapped the tarball on the registry or in transit" class outright, and it makes the build pipeline itself auditable.

Where attestation stops helping when the legitimate identity is the attacker

Provenance says nothing about intent or about the safety of the source commit. If an attacker publishes through the project's own CI — a stolen token scoped to publish, or a commit they can land on the release branch — the resulting attestation is valid and correct. Verification passes. The attestation did its job and you still got a malicious release.

The same applies to maintainer accounts. Attestation binds an artifact to an identity. It doesn't tell you whether that identity was under the legitimate owner's control at the time.

⚠️

"It has provenance" is not a safety verdict. It means the build was reproducible from a named commit by a named workflow. Treat it as a narrowing filter, not a green light.

Verification commands instead of assumed trust

## Verify signatures/attestations for the whole installed tree
npm audit signatures

## Inspect what attestations a specific version advertises
npm view @ethersphere/[email protected] dist.attestations --json

npm audit signatures validates the registry signatures and provenance attestations for installed packages. A clean run means your installed artifacts match what was signed. It does not mean the signed content is benign — which is exactly the failure mode in a publisher-identity compromise.

Reducing Blast Radius: Pinning, Overrides, and Install-Time Controls

Exact pinning and an update cooldown window

The cheapest structural change: stop accepting future publishes implicitly.

## .npmrc
save-exact=true

With save-exact, npm install foo writes "foo": "13.2.9" instead of "^13.2.9". Upgrades then become deliberate, reviewable diffs.

A cooldown window gives the ecosystem time to notice a bad release before it reaches you. npm supports --before for date-bounded resolution on fresh installs:

npm install @ethersphere/bee-js --before=2026-04-01

Caveat, untested across all npm versions: --before governs resolution, so it will not override an already-committed lockfile resolved to 13.3.0. It helps when you're adding or re-resolving, not when you're frozen. If you use Renovate or Dependabot, the cooldown belongs in the bot config instead — Renovate's minimumReleaseAge (previously stabilityDays) makes the automation wait days before proposing a fresh publish, which is where most of the practical value sits.

Lockfile discipline and reproducible installs in CI

  • Commit the lockfile. Always. A repo without one has no version history of its dependency tree and no forensic record after an incident.
  • Use npm ci, pnpm install --frozen-lockfile, or yarn install --immutable in CI. These fail instead of silently re-resolving.
  • Pin the package manager itself (Corepack or an explicit version) so lockfile semantics don't drift between a developer's laptop and the runner.
  • Build runtime images with npm ci --omit=dev; keep build-tool dependencies out of production images.
  • Keep the lockfile diff in code review. A lockfile change is a code change, and right now it gets reviewed like a formality.

Overrides, registry allowlists, and install-script policy

If a transitive dependency is stuck on a bad version you can't patch directly, force the resolution:

{
  "overrides": {
    "@ethersphere/bee-js": "13.2.9"
  }
}

For pnpm, the same intent goes under "pnpm": { "overrides": { ... } } in package.json. Verify the result with npm ls @ethersphere/bee-js --all — an override that silently misses a nested path is worse than none, because you think you're covered.

Install-script policy is the highest-leverage control most teams still haven't enabled:

## .npmrc — global switch, blunt but effective
ignore-scripts=true

Blunt, because some legitimate packages genuinely need to build a native addon at install time. The refined version is an allowlist via a tool like @lavamoat/allow-scripts, which permits scripts only for packages you name. For a client library like bee-js, which isn't compiling anything native, ignore-scripts=true costs you close to nothing.

Finally, scope your registry so a typo-squatted name can't resolve from the public registry by accident:

@ethersphere:registry=https://registry.npmjs.org/

Hardening Maintainer Accounts and Publish Workflows

Token scope, 2FA, and trusted publishing via OIDC

For maintainers, the ordering of fixes isn't subtle:

  1. Require 2FA on the account and on publish. Non-negotiable baseline.
  2. Kill long-lived tokens. Granular access tokens, scoped to a single package, read-only wherever write isn't needed. Audit what exists with npm token list, revoke anything unrecognised with npm token revoke <id>.
  3. Move publishing to trusted publishing over OIDC. The registry trusts a short-lived workload identity issued by your CI provider, so there's no static publish secret to steal from an environment variable or a log.
  4. Protect the release path. Environment protection rules, required reviewers, and a branch policy on whatever ref the publish workflow triggers from — because an attacker who can land a commit there gets a valid attestation for free.

A minimal trusted-publishing workflow, for a workflow that publishes with provenance:

name: publish
on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write   # required for trusted publishing / provenance

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: npm-publish
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - run: npm publish --provenance --access public

The id-token: write permission is the mechanism, and it's also the thing to review in other people's workflows. It's the difference between "publishing requires a secret that can leak" and "publishing requires an identity that expires in minutes".

What package maintainers should change first

If you maintain a package with any meaningful downstream reach, in this order: enable 2FA, revoke unused tokens, cut over to trusted publishing, add a --provenance publish, restrict the release trigger to a protected ref, and add a prepublishOnly script that runs a diff check against the intended version. That last one won't stop a determined attacker with publish rights, but it will stop a fat-fingered or automated bad publish, which is a real category.

If You Already Installed It: A Containment Sequence

Order matters, because each step changes the environment the next one inspects.

  1. Freeze. Stop deploys from the affected pipeline. Don't run npm install or npm ci again until the lockfile is fixed — a reinstall can repopulate a cache you just cleared.
  2. Determine whether code executed. Read hasInstallScript across the installed tree (Step 3 script). If nothing in the tree declares install scripts, a postinstall payload did not run. If something did, assume execution and move to step 3 immediately.
  3. Assume the CI environment was readable. Any code that ran in the job could read NPM_TOKEN, cloud credentials, GITHUB_TOKEN, and internal hostnames. Rotate publish tokens and any credential present in the affected job's environment. Revoke, don't just reissue.
  4. Scope the artifacts. Grep lockfiles and SBOMs across all repositories and workspaces for the package name and version. Include archived repos and long-lived branches; they get rebuilt eventually.
  5. Rebuild clean. Pin to a known-good version, purge the npm cache (npm cache clean --force is fine here — you want the cache gone, not verified), invalidate CI caches by bumping the key, rebuild images from scratch rather than patching layers, and reinstall with --ignore-scripts if your dependency set tolerates it.
  6. Verify the rebuilt output. Diff the produced bundle or image manifest against the last build you trust. A diff -r over the built output, or an SBOM diff, catches a payload that survived your source review.
  7. Purge the mirrors. If any internal registry proxy served the version, purge it and force a re-fetch. Otherwise step 5 is theatre.
  8. Watch. Monitor outbound requests from build infrastructure for a couple of weeks. Exfiltration from a build job often looks like a single unexpected DNS lookup or HTTPS POST, not a spike.

What Would Change This Assessment

This is a live incident with a thin public record, so the assessment is explicitly conditional:

  • If the write-up shows registry-side tampering rather than publisher-credential compromise, signature verification moves from "useful but limited" to "the control that would have caught it", and the containment sequence ends at step 5.
  • If the write-up shows a postinstall script, every environment that installed 13.3.0 without ignore-scripts should treat CI credentials as disclosed. Step 3 becomes urgent rather than routine.
  • If the change is confined to a browser entry point with no install scripts, the install-time story is quiet, but every shipped bundle built from 13.3.0 is affected — and that population is invisible in your lockfile audit.
  • If 13.3.0 was never reachable by a caret range from your pinned base — your lockfile predates the publish and you ran npm ci throughout, say — you were likely never exposed. The cache and mirror checks in Step 4 confirm that rather than assume it.
  • If other packages under the same publisher identity were touched, the scope expands beyond bee-js and the enumeration in Step 1 has to be repeated per package.

Unconfirmed and worth watching: whether npm has unpublished the version, and whether the publisher's tokens were revoked. If the version remains installable and the token was never revoked, the window for new downstream exposure is still open.

Conclusion

The bee-js incident isn't a story about one bad version. It demonstrates that the npm release channel is an execution channel: a publish can change what runs on your machine, in your CI, and in your users' browsers without a single line changing in your repository.

My position is that most teams are defending the wrong artifact. Review effort goes into source diffs of repositories; execution happens from tarballs resolved through ranges, cached in three layers, and mirrored through a proxy. The controls that actually change the outcome are unglamorous: exact pins, a committed lockfile, npm ci with a frozen lockfile, ignore-scripts where it's affordable, a cooldown before adopting fresh publishes, and a written-down procedure for the hour after you learn a version is bad.

Provenance and trusted publishing are worth adopting, and worth asking your dependencies to adopt. But be precise about what they buy: they prove who built this artifact from what commit. They do not prove the builder was acting in good faith. Keep the tarball diff and the lockfile audit in the playbook — they're what remains true when the legitimate identity is the attacker.

Further Reading

Share this post

More posts

Loading comments…