The Worm That Signed Its Commits "Claude"

By Juan Aguirre

The Worm That Signed Its Commits "Claude"

Well, it happened again. Same worm, new tricks, and a name it gave itself.

On August 4, 2026, someone got into the npm account behind two package families that almost nobody installs on purpose and almost everybody has installed. keyv. cacheable. flat-cache. file-entry-cache. If ESLint is in your project, most of these are too, four levels down, and you have never once typed their names.

Ten packages went out in the first forty minutes. By the time the dust settled the count was 434 packages across 1,381 versions, over two billion installs a month between them. keyv alone does 604 million downloads a month, which is why its name is on every write-up including this one , but this stopped being about keyv within the hour.

I spent sometime inside the payload. The credential theft is comprehensive and grim and I'll get to it. But the thing I keep coming back to is one string, in the module that talks to GitHub's GraphQL API:

Commits created through GitHub's createCommitOnBranch mutation are signed by GitHub itself. So the commits this worm makes , the ones carrying your secrets out, and the ones carrying the implant into your repositories , render with a green Verified badge, attributed to an AI assistant.

I'm not the first to spot that author string. I do think it's the most important detail in this incident, and it belongs at the top of a post rather than in a bullet near the bottom.

What actually shipped

The first wave, all times UTC on August 4:

