november 2026 · 5 min

The Release Button Is a Signing Key

On Nov 9, @ethersphere/bee-js 13.3.0 went out on npm with a credential stealer in it, and with valid SLSA provenance linking it to the real repository and the real workflow. Within half an hour a trojanized Swarm Desktop 0.56.2 appeared, signed with the project's Developer ID and notarized by Apple, for the app's own updater to offer.

Nothing in the publish pipeline misbehaved. The attacker didn't need the npm token or the signing certificates. They needed the button that tells the pipeline to use them. Where the trigger is the only gate, whoever can cut a release holds your publishing keys.

The Mechanism

The sequence, from StepSecurity, Socket and SafeDep (times UTC):

npm removed 13.3.0 about 75 minutes after it went up. The payload ran from postinstall, sent swarm.key and config.yaml (which holds the keystore password) to the attacker, and dropped a backdoor that reads its C2 address from an Ethereum contract. The advisory says any machine that had it installed "should be considered fully compromised." How the attacker got the accounts is not established. Socket names two, the bot and a maintainer, and doesn't say how either was obtained.

SafeDep makes the point that matters: no branch contains the commit. "Branch protection did not stop this, because the attacker did not use a branch." The code shipped through a release.

Why Provenance Passed

Provenance answers one question: was this artifact built by this workflow, from this repository, at this ref? Here the answer was yes, honestly. As Socket puts it: "It proves where a package was built, not that the source was reviewed."

That's the class. A release workflow is a deploy, and the event that starts it is the authorization. Most teams protect main with reviews and signed commits, then wire publishing to on: release or workflow_dispatch, events that anyone with write access, or a bot token, can fire from any ref. The keys sit behind the weakest trigger in the repo.

It's the same DNA I built Preflight around after the Codecov breach, and the reason to trust your own hash: checks done on the publisher's side certify the publisher, and say nothing once the publisher is the one compromised. Provenance is a better receipt. It's still a receipt.

The Check

List the workflows that start on a release or a manual run and never name an environment:

grep -lE '^\s*(release|workflow_dispatch):' .github/workflows/*.y*ml \
  | xargs -r grep -L '^\s*environment:'

Treat the output as candidates: it misses inline triggers like on: [release], and a file counts as covered if any job in it names an environment. Anything it prints that publishes, signs or deploys reaches its secrets with nobody in the way. Then see what the environments you do use actually enforce:

gh api repos/OWNER/REPO/environments \
  --jq '.environments[] | {name, rules: [.protection_rules[]?.type]}'

An environment with no required_reviewers is a name, not a gate. (The self-review setting lives on that rule, as prevent_self_review.)

The Control

Move the authorization from the trigger to the job that holds the keys:

The Tradeoff

Every release now waits for a person. That's the point, but release-please style automation stops being fully automatic, and a weekend hotfix waits for whoever is on the reviewer list. On Free, Pro and Team plans, required reviewers only work in public repos.

And it raises the bar, it doesn't remove it. Socket says the attacker held a maintainer account as well as the bot. A maintainer on the reviewer list can approve what the bot started, so the gate is as strong as the second identity. Two stolen accounts instead of one is still a real improvement over one bot token that opens everything, but don't make the bot a reviewer and don't let one person be the whole list.

A publish job with no person in front of it is a signing key left on the desk. Find yours with the grep above before someone else presses the button.

← all posts