Incident · self-spreading npm malware

The Shai-Hulud npm worm: what it did on a developer’s Mac, and what Kilobat would have shown

In September 2025 a worm spread through npm: each infected package stole the developer’s or CI system’s tokens, used any npm token it found to publish infected versions of that maintainer’s other packages, and leaked secrets through GitHub. A second, larger wave in November ran before installation even finished.

When: September and November 2025Sources checked: 1 November 2026

In short

  • Protection: Wouldn’t help

    Nothing to block on the Mac. Its traffic went to npm, GitHub and cloud APIs; webhook.site was called from GitHub’s runners, not from developers’ machines.

  • Agent guard: Wouldn’t help

    The webhook.site rule never came into play, because the request came from a GitHub Actions runner. The other destinations were well-known hosts or local addresses.

  • Agent network log: Limited

    For an install an agent ran, it would have shown TruffleHog, curl calling the GitHub API, an npm publish and first-time cloud hosts, all under one install.

What happened

GitHub says it was told of the attack on 14 September 2025. Socket and StepSecurity first reported @ctrl/tinycolor 4.1.1 and 4.1.2 and some 40 other packages; within a week the count passed 500, including packages published by CrowdStrike. Each infected version ran a 3.6 MB bundle.js from a postinstall hook, on macOS and Linux only.

The script dumped the environment, downloaded the open-source secret scanner TruffleHog from its GitHub releases and ran it over the whole file system, and tried the cloud metadata endpoints that hand out credentials on cloud machines. With AWS or Google Cloud credentials it listed and read the secrets in those accounts’ secret managers.

With an npm token, from ~/.npmrc or the environment, it checked the token with npm’s whoami endpoint, searched for the maintainer’s other packages, added itself to each with a patch version bump and published them. That is what made it a worm: one developer’s token infected every package they could publish.

With a GitHub token, it created a public repository named Shai-Hulud holding the collected data as double-base64 data.json, pushed a shai-hulud branch with a workflow, .github/workflows/shai-hulud-workflow.yml, to the repositories the token could reach, and in some cases copied private organisation repositories into public ones marked “Shai-Hulud Migration”. That workflow ran on GitHub and posted the repository’s secrets to a webhook.site address. Wiz found the webhook had been deactivated for excessive use, but the workflow also printed the secrets into its logs.

The second wave, uploaded between 21 and 23 November, used new files, setup_bun.js and bun_environment.js, ran in the preinstall phase, and kept running in the background on developer machines so that installs didn’t look slow. It published secrets to repositories with Shai-Hulud in their description, more than 25,000 of them by Wiz’s count, and registered infected machines as GitHub Actions self-hosted runners named SHA1HULUD, a way back in.

Timeline

  1. 14 Sep 2025

    GitHub is notified of a self-replicating worm in npm packages (GitHub).

  2. 15 Sep 2025

    Socket and StepSecurity report @ctrl/tinycolor and over 40 more packages.

  3. 16 Sep 2025

    Socket counts nearly 500 affected packages, including CrowdStrike’s; Aikido and others track it spreading.

  4. 22 Sep 2025

    GitHub has removed over 500 packages, makes npm block uploads carrying the worm’s indicators, and announces 2FA-only local publishing, 7-day granular tokens and trusted publishing.

  5. 23 Sep 2025

    CISA’s alert advises rotating developer credentials and blocking outbound connections to webhook.site.

  6. 21–23 Nov 2025

    The second wave’s packages are uploaded to npm (Wiz, Aikido).

  7. 24–26 Nov 2025

    Repositories with leaked secrets appear by the thousand; GitHub starts removing them and revoking leaked tokens on 26 November.

What it looked like on the network

On a developer’s Mac, almost everything went to hosts a developer uses all day. From registry.npmjs.org it fetched the infected package, then called /-/whoami and /v1/search, downloaded the maintainer’s other packages and published new versions: an upload from npm in the middle of an install. On api.github.com it read the account, created the Shai-Hulud repository and pushed branches and workflow files; StepSecurity’s deobfuscated code shows the backdoor module doing this with curl. TruffleHog came from GitHub’s release downloads.

With cloud credentials, it also reached the AWS Secrets Manager and Google Secret Manager APIs. It tried the metadata address 169[.]254[.]169[.]254 and metadata[.]google[.]internal, which only answer on cloud machines. The process tree ran from npm through a shell to node bundle.js, which started trufflehog, a bash script running curl, and npm publish.

The one unusual host, webhook[.]site, wasn’t contacted from the Mac at all. It sat in the workflow the worm pushed to GitHub, so the request came from a GitHub-hosted runner when the workflow ran.

Indicators

