Shai-Hulud - npm Phishing → TruffleHog Sweep of the Developer's Machine → Self-Replication Across 500+ Packages → Secrets Committed to Public Repos
Detected on September 15, 2025, Shai-Hulud was the first genuine worm in the npm ecosystem: malware that spread from maintainer to maintainer with no attacker involvement after the first push. It began with a credential-harvesting phishing campaign spoofing npm and targeting MFA-update pages. Once it held a maintainer's publish token, a 3.6MB bundled payload running as a postinstall script downloaded TruffleHog - a legitimate secret-scanning tool - and turned it on the developer's own machine, harvesting npm tokens, GitHub personal access tokens, AWS, GCP and Azure keys, and SSH keys. Then came the part that made it a worm: it authenticated to npm as that maintainer, queried the registry for up to twenty other packages they owned, injected itself into each, and force-published. It also created a public GitHub repository named "Shai-Hulud" in the victim's own account and committed the stolen secrets into it. More than 500 packages were compromised, starting with @ctrl/tinycolor at over two million weekly downloads. CISA issued an alert on September 23.
The pretext was well chosen. A message telling a maintainer that npm requires them to update their multi-factor authentication settings is not obviously suspicious, arrives in a plausible register, and drives the victim to a page where entering both a password and a live one-time code is exactly what the workflow appears to call for. That is the specific weakness of time-based one-time codes: a real-time relay defeats them, because the attacker is standing between the victim and the genuine site and using the code within its validity window. The same technique had hit the debug and chalk maintainers earlier in September 2025, so this was a proven approach against this exact community before Shai-Hulud used it.
Captured: Credentials plus a live TOTP code, relayed in real time
Why TOTP fails here: A real-time relay uses the code inside its validity window
Precedent: The
debug and chalk maintainer phishing earlier in September 2025Control that breaks it: Phishing-resistant FIDO2 or passkeys, which are origin-bound
bundle.js, a 3.6MB webpack-bundled payload executed by a postinstall script on every installThe first packages hit included @ctrl/tinycolor, which serves over two million downloads a week, alongside more than forty others spanning several maintainers. The payload was a single minified webpack bundle of roughly 3.6MB, internally modular across reconnaissance, credential harvesting and propagation. Size is worth noticing as a detection signal in its own right: a colour-manipulation utility does not need a multi-megabyte bundle, and a sudden order-of-magnitude jump in package size between patch versions is a cheap, high-signal check that almost nobody runs. Because it executed as a postinstall script, installation alone was sufficient - the package never had to be imported or called.
@ctrl/tinycolor (2M+ weekly downloads) and 40+ others across several maintainersPayload:
bundle.js, ~3.6MB minified webpack bundle, modular internallyTrigger: postinstall script - installing was enough (2.0 later moved to preinstall)
Cheap detection nobody runs: Package size jumping an order of magnitude between patch versions
Eventual scale: 500+ packages
TruffleHog exists so defenders can find secrets accidentally committed to their repositories. Shai-Hulud downloaded it and pointed it at the victim. This is living off the land applied to the security toolchain, and it is efficient for the same reason the tool is useful: someone has already done the hard work of enumerating what every cloud provider's credential format looks like and where it tends to be stored. It also frustrates detection, because a widely used open-source binary with a legitimate purpose is a poor signature. The haul was comprehensive - npm tokens from .npmrc, GitHub personal access tokens, AWS, GCP and Azure keys, and SSH private keys usable for lateral movement into networks the developer could reach.
Harvested: npm tokens from
.npmrc, GitHub PATs, AWS/GCP/Azure keys, SSH private keysWhy it is effective: The tool already knows every credential format and where they live
Why it evades detection: A legitimate, widely used binary makes a poor signature
Detection angle: Unexpected download and execution of security tooling by a build or install process
Every earlier npm compromise on this site required a human at each hop: someone had to steal a token, decide what to publish, and push it. Shai-Hulud removed the human. Using the token it had just harvested, the malware authenticated to npm as that maintainer, asked the registry which packages they owned, took up to twenty of them, injected its own payload, and published. Each of those packages then infected the next developer who installed it, whose tokens produced the next twenty packages, and so on. Growth is compounding rather than linear, and the propagation speed is bounded only by how often people run npm install - which, in a modern CI-driven ecosystem, is constantly. Reaching 500+ packages did not require 500 attacker decisions. It required one.
Selection: Query the registry for packages owned by that account, take up to 20
Action: Inject the payload and force-publish new versions
Attacker involvement per hop: None
Growth: Compounding, bounded only by how often the ecosystem installs
.github/workflows/shai-hulud-workflow.yml triggered on push and exfiltrated the entire secrets context using ${{ toJSON(secrets) }}Where the malware could reach a repository, it wrote a GitHub Actions workflow carrying a base64-encoded bash payload, triggered on push. The expression ${{ toJSON(secrets) }} serializes the job's entire secrets context in one go, so the workflow did not need to know what any particular repository's secrets were called - it took all of them and shipped them to command and control. This is durable in a way the package payload is not: removing the malicious dependency does nothing to a workflow file sitting in the repository, which will keep firing on every push by whoever commits next. Any response that stopped at "downgrade the package" left this running.
.github/workflows/shai-hulud-workflow.yml, carrying base64-encoded bashTrigger:
pushPayload expression:
${{ toJSON(secrets) }} - the whole secrets context, whatever it containsWhy it survives cleanup: Removing the npm package does not remove a committed workflow file
Hunt: Search every repository for that filename and for
toJSON(secrets) in any workflow
Using the stolen GitHub token and the /user/repos endpoint, the malware created a public repository in the victim's own account and committed the harvested secrets into it as JSON. As with Nx / s1ngularity the same month, the destination is GitHub itself, so there is no attacker infrastructure to blocklist and no unusual egress to notice. And as there, the repositories were public, which converted a theft into a broadcast: every secret was readable by anyone who found the repo, including the many automated scrapers that watch GitHub for exactly this. Some variants also exfiltrated to a webhook.site endpoint. Two independent groups of npm incidents in one month settling on the same trick is worth reading as a pattern rather than a coincidence.
/user/repos API with the stolen token, creating a public repoRepo name: "Shai-Hulud" (2.0 used "Sha1-Hulud: The Second Coming")
Content: Harvested credentials serialized as JSON
Secondary channel: A
webhook.site endpoint in some variantsConsequence: World-readable secrets, exposed to every credential scraper watching GitHub
CISA published an alert on the npm ecosystem compromise on September 23, 2025, which is the correct signal for a supply-chain event of this shape: the affected population is everyone who installed, not everyone who was targeted. The lineage since is the reason this entry matters more than its own package count. In November 2025 the worm returned as Shai-Hulud 2.0, or "Sha1-Hulud: The Second Coming", moving execution to preinstall, hitting roughly 796 packages, spawning more than 25,000 malicious repositories across about 350 accounts, and adding a discussion.yaml workflow that registered infected machines as self-hosted runners for arbitrary command execution. In 2026 the technique appeared again in Mini Shai-Hulud / TanStack, which needed no stolen token at all - it poisoned the Actions cache and scraped OIDC from runner memory, publishing packages with valid SLSA provenance. Each generation needed less stolen from the maintainer than the last.
November 2025: Shai-Hulud 2.0 - preinstall execution, ~796 packages, 25,000+ repos across ~350 accounts
2.0 addition:
discussion.yaml registering infected machines as self-hosted runners2026: Mini Shai-Hulud / TanStack - no stolen token, cache poisoning and OIDC theft instead
Trajectory: Each generation required less stolen from the maintainer than the last
🛡 How to Defend Against This Chain
npm install --ignore-scripts in CI and on developer machines neutralizes it without knowing anything about this specific worm. Pair that with a lockfile whose integrity hashes are enforced, so a force-published version cannot silently enter your build, and add a delay policy - not installing anything published in the last 24 to 48 hours would have skipped most of this worm's usable window..github/workflows/shai-hulud-workflow.yml, for any workflow containing toJSON(secrets), and for public repositories named "Shai-Hulud" or "Sha1-Hulud" in your organization and your developers' personal accounts. For the 2.0 wave, check for discussion.yaml and for unexpected self-hosted runners registered against your repositories. Downgrading a package removes none of these.