
Tracing the @ethersphere/bee-js 13.3.0 Compromise Into Downstream Builds
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:
- Transitive reach. Client libraries get pulled in by a CLI, a dashboard, or a framework wrapper. Your
package.jsonmay never name it while your lockfile is full of it. - Build-time execution. Any dependency can run code at install time through
preinstall,install, orpostinstall, and imported code runs in whatever process loads it — including a CI job with a publish token, a cloud credential and aGITHUB_TOKENsitting in the environment. - 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
| Claim | Status |
|---|---|
| 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 2026 | Confirmed by the supplied source |
The affected artifact is the 13.3.0 release of the package @ethersphere/bee-js | Confirmed by the supplied source |
| The word "compromised" is the report's characterisation of that release | Confirmed 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
postinstallscript, a modifiedmain/moduleentry, 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.0is 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:
- Publisher credential theft. A long-lived npm token leaks from a
.npmrc, a CI secret, a laptop, or a maliciouspostinstallin another package the maintainer installed. The attacker runsnpm publishas the maintainer. Registry-side, nothing looks wrong. - 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.
- 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:
| Surface | What changes | When it bites |
|---|---|---|
| Install scripts | preinstall / install / postinstall in package.json | At npm install, with your CI env in scope |
| Entry points | main, module, exports, browser fields | At import, in the build or the runtime |
| Added dependencies | New names in dependencies | At install, transitively, possibly as a typosquat |
| Bundled files | Extra files inside the tarball not present in git | At import, via a relative path |
dist-tags | latest moved to an arbitrary version | On any un-pinned range resolution |
| Metadata | README, repository URL, maintainer list | Phishing, 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 updatewhile13.3.0waslatest; - any CI job that installs without a committed lockfile (
RUN npm installin 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— asha512digest 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 withEINTEGRITY.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, oryarn install --immutablein 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:
- Require 2FA on the account and on publish. Non-negotiable baseline.
- 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 withnpm token revoke <id>. - 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.
- 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.
- Freeze. Stop deploys from the affected pipeline. Don't run
npm installornpm ciagain until the lockfile is fixed — a reinstall can repopulate a cache you just cleared. - Determine whether code executed. Read
hasInstallScriptacross 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. - 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. - 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.
- Rebuild clean. Pin to a known-good version, purge the npm cache (
npm cache clean --forceis 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-scriptsif your dependency set tolerates it. - Verify the rebuilt output. Diff the produced bundle or image manifest against the last build you trust. A
diff -rover the built output, or an SBOM diff, catches a payload that survived your source review. - Purge the mirrors. If any internal registry proxy served the version, purge it and force a re-fetch. Otherwise step 5 is theatre.
- 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
postinstallscript, every environment that installed13.3.0withoutignore-scriptsshould 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.0is affected — and that population is invisible in your lockfile audit. - If
13.3.0was never reachable by a caret range from your pinned base — your lockfile predates the publish and you rannpm cithroughout, 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-jsand 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
- Step Security blog — where the "@ethersphere/bee-js 13.3.0 Compromised" write-up was published; read the primary report for payload details this post deliberately does not assert.
- @ethersphere/bee-js on npm — version list, publish timestamps, and current registry state of
13.3.0. - ethersphere/bee-js on GitHub — source, tags, and release history to diff the published tarball against.
- npm: generating provenance statements — what a provenance attestation binds, and how to publish with
--provenance. - npm: trusted publishing — OIDC-based publishing that removes long-lived publish tokens.
- GitHub: about security hardening with OpenID Connect — the workload identity behind trusted publishing in Actions.
- SLSA provenance specification — the underlying model for build provenance, useful for reading attestations critically.
More posts

Reading the Axios npm Diff: How a Postinstall Hook Turns Into CI RCE
A technical walkthrough of the malicious Axios npm release shows exactly how a postinstall hook escalates from a dependency install into remote code execution inside CI pipelines, and which lockfile and install-script signals developers should audit first.

$16K per Node: What Per-GPU Software Licensing Adds to a Self-Hosted AI Bill
Nvidia's roughly $2,000-per-GPU enterprise software licensing model adds about $16K per eight-GPU node to self-hosted AI costs, reshaping cloud comparisons, budgeting, and vendor lock-in decisions for teams running GPU-backed workloads.

Instrumenting Agent Loops for Cost per Successful Task
Nvidia's Agentic AI Stack claim that token usage doubles monthly frames inference cost as the key constraint, and this walkthrough shows how to instrument agent loops with token budgets, model routing, caching, and cost-per-successful-task metrics.
Loading comments…