Group
← Back

npm install and pray. Axios and TanStack, 2026.

May 22, 2026SecurityDevOpsSystems

Every time you run npm install, or open VSCode for that matter, you are trusting code you have never read. Not just the package you asked for, but the entire tree it brings with it. A typical Next.js project has three or four hundred transitive dependencies. A typical Node.js service is similar. The combined line count of that code exceeds what most developers would read in a year, written by maintainers you will never meet, running on your machine with your filesystem permissions and your credentials in scope.

We extend trust to the npm registry the way we extend it to a grocery store: we assume the product is what the label says. Most of the time that assumption holds. When it does not, it tends to be problematic.

2026 has been a rough year for software supply chains, and we are not halfway through it. The European Commission lost hundreds of gigabytes of data from 29 AWS environments. Aqua Security's Trivy scanner was compromised. The Bitwarden CLI npm package was briefly poisoned. Two attacks in this stretch stand out not just for scale but for what they reveal about the limits of standard mitigations: the Axios compromise of March 31, attributed to Sapphire Sleet (a North Korean state actor), and the TanStack compromise of May 11, carried out by a worm named Mini Shai-Hulud, attributed to TeamPCP.

Both attacks used the npm install lifecycle as their entry point. Beyond that they worked differently, came from different groups, and illuminate different failure modes.

The Axios attack

Date: March 31, 2026
Attribution: Sapphire Sleet, a North Korean state-sponsored group
Affected versions: axios@1.14.1, axios@0.30.4
Safe versions: axios@1.14.0, axios@0.30.3
Window: approximately three hours

Axios has roughly 83 million weekly downloads. The attackers did not find a bug in it. They targeted it because getting access to one person's publishing credentials was enough.

Over two weeks the attackers posed as a business contact, set up a fake company identity, ran several meetings to build trust, and eventually got the lead maintainer, Jason Saayman, on a call where they prompted him to install what looked like a missing component for the meeting software. That was the malware. With it running on his machine they had everything they needed to publish on his behalf.

The malicious Axios versions looked nearly identical to the real ones. The only change was a single added dependency, a package called plain-crypto-js that had been quietly published the day before. When anyone ran npm install, that dependency's setup script ran automatically in the background, downloaded a remote payload, installed it silently, then erased its own tracks. The attacker's server went offline shortly after; the malicious versions were live for about three hours before being removed.

The TanStack attack: Mini Shai-Hulud

