When the Sandworm Came for My Secrets: Lessons from Shai-Hulud 2.0

When the Sandworm Came for My Secrets: Lessons from Shai-Hulud 2.0

Russ McKendrick
Russ McKendrick 8 min read Suggest Changes

Earlier this week, I discovered that this very repository had fallen victim to Shai-Hulud 2.0 - a sophisticated supply chain attack that compromised npm packages and exfiltrated secrets from thousands of developers and CI/CD pipelines. My API keys for services like FAL.ai, OpenAI, Perplexity, and others were published to attacker-controlled repositories before I even knew what had happened.

This post covers what the attack is, how it works, exactly how I got caught, and the changes I’ve made to prevent this from happening again.

How I Found Out

On November 27th, I received an email from Omar Hammou, a security researcher who monitors GitHub for exposed secrets:

I have identified several leaked credentials associated with your accounts, including OpenAI and Perplexity tokens. I operate a GitHub monitoring system that continuously flags exposed secrets, and although some repositories were deleted, the leaked data is still present in GitHub’s historical snapshots.

The credentials had been exfiltrated to a repository at github.com/dp5iveme/zf0jxl7wqn14g9enav (since deleted by GitHub). As Omar explained, even when repositories are deleted, forks and clones preserve the repository state as archived snapshots - meaning the leaked data can persist indefinitely. Omar was kind enough to share the full payload that was captured - a JSON dump of my entire shell environment, including every environment variable that was set at the time of the attack.

What is Shai-Hulud 2.0?

Named after the giant sandworms from Frank Herbert’s Dune, Shai-Hulud 2.0 is an ongoing malware campaign that targets npm package maintainers. Attackers compromise maintainer accounts and publish trojanized versions of legitimate packages.

Play

According to Wiz’s analysis, the scale is staggering:

  • 25,000+ repositories affected
  • 350+ unique users compromised
  • 1,000 new repositories being created every 30 minutes during the initial outbreak
  • Packages from major companies including Zapier, ENS Domains, PostHog, and Postman were trojanized

How the Attack Works

The malware is deceptively simple but devastatingly effective:

  1. Preinstall Hook Execution: The malicious code runs during npm’s preinstall phase via scripts like setup_bun.js and bun_environment.js. This ensures execution before any code review or static analysis can occur.

  2. Credential Harvesting: The malware systematically steals:

    • Local configuration files (.npmrc, .env, cloud CLI configs)
    • Environment variables
    • Cloud metadata service tokens
    • Secrets from AWS Secrets Manager, Google Secret Manager, and Azure Key Vault
  3. Triple Base64 Encoding: Stolen data is encoded three times before exfiltration, evading simple detection rules.

  4. Exfiltration to GitHub: Credentials are published to attacker-controlled repositories with the description “Sha1-Hulud: The Second Coming.”

  5. Persistent Backdoor: The malware injects a discussion.yaml GitHub Actions workflow that registers infected machines as self-hosted runners named “SHA1HULUD”, enabling future remote command execution.

  6. Destructive Fallback: If credential exfiltration fails, the malware attempts to delete the victim’s entire home directory - converting espionage into sabotage.

The Smoking Gun: kill-port@2.0.3

Thanks to the exfiltrated payload Omar shared, I was able to identify exactly which package compromised my system. The evidence was right there in the environment variables:

The Smoking Gun
{
"npm_lifecycle_script": "node setup_bun.js",
"npm_lifecycle_event": "preinstall",
"npm_package_name": "kill-port",
"npm_package_version": "2.0.3",
"PWD": "/Users/russ.mckendrick/Code/blog/node_modules/kill-port"
}

The culprit was kill-port - a utility package for killing processes on specific ports. According to the GitLab Advisory (GMS-2025-498), versions 2.0.2 and 2.0.3 were compromised on November 24th, 2025. The vulnerability is rated HIGH severity (CVSS 8.6) and classified as CWE-506 (Embedded Malicious Code).

The npm_lifecycle_script value of node setup_bun.js is the exact malicious script identified in the Wiz security report - it’s designed to look innocuous (who wouldn’t trust something called “setup_bun.js” in 2025?), but its sole purpose is credential theft.

The package maintainer confirmed on GitHub that npm support has since removed the infected versions (2.0.2 and 2.0.3) from the registry. Version 2.0.1 is safe and does not contain the malicious files.

What Was Stolen

The payload captured my entire environment, including:

ServiceCredential Type
OpenAIAPI Key
PerplexityAPI Key
FAL.aiAPI Key
TavilyAPI Key
ExaAPI Key
Last.fmAPI Key