As the sources document them. Domains are text, never links, and are defanged ([.]) where the source defangs them. Most of this infrastructure is long gone: indicators help you check old logs, files and accounts, not block today’s campaigns.

IndicatorTypeWhere it showsNote
webhook[.]site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7URLGitHub Actions runnerWhere the injected workflow posted each repository’s secrets. Deactivated by the platform.
api.github.comHost (legitimate)Your MacCalls to /user and /user/repos, and pushes of branches and workflow files, made during an npm install.
registry.npmjs.org/-/whoami, /v1/search?text=maintainer:…npm API callsYour MacToken check and the search for the maintainer’s packages, followed by npm publish.
secretsmanager.*.amazonaws.com, secretmanager.googleapis.comCloud APIsYour Mac, CIOnly where cloud credentials were present.
46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09SHA-256 of bundle.jsnode_modulesThe September payload.
shai-hulud branch, .github/workflows/shai-hulud-workflow.ymlBranch, fileGitHub repositoriesThe workflow that sent secrets to the webhook and into the logs.
Shai-Hulud repository with data.json; “Shai-Hulud Migration”GitHub repositoriesGitHub accountCollected data, and private repositories copied public with a -migration suffix.
setup_bun.js, bun_environment.js, SHA1HULUDFiles, runner namenode_modules, GitHubThe November wave’s payload, and the self-hosted runner it registered.

What Kilobat would have shown or blocked

  • Agent network log. For an install an agent ran, the session would group the hosts by process: trufflehog starting under the install, curl calling api.github.com, npm sending to registry.npmjs.org, and, with cloud credentials on the Mac, AWS or Google API hosts flagged first time. Processes that carried on in the background after the install are followed, since processes handed over to launchd stay the agent’s.
  • App traffic. Even without an agent, Kilobat’s app list shows command-line tools that aren’t part of macOS with their executable path, so a trufflehog you never installed, or a node process uploading to GitHub, is visible while it runs.
  • Protection. Nothing on the Mac. npm, GitHub and the cloud providers aren’t threats, and the one host defenders were told to block was contacted from GitHub’s runners.
  • Agent guard. Its request-catcher rule names webhook.site and would refuse it for an agent and anything it starts, but here the request came from GitHub’s runners, never from a Mac. The metadata address is link-local, which Kilobat treats as the local network.

Where Kilobat wouldn’t have helped

  • The traffic that did the damage, on npm and GitHub, used the developer’s own valid tokens and looks like ordinary publishing and pushing.
  • The webhook exfiltration and the secrets printed into workflow logs happened on GitHub’s runners. Blocking webhook.site, as CISA advised, matters most where CI runs.
  • Kilobat records hosts and byte counts, never contents, so it can’t say what TruffleHog found or what a repository received.
  • Installs run in Docker containers, or by an editor’s extension host in VS Code, aren’t attributed to an agent.
  • Infected packages have since been removed and the 30-day agent log can’t reach back to 2025; what’s left to check is your lockfiles, your caches and your GitHub account.

Check your Mac now

  1. Check your lockfiles against the published package lists from Socket, StepSecurity and Wiz. For one package:

    npm ls @ctrl/tinycolor
  2. In a project folder, look for the payload files of either wave.

    find . -path "*/node_modules/*" \( -name bundle.js -o -name setup_bun.js -o -name bun_environment.js \) -print
  3. Compare any bundle.js with the September hash. A match is the worm; other bundle.js files are common and usually harmless.

    find . -path "*/node_modules/*" -name bundle.js -exec shasum -a 256 {} + | grep 46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09
  4. List your GitHub repositories whose name or description mentions Hulud.

    gh repo list --limit 1000 --json name,description --jq '.[] | select((.name + " " + (.description // "")) | test("hulud"; "i")) | .name'
  5. In each organisation’s and repository’s Settings › Actions › Runners, remove any self-hosted runner you didn’t set up, especially one named SHA1HULUD. On the Mac, if you don’t run a runner yourself, this should print nothing.

    pgrep -fl "Runner.Listener|Runner.Worker"
  6. If anything matched, rotate npm tokens, GitHub tokens and cloud keys, then list your npm tokens to delete old ones.

    npm token list
  7. To keep install scripts from running unless you ask, turn them off for npm and run them per project when needed.

    npm config set ignore-scripts true
  8. For the next one, read what each install an agent ran reached. Watch for uploads to the npm registry and GitHub API calls during an install.

    kilobat agent-log --all

Sources

Checked on 1 November 2026.

See what your agents and apps connect to

Kilobat is a Mac menu-bar app that names the app, process and agent behind every connection, keeps them away from known-bad hosts, and logs what each agent session reached.

Download for Mac ↓
brew install seifip/kilobat/kilobat