Date: May 11, 2026
Attribution: TeamPCP, a financially motivated group
Affected packages: 84 versions across 42 @tanstack/* packages
CVE: CVE-2026-45321
Window: 20 to 26 minutes before external detection

No one's credentials were stolen. No one was tricked into clicking anything. TanStack's team had all the right security practices in place. None of it helped.

The attackers found three configuration mistakes in the way TanStack's automated publishing pipeline was set up, all of them documented and publicly known. Chaining them together let outsiders submit a code contribution that the system treated as trusted, use that foothold to tamper with a shared internal cache, and then read a short-lived publishing credential out of the build system's memory while a release was in progress. With that credential they published 84 malicious package versions in about six minutes. The packages passed every security check npm and GitHub provide; they even carried cryptographic certificates proving they were built from TanStack's own infrastructure. Because they were.

Once installed, the malware collected credentials from cloud services, code platforms, and secret managers, then tried to embed itself so it would keep running: writing hooks into editors and developer tools, launching a background process that watched for any attempt to revoke access. It also spread; within hours it had reached Mistral AI, UiPath, and over 160 other packages. It was only caught because it accidentally broke the TanStack test suite. An external researcher at StepSecurity noticed twenty minutes after the malicious versions went up.

How to check

The commands below are packaged in npm-supply-chain.sh:

curl -fsSL https://gist.githubusercontent.com/valentinradu/0a4dca318d197c8c6f091b4a4e4b9b3c/raw/npm-supply-chain.sh | bash -s check
curl -fsSL https://gist.githubusercontent.com/valentinradu/0a4dca318d197c8c6f091b4a4e4b9b3c/raw/npm-supply-chain.sh | bash -s harden

The check command scans your npm dependency tree and package-lock.json for axios@1.14.1, axios@0.30.4, and plain-crypto-js@4.2.1; checks ~/.claude/hooks/, .vscode/tasks.json, and running processes for Mini Shai-Hulud artifacts; and scans uv and pipx virtual environments. The harden command applies the ~/.npmrc settings below.

If you think you were exposed, assume every credential reachable from that machine is compromised and rotate immediately: npm publish tokens, GitHub personal access tokens, AWS access keys and IAM roles, GCP service accounts, Azure service principals, SSH keys, HashiCorp Vault tokens, and Kubernetes service account tokens. Then delete node_modules, pin to a known-safe version, and reinstall with npm install --ignore-scripts.

How to harden

Add to ~/.npmrc:

ignore-scripts=true
min-release-age=7

ignore-scripts=true prevents postinstall hooks from running. This alone would have blocked the Axios attack entirely and prevents Mini Shai-Hulud from executing during install. Packages requiring native compilation via node-gyp will break; override per-project with --ignore-scripts=false. min-release-age=7 refuses packages published fewer than seven days ago, which catches the pattern both attacks used: malicious versions published in a rush and pulled within hours.

For dependency hygiene: pin exact versions in package.json (replace ^ and ~), commit package-lock.json as source, run npm audit in CI.

Python (pip and uv)

For a global release cooldown with uv, add to ~/.config/uv/uv.toml:

exclude-newer = "7 days"

The closest equivalent to ignore-scripts=true is --only-binary :all: in both pip and uv, which forces installation from pre-built wheels and skips all build scripts. The coverage at install time is actually broader than npm's flag: no build code runs at all, but import-time code still executes when your application uses the package. Setting it globally can break packages that publish source distributions only; apply it per environment when needed.

For lockfile discipline, use uv sync --frozen (refuses to install if the lockfile does not match exactly, analogous to npm ci). Audit with pip-audit or uv pip audit.

For GitHub Actions

The Mini Shai-Hulud chain has three specific fixes. Never check out fork code inside a pull_request_target workflow; use a sandboxed pull_request job to run code and a separate pull_request_target job (no code checkout) to post results. Add permissions: id-token: none to every workflow that does not publish. Pin all third-party actions to commit SHAs, not mutable tags. And separate cache keys between fork PR workflows and main-branch workflows; the shared namespace was the bridge the attacker crossed.

Credentials at rest

Both attacks went straight for plaintext credentials on disk: ~/.aws/credentials, npm tokens, GitHub tokens, SSH keys. A process that can read the filesystem walks away with them. Encrypting them at rest means it gets ciphertext instead.

age is a modern, composable file encryption tool. You can use it with an SSH key as the recipient directly, or tie the age identity to your GPG key so decryption requires your passphrase:

# Encrypt a credential file to your SSH public key
age -R ~/.ssh/id_ed25519.pub ~/.aws/credentials > ~/.aws/credentials.age
rm ~/.aws/credentials

# Decrypt into a subshell when needed, never touching disk
export $(age -d -i ~/.ssh/id_ed25519 ~/.aws/credentials.age | xargs)

On Linux, systemd-creds binds secrets to the host's TPM2 chip instead of a key file, so the plaintext can only be recovered on the same machine:

# Encrypt, bound to this host
systemd-creds encrypt --with-key=host ~/.aws/credentials ~/.aws/credentials.cred
rm ~/.aws/credentials

# Decrypt
systemd-creds decrypt ~/.aws/credentials.cred - | xargs -d '\n' -I{} export {}

Neither approach stops a compromised process from using credentials that are already loaded into the environment. The goal is narrowing the window: credentials exist in plaintext only for the duration of the process that needs them, not permanently on disk where any filesystem read gets them for free.

On the developer machine

Two adjustments that reduce the surface on your own workstation.

The first is memory locking. Tools that handle secrets (gpg-agent, ssh-agent, most credential helpers) can lock their key material into RAM via mlock, preventing it from being paged out to the swap partition where it would persist on disk. Check and raise the locked-memory limit if needed:

# Check current limit (in KB; "unlimited" is ideal for credential tooling)
ulimit -l

# Raise it permanently in /etc/security/limits.conf:
# * - memlock unlimited

More directly relevant to the TanStack attack: Mini Shai-Hulud extracted OIDC tokens by reading the GitHub Actions runner's memory via /proc/<pid>/mem. On Linux, the Yama ptrace scope governs which processes are allowed to do this. Setting it to 1 restricts memory inspection to parent-child relationships, which stops opportunistic reads by unrelated processes without breaking debuggers:

# Temporary
sudo sysctl kernel.yama.ptrace_scope=1

# Permanent
echo "kernel.yama.ptrace_scope = 1" | sudo tee /etc/sysctl.d/99-ptrace.conf

The second is terminal history. Shell history files are plaintext, readable by any process with your filesystem permissions. When working with credentials (exporting tokens, running authentication scripts, debugging cloud access), start a Fish private session first:

fish -P

Private mode disables history for the session. Tokens you export, commands you run, anything typed stays out of ~/.local/share/fish/fish_history. Make it a habit for any terminal work involving secrets.

Everything above is a floor, not a ceiling. These measures address the specific vectors used in the two attacks described here. A determined adversary, or the next attack using techniques not covered, will find gaps. Proper defense also involves network egress monitoring, runtime security tooling, formal dependency review for anything handling sensitive data, and regular credential rotation as policy rather than incident response. Start here, but do not treat this list as complete.

Testing Pent against both attacks

Pent uses two kernel-level controls on Linux: Landlock LSM (Linux Security Module) for filesystem isolation, and network namespaces with a domain-allowlist proxy for network isolation. The @npm profile allows only registry.npmjs.org on the network and ~/.npm on the filesystem.

yay -S pent
pent config add --global @npm @pip
pent run -- npm install

I ran npm install for the compromised Axios version through Pent and watched the dropper's outbound call to sfrclak[.]com:8000 come back with NXDOMAIN: the domain isn't in the allowlist, so the DNS proxy swallowed it. The RAT never arrived. I also verified that any read attempt against ~/.ssh or ~/.aws inside the sandbox returns EACCES from Landlock, so an embedded payload would also have had nothing to steal. Two independent controls, and either one alone was enough.

Testing against the Mini Shai-Hulud install phase, I confirmed each persistence path failed: the write to ~/.claude/hooks/ was blocked by Landlock, the write to .vscode/tasks.json the same, the outbound call to the Session P2P network was dropped by the namespace, and 169.254.169.254 was unreachable. The worm executed but couldn't persist and couldn't exfiltrate anything.

The gap showed up when I ran the app outside the sandbox. npm install finishes, the sandboxed process exits, the worm dies with it. But router_init.js is still in node_modules. Running npm run dev outside Pent gave the worm unrestricted access: it wrote the Claude Code hook, read ~/.ssh, reached out to the Session P2P network. Every subsequent Claude Code launch would have fired the hook. A sandboxed install followed by an unsandboxed runtime is a half-measure; for meaningful coverage every Node.js execution needs to go through the sandbox:

pent run -- npm install
pent run -- npm run dev
pent run -- node server.js

Summary

AttributeAxios (March 31)Mini Shai-Hulud / TanStack (May 11)
GroupSapphire Sleet (North Korean state actor)TeamPCP (financial, no state affiliation)
EntryMaintainer account hijack via RATGitHub Actions chain (3 public techniques)
MechanismPostinstall hook → C2 → cross-platform RATCache poisoning → OIDC extraction → direct npm publish
ProvenanceIllegitimate (compromised account)Valid SLSA provenance, indistinguishable from real
PersistenceRAT beaconing from %PROGRAMDATA%\wt.exe.claude/ hooks, .vscode/tasks.json, daemon
Exfiltrationsfrclak[.]com (HTTP)Session P2P (onion-routed), 83.142.209[.]194
Stopped by ignore-scripts=trueYesYes (on developer machine)
Stopped by Pent @npmYesYes (install phase); not if runtime runs outside Pent
CVECVE-2026-45321 (CVSS 9.6)

Sources