Plus my username, home directory path, shell configuration paths, and various other environment details that could be useful for further attacks.

Fortunately, I don’t store AWS, GCP, or Azure credentials in environment variables (they use CLI-based authentication), but many developers aren’t so lucky.

The Global Damage

According to Wiz’s preliminary analysis, attackers obtained:

Credential TypeCount
GitHub Access Tokens775
AWS Credentials373
GCP Credentials300
Azure Credentials115

These numbers only represent what was discovered in the attacker’s public repositories before GitHub took action.

What I’ve Changed

After rotating all my compromised credentials (a fun few hours, I must say), I’ve implemented several changes to reduce the attack surface:

Migrated from npm to pnpm

Beyond being faster and more disk-efficient, pnpm has better security defaults. I’ve updated all my GitHub Actions workflows to use pnpm with the --frozen-lockfile flag:

Github Action
- name: 🌸 Setup PNPM
uses: pnpm/action-setup@v4
- name: 📦 Install dependencies
run: pnpm install --frozen-lockfile

The --frozen-lockfile flag ensures CI/CD installs exactly what’s in the lockfile - no surprises from updated packages.

I’ve also added aliases to my .zshrc to prevent accidental npm usage locally:

Terminal window
# 📦 pnpm aliases in .zshrc
alias npm='echo "Error: Use pnpm"; false'
alias n="pnpm"
alias ni="pnpm install"

This ensures I can’t accidentally run npm install out of muscle memory.

Explicitly Approve Build Scripts

pnpm 10+ has a security feature that blocks lifecycle scripts by default, showing warnings like:

Ignore build scripts !!!
Ignored build scripts: esbuild, sharp, workerd.
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

Rather than blanket-allowing all scripts, you can explicitly whitelist only the packages you trust. I’ve added this to my package.json:

package.json
{
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp",
"workerd"
]
}
}

This means:

PackageScriptPurpose
sharpinstallDownloads native image processing binaries
esbuildpostinstallDownloads platform-specific bundler binary
workerdpostinstallDownloads Cloudflare Workers runtime

Any other package trying to run lifecycle scripts will be blocked. This is a much better approach than --ignore-scripts (which breaks legitimate packages) or allowing everything (which is what npm does by default).

Regular Security Audits

I’ve added pnpm audit to all my GitHub Actions workflows:

pnpm audit
- name: 🔒 Security audit
run: pnpm audit

This checks dependencies against the npm security advisory database and will fail the build if any known vulnerabilities are found. You can also run it locally:

Terminal window
pnpm audit

Pinned Dependencies

Rather than allowing automatic updates, I’m now pinning dependencies to specific versions and reviewing changes manually:

package.json
{
"dependencies": {
"astro": "5.1.1"
}
}

Auditing for Compromise

If you’re concerned your environment may have been affected, check for:

  1. Suspicious workflow files: Look in .github/workflows/ for shai-hulud-workflow.yml, discussion.yaml, or unexpected branches
  2. Self-hosted runner registrations: Check for runners named “SHA1HULUD” in your GitHub repository settings
  3. Unexpected npm packages: Review your package-lock.json or pnpm-lock.yaml for packages added between November 21-25, 2025

Credential Rotation

If you installed any npm packages during the attack window (November 21-25, 2025), you should:

  1. Clear your npm/pnpm cache: pnpm store prune
  2. Delete node_modules and reinstall
  3. Rotate all credentials that were accessible to your development environment
  4. Review GitHub for any unauthorized workflow changes

Lessons Learned

This attack is a stark reminder of several realities in modern development:

  1. Supply chain attacks are the new normal: We implicitly trust thousands of packages and their maintainers every time we run npm install.

  2. CI/CD environments are high-value targets: They often have access to production credentials, cloud resources, and deployment capabilities.

  3. Lifecycle scripts are a security risk: The ability for packages to execute arbitrary code during installation is powerful but dangerous.

  4. Defense in depth matters: No single measure would have prevented this, but layers of security (restricted CI/CD permissions, pinned dependencies, disabled scripts) reduce the blast radius.

  5. The security community is watching: I’m grateful to researchers like Omar who actively monitor for exposed secrets and alert affected developers. Without his email, I might not have known for weeks.

Acknowledgments

Thanks to Omar Hammou for the heads-up and for sharing the exfiltrated payload so I could identify the attack vector. Security researchers who responsibly disclose findings like this are invaluable to the community.

Further Reading

Stay vigilant out there !!!

Share

Related Posts

Comments