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):
- 23:59, Nov 8: an unsigned commit, "chore(upcoming): release 13.3.0", under the identity of
bee-worker, the project's release automation. StepSecurity found no Actions run that created it. The real release pull request was still open. - 00:06, Nov 9:
bee-workerpublishes a GitHub release on a new tag,v13.3.0. - 00:09: the release triggers the normal npm publish workflow. It uses trusted publishing, so no npm token is involved, and npm records a genuine provenance attestation for tag
v13.3.0and that commit. - 00:36: Swarm Desktop 0.56.2 appears on GitHub, signed and notarized. The updater can offer it to Windows x64 machines and Apple Silicon Macs.
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:
|
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:
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:
- One environment for publishing, with required reviewers. GitHub holds environment secrets until a reviewer approves, and can prevent whoever started the run from approving it. A stolen bot token can still fire the release. It can't approve its own run.
- Bind npm to that environment. The npm trusted publisher settings take an optional environment name. Set it, and a publish from any other job is refused, even from the right workflow file.
- Put signing certificates in the same environment. Signing is publishing. Socket's advice for the desktop side is the same: don't expose signing certificates and notarization credentials to manually dispatched workflows that accept an arbitrary branch input.
- Restrict who can create release tags. A tag ruleset on
v*with "Restrict creations" leaves tag creation to the bypass list. Keep the release bot off it unless it truly cuts tags, and scope it to exactly that.
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.