Security1 publisher3 min readPublished
Shai-Hulud's third wave printed SAP's npm token straight into a workflow log
Four core SAP build packages shipped an identical 11.6MB credential stealer. Because npm trusted the whole cap-js repository rather than one branch, a commit pushed to an unused branch was enough to publish them.
The Watch · Security desk

What happened
- Four packages shipped the same malicious preinstall hook, which bootstraps the Bun runtime and runs an obfuscated 11.6MB credential stealer from a setup.mjs loader that is byte-for-byte identical in each.
- For the cap-js packages, an attacker using a compromised SAP developer's GitHub account pushed a commit to the update/releases branch that rewrote release-please.yml to echo a live npm token into the run log.
- Stolen secrets are dropped into new repositories on each victim's own GitHub account, titled "A Mini Shai-Hulud has Appeared" and visible in public GitHub search.
Compiled by The WatchSomething wrong?How this is made
Why it matters
- exposure Anyone who built, tested or deployed SAP applications against these versions has to work from the assumption that the npm token and everything else in that environment left the building, because StepSecurity reports the theft was silent.
- constraint Repository-scoped trusted publishing hands an attacker who can push any branch the same publish rights as the release branch, which makes branch or workflow scoping the floor for every package using OIDC.
- capability Wrapping the exfil under RSA-4096 takes away the triage shortcut defenders used in earlier waves, where reading the dead drop told you which secrets to rotate and which to leave alone.
- contradiction The version numbers for @cap-js/postgres and @cap-js/db-service swap between the affected list and the unpublish note, so a team pinning by version alone can clear a package that is still poisoned.
StepSecurity calls the byte-for-byte identical `setup.mjs` loader a conclusive fingerprint of the worm running autonomously [3]. Its own forensics describe something more hands-on: two separate entry points, which the writeup puts roughly two hours apart [17]. The mbt publish went out at 09:55 UTC through `cloudmtabot`, the same service account behind every legitimate mbt release, using a static automation token the attacker got hold of through a channel not visible in public repository data [9]. The first malicious commit in `cap-js/cds-dbs` landed at 11:23 UTC [10], 88 minutes after the mbt publish [16]. Identical payloads across four packages is consistent with worm propagation; it does not account for two distinct publishing chains being taken over inside an hour and a half, one of them requiring a hijacked SAP developer's GitHub account [10].
The OIDC abuse is the part with the widest blast radius outside SAP. The attacker never touched `main`. Rewriting `release-please.yml` so it fired on `update/releases`, then swapping the publish steps for a manual OIDC exchange that echoed the token double-base64-encoded into the run log, produced a live publish credential because the trusted publisher configuration trusted the repository and not a branch or workflow [10][11]. Any project with that configuration has the same hole.
Cleanup came after the fact. Commit `4ae7eb0` stripped the OIDC exchange code, workflow run 25108178873 was cancelled, and the branch was force-reverted [12]. The packages were already in the registry [12]. StepSecurity says it detected the first malicious publish within minutes through its OSS AI Package Analyst and Harden-Runner, raised GitHub issues on the affected repositories, and notified SAP's security team directly [14].
Two of the four have since been unpublished, but the writeup gives `@cap-js/postgres` and `@cap-js/db-service` one pair of version numbers in its affected list and the inverse pair in its unpublish note [13]. Pin by package name and treat both listed versions of each as suspect.
Registry cleanup does nothing about the persistence. The payload committed `.claude/settings.json` with a Claude Code `SessionStart` hook and `.vscode/tasks.json` with `"runOn": "folderOpen"` into every repository it could reach, authored as `[email protected]` under the message "chore: update dependencies" [6]. A developer who opens one of those folders re-runs the malware [6]. The hook is stored as repository state rather than a dependency, so it survives every npm-side remediation.
The dead-drop repositories on victims' own GitHub accounts are searchable and titled "A Mini Shai-Hulud has Appeared" [4], but their contents are AES-256-GCM ciphertext with the key wrapped under an RSA-4096 public key embedded in the payload [8]. Finding your own dead drop only confirms the breach, not the contents. StepSecurity's read on the operator rests on the payload exiting cleanly on `ru`-locale systems with the log line "Exiting as russian language detected!", which it describes as a well-known pattern among Eastern European actors [7]. That is a code-level heuristic, and it is doing less work here than the token forensics.
For anyone whose CI installed one of these versions, the recovery scope is every credential that job could see plus every repository the resulting token could commit to.
What to watch
- Whether StepSecurity's ongoing scan turns up compromised packages beyond the four confirmed so far.
- Whether mbt moves off static automation tokens and cap-js trusted publishing gets scoped to a branch or workflow.
- Whether anyone recovers the RSA-4096 private key, which is the only way victims learn what the dead-drop repos actually hold.