Look at 09:38. Three minutes after the first malicious publish, a package belonging to somebody else goes out with the same hook. The worm works, and it works fast. The force-pushes at 09:39 say the attacker still held the account and the CI while defenders were already looking. Then it kept going , ecto, forty-odd @ornikar/* packages, Qlik's embed packages , because every set of stolen credentials bought another publish.

The diff is almost insultingly small. Clean 6.0.0-rc.1 beside malicious 6.0.0, the entire change to package.json:

Two files added, one lifecycle script, and dist/ untouched , byte-identical to the clean release candidate. So the package works. require('keyv') behaves exactly as documented, because nothing about the attack depends on the library doing anything at all. preinstall fires before a single line of package code is imported. You're compromised at install time, and you get a working key-value store as consolation.

One nuance specific to keyv, because I've seen it muddled: dist-tags.latest stayed pinned at 5.6.0, so npm install keyv never resolved to 6.0.0. Exposure came from explicit pins or wide ranges. Don't read that as a reprieve , it's one package out of four hundred, and the whole point of a worm is that the list grows while you're reading.

Stage one: bringing your own runtime

setup.mjs is obfuscated with obfuscator.io , RC4 string array, control-flow flattening, the usual. Underneath, it does something I'd been waiting to see somebody do properly. It checks whether bun is installed, and if not, downloads the genuine Bun runtime (v1.3.13) from GitHub releases and runs the second stage under that.

Your Node-level instrumentation doesn't see it. Your NODE_OPTIONS hooks don't fire. Your EDR watching node process trees sees node setup.mjs spawn something it has no policy for. The payload runs outside the runtime everyone is watching, and it gets there by downloading a legitimate, wildly popular developer tool from github[.]com. With no checksum verification, incidentally , the attacker trusts GitHub more than they trust you.

The top-level handler is the detail I'd frame:

Always exit zero. A failed infection must never fail the install, because a failed install is a developer who starts reading logs.

Stage two, and the wrong unit of work

Math_Symbol.js is 727,680 bytes of Bun-bundled CommonJS, obfuscated per module: 73 distinct 91-character basE91 alphabets over one shared 4,613-entry string array, rotated left by 0xe at construction, decoded lazily per scope.

So I reimplemented basE91 in Python, harvested every alphabet, decoded each array under each alphabet, kept whichever produced the sanest text, and got 4,612 strings out. Feeling good, and wrong.

Best-alphabet-per-array is the wrong unit. This bundle has one array and 73 alphabets, one per module , so picking a single winner meant decoding one module correctly and garbling seventy-two. I spent an afternoon searching those results for a persistence routine, found nothing, and concluded the sample didn't have one. It has one. Redone per index, trying all 73 alphabets against each entry, coverage went from scattered hits to candidates for 4,498 of 4,613 entries , and that run also found the C2 mechanism I'd already told people didn't exist.

Deobfuscation wasn't the hard part here. Choosing the right unit of work was.

The payoff: the array is rotated as a single unit, so index order roughly preserves bundle order and neighbouring indices reco nstruct a module's entire string set. You stop reading a list of strings and start reading components. The bundle even labels them , [collector], [dispatcher], [provenance], [publish].

Dressed as an agent

Here's the module that made me sit up. Its strings, in index order:

The .claude and .vscode hooks have generally been described as something the attacker did to the source repository. Sitting where they're sitting , inside the GraphQL commit builder, between the file-update machinery and the commit author constant , they're not that. They're a payload capability. The malware commits its own autostart hooks into any repository it holds a token for, which is how two package families became four hundred and roughly 1,300 attacker-created repositories.

What those two files do, if you're not deep in agent tooling:

Clone the repo to take a look, or point a coding agent at it, and the loader runs. No dependency installed, no script invoked deliberately, no npm install anywhere in the chain. Opening the folder is the payload.

And those commits are authored as claude@users.noreply.github.com and signed by GitHub, so they arrive Verified. If you've spent the last year watching agent-authored commits scroll past your repositories , and if you're honest, you stopped reading them closely months ago , this was built for exactly that blind spot.

Then it gets more specific. The branch selection module, getEligibleBranchesfetchBranchesfilterBranches, contains exactly four globs and no others:

dependabot/* dependabot/** copilot/* copilot/**

alongside Total branches fetched :, Eligible after filtering:, and No eligible branches found , nothing to do.

I could not settle whether that was an allowlist or a denylist , proving it means resolving the per-module index arithmetic back to call sites, and I ran out of afternoon. Aikido's analysis settled it: the worm commits to up to 50 branches per repository, skipping dependabot and copilot.

Which is the more interesting answer. It isn't hiding among bot commits , it steps around the branches where bot commits are expected and disposable, to land on the ones humans actually merge, wearing an AI assistant's name and GitHub's own signature. It wants the trust of automation without the transience of it.

Why all of this lives on a CI runner

Every supply chain compromise I've looked at this year has drifted toward the same place, and it isn't the developer laptop. It's the GitHub Actions runner , the one place where organization-level secrets, a workable OIDC identity, and the right to publish all sit together. This payload doesn't merely prefer that environment. It says so, in its own error message:

GITHUB_REPOSITORY env var is not set. This must be run inside a GitHub Actions workflow, or you must set GITHUB_REPOSITORY=<owner>/<repo> manually.

Secret masking doesn't help. The payload gates on RUNNER_OS , Not running on Linux runner / Runner matches! , then runs this:

It reads secrets out of the runner's process memory. Masking keeps secrets out of logs; this doesn't care about logs. A value that never printed anywhere is still in the heap tagged "isSecret":true , which, conveniently, is what makes it easy to find.

There's no stored token to steal, so it mints one.

That's npm's OIDC trusted publishing, which exists so maintainers don't have to park long-lived tokens in CI. Genuinely better security , but a workflow that can prove it's the right workflow can mint publish credentials on demand, and code running inside that workflow makes the same proof.

Then it signs its work , fulcio[.]sigstore[.]dev, rekor[.]sigstore[.]dev, slsa.dev/provenance/v1, DSSE envelopes, a [provenance] Rekor log entry created at index log line.

Which brings us to the detail I'd tattoo on the industry's forehead if I could. keyv@6.0.0 shipped with a passing npm provenance attestation. Not forged by the payload , passing, from the maintainer's own pipeline, because the legitimate release workflow faithfully attested a source tree that was already trojanized.

Provenance proves where a package was built. It does not prove the source was clean. Sigstore worked perfectly. npm worked perfectly. The attestation is valid. The package is malware. If your policy is "require provenance," you'd have installed this one with a green check beside it.

The part where I was wrong: C2 on a blockchain

In my first pass I found a DomainSender component that resolves destinations over DNS and health-checks them before sending, with no hardcoded domain in the file. I wrote that down as "no fixed C2, resolved dynamically over DNS." Reasonable, and wrong in the part that matters. Once the per-index decode gave me proper coverage, two strings turned up in the middle of that module:

contractAddress. Contract returned <n> domain(s). The domain list isn't resolved over DNS , it's read off the Ethereum blockchain, and then DNS-resolved and health-checked. The values come from Wiz's and Aikido's analysis:

An eth_call against a public RPC endpoint. No attacker-controlled infrastructure in the lookup path at all , just a read from a contract anyone can read and nobody can take down. It initially held three domains and was later updated to return only npm-cache[.]com, so the operator rotates infrastructure with one transaction and every deployed copy of the worm picks up the change. Seize npm-cache[.]com and the contract answers with something else tomorrow. Also worth noticing: pypi-get[.]com. This crew is thinking about more than npm.

So why couldn't I see it statically? Because there are two encryption layers. The blobs I'd filed as "residual base64, probably a key" are AES-256-GCM-encrypted constants decrypted at runtime, sitting under the basE91 layer. The contract address, RPC hosts and RSA public key are all in there, and getting them out means deriving masterKey first. That's the wall static analysis hits on this sample.

The operator left the lights on

The shipped bundle is not a stripped release build. It's a development build:

Bearer <redacted> and a path to ../redacted.js mean somebody built log redaction so they could safely paste output somewhere. A dry-run mode spanning publish, dispatch and commit means they iterated against a real registry and real repositories without firing. About to publish! and We tried. are what a developer writes at 2am.

Then, in the Kubernetes namespace list alongside kube-system and kube-public: local-path-storage. That's the default storage-provisioner namespace on k3s and kind , not something you find in a victim's production cluster, but exactly what you find in a local test cluster somebody spun up to check their secret enumeration worked.

And one string I haven't seen mentioned anywhere else, at index 2, right beside Collector start. and masterKey:

Exiting as russian language detected!

A language guard that aborts the run. I couldn't determine what it reads to make that call, so I'll leave the inference where it belongs, which is with you.

It named itself

Attribution here didn't come from the binary , the self-identifying markers aren't recoverable from the sample, for the reason above. It came from what the worm left lying around on GitHub. Every exfiltration repository it creates carries the same description:

Shai-Hulud: Here We Go Again

Roughly 1,300 of them. So we aren't inferring a lineage from tradecraft; the campaign is announcing itself, with a sequel title. Wiz places it more precisely as a descendant of the "Mini" Shai-Hulud family, related to earlier TeamPCP and antv activity , a name I've written about before and didn't expect to type again this month. Though a self-applied name only proves the branding travelled; whether it's the same crew, an evolution, or somebody who picked up the toolkit and the tagline together is harder to say.

Wiz also recovered a string planted in commits:

That's not a technical control. That's a bluff aimed at whoever finds the key, hoping they'll hesitate before revoking it. Do not hesitate.

Removing the package does not fix this

I say this in nearly every one of these posts, and it has never been more literally true.

The payload installs a host-level dead man's switch. From my sample I can confirm the call site , installTokenMonitor, spawning bash with a script fed on stdin, Backgrounded with PID, Failure saving persistence. , and Socket's team recovered the script itself. It writes the stolen GitHub token and a handler command to disk, persists as a macOS LaunchAgent or a Linux systemd user service with loginctl enable-linger so it survives logout, then polls the GitHub API with the stolen token every 60 seconds. When the token stops working:

Read that again. Revoking the credential is the trigger. The first thing every incident response playbook tells you to do is the thing that fires a remote-supplied command on the host. And the service calls itself "GitHub Token Validity Monitor," which in a list of launch agents reads like a developer convenience , right up until you notice that no linter, no secret scanner and no release tool installs a background service watching for its own credential to be revoked so it can evaluate a string it was handed.

So the order matters, and it's the reverse of what you'd reach for:

  • Remove the switch first. ~/.local/bin/gh-token-monitor.sh, ~/.config/gh-token-monitor/, ~/Library/LaunchAgents/com.user.gh-token-monitor.plist on macOS (unload it), ~/.config/systemd/user/gh-token-monitor.service on Linux (and loginctl disable-linger). Assume an equivalent exists for the npm token.
  • Remove the implant. Math_Symbol.js or math_init.js, bun-dl-* temp directories, the node setup.mjs → downloaded bun process chain, and any .claude/settings.json or .vscode/tasks.json you did not write.
  • Then rotate. npm, GitHub PATs and GITHUB_TOKEN, AWS, GCP, Azure, Vault, Kubernetes service accounts, SSH keys, Terraform credentials, and every org and repo secret. Revoke npm and GitHub tokens rather than only rotating them.
  • Then audit. Unexpected versions published from your account. New repositories , search your org for that Shai-Hulud: Here We Go Again description. Commits you don't recognise, including, especially, the Verified ones.
  • For dependencies: pin to the last known-clean version and rebuild lockfiles by integrity hash. No carets, no tildes, no npm update. Given the spread, blocking the keyv, @keyv and cacheable scopes at your registry proxy beats whack-a-mole with versions while a worm is still publishing.

    Indicators

    Defanged.

    Files (SHA-256)

    On disk

    Network , exfil destinations and RPC endpoints per Wiz and Aikido; the rest observed directly.

    Data goes out encrypted , aes-256-gcm under RSA_PKCS1_OAEP_PADDING , so the dead-drop repos and the domains receive ciphertext only.

    Hunt for

  • repositories described Shai-Hulud: Here We Go Again
  • commits authored claude@users.noreply.github.com you can't account for, Verified included
  • User-Agent Bun/1.3.13, and spoofed npm/11.13.1 node/v24.10.0
  • node setup.mjs spawning a downloaded bun
  • sudo python3 piped into grep -aoE '"isSecret":true'
  • a backgrounded bash polling api.github.com on a 60-second interval
  • What I'm still not sure about

    A capability list drawn from decoded strings isn't one drawn from traced execution, so:

  • Much of that 727 KB is legitimately bundled library code , node-tar, brotli and zlib constants, verbatim @aws-sdk ini parsing , and the obfuscator ran over everything, so library and bespoke code look identical. Every string I quoted is an operator-authored log line, a hardcoded target path, or a regex with no plausible library origin, but attributing specific functions needs call-site work I didn't finish.
  • The second encryption layer is intact. I have the mechanism, not the constants. Same for what the language guard reads.
  • GCP and Azure appear as regexes and file targets rather than confirmed API calls; AWS is unambiguous. And the filesystem sweep is broader than I'd have claimed from strings alone , Aikido counts ~200 glob patterns covering .env* files, private keys, SSH configs, Terraform state, Docker configs, KeePass databases and VPN configs, skipping anything over 5 MB, which is what my Error: File too large ( string and the per-OS "hotspots" tables were for.
  • The affected-package list was still moving as I wrote this, so treat any snapshot, mine included, as a floor rather than a total.

    The uncomfortable bit

    Strip away the Bun side-loading and the memory scraping and the smart-contract C2, and what's left is an attacker who worked out that the best camouflage available in 2026 is to look like an AI agent. We built an enormous volume of automated commit traffic into our workflows over the last two years, trained ourselves to skim past it, and somebody noticed.

    I don't have a clean answer. "Read your agent's commits" is true and useless at scale. But I'd start by treating a Verified badge as what it actually is , proof that GitHub's API made the commit, not proof that a human meant to. Same lesson as the provenance attestation one layer up, and the signed Bun binary one layer down. Every signature in this story is valid. Every one attests that the machinery ran correctly. Not one says anything about intent.

    How Can Safety Help Protect You?

    Everything in this attack happens before your tooling gets a look at it. A preinstall hook runs before any package code is imported, and a .claude or .vscode autostart hook runs before there's even an install to inspect. Scanning what landed in node_modules is already too late.

    The Safety Firewall analyzes every package installation request before it reaches the public registry, so malicious, vulnerable, and policy-violating packages are blocked before a lifecycle script ever executes , on developer machines and in CI alike. And because this campaign's persistence lives outside the dependency tree entirely, in LaunchAgents, systemd units and editor config, Safety gives security teams visibility into what is actually installed and running across developer workstations, not just what your SBOM says. Prevention and observability, not just detection.

    Interested in trying Safety Firewall? Reach out and we'll get you set up.

    Stay curious. I still owe you a masterKey.

    Read the full article