Shai-Hulud npm Attack: keyv, Affected Versions, What to Do

php2shell patches (CVE-2026-109618 and CVE-2026-109619) for EOL PHP are out now. With sales replies slower than usual due to high demand, the fastest route to patched packages is to buy ELS for Runtimes online (opens in new tab). Read the patch FAQ

Go straight to an AI summary of this blog post instead

  • Summarize this post with Perplexity
  • Summarize this post with Gemini

The Short Version

The Shai-Hulud npm attack returned on 4 August 2026, entering the registry through keyv and the cacheable family of caching libraries, then spreading on its own to hundreds of other packages. It is a self-replicating credential stealer. Researchers who tore it apart found the campaign identifying itself in GitHub repository descriptions as “Shai-Hulud: Here We Go Again,” which puts it in the same lineage as the npm supply chain attacks of September 2025 and the Shai-Hulud and Megalodon campaigns we analyzed in June.

The counts vary by who was measuring and when. JFrog documented more than 400 packages across more than 1,700 versions. CyberScoop reported the initial wave passing 440 packages in under four hours, and the eventual total exceeding 860 packages carrying a combined two billion monthly installs. Take it as more than 400 packages, and by some counts more than 800.

The number worth sitting with is not the package count. It is the four hours. One maintainer account fell, and inside a single afternoon the compromise had propagated through hundreds of unrelated projects owned by people who had done nothing wrong and had no way to know.

What makes this campaign useful to study is that it walked through two completely different doors, and most of the industry’s defensive tooling is pointed at neither of them. Getting in required compromising a maintainer and putting malicious code in a source tree. Spreading did not require writing any code at all. It required only the ability to publish.

Those are two different problems. They need two different answers.

Am I Affected by the Shai-Hulud npm Attack?

Start here before reading anything else.

You have potential exposure if you installed, or your CI installed, any version of keyv, cacheable, cacheable-request, cache-manager, flat-cache, file-entry-cache, or anything under the @cacheable scope on or after 4 August 2026. The poisoned releases documented so far are keyv 6.0.0, cacheable 2.5.1, @cacheable/memory 2.2.1, @cacheable/net 2.1.1, @cacheable/node-cache 3.1.2 and @cacheable/utils 2.5.1 from JFrog’s analysis, with cache-manager 7.2.10 and cacheable-request 13.0.20 confirmed by Wiz. CyberScoop reports flat-cache and file-entry-cache following within roughly thirty minutes of the first release. Check what your own lockfile resolved, since every published list was still growing as it went out.

You have exposure through the second wave if you consume packages under the @onereach, @or-sdk, @nebula.js, @qlik, @ornikar or @servicetitan scopes, or any of the several hundred other packages whose maintainers had npm tokens sitting on a machine the worm reached. Treat any npm install performed on 4 August or after as suspect until you have checked what it actually resolved.

One detail narrows the blast radius considerably. The payload ran from an npm preinstall lifecycle hook, and on npm 12 and later those hooks do not run by default. If your builders and developer machines are on npm 12 or newer under its default script policy, or you install with --ignore-scripts, the dropper would not have executed even if the poisoned tarball landed in node_modules, unless lifecycle scripts had been explicitly re-enabled for these packages. JFrog makes this point explicitly, and it is the single most useful piece of good news in the incident.

One detail widens it. keyv and flat-cache are not packages most teams choose. They arrive as transitive dependencies of ESLint and of HTTP client libraries, several layers down, in projects that have no idea they are there.

That is why this one reached deeper than its predecessors. Wiz telemetry cited by CyberScoop found the most prevalent of the compromised packages present in around 46 percent of the cloud environments they can see, against roughly 28 percent for the earlier Shai-Hulud 2.0 wave. The Shai-Hulud npm attack got its reach by entering through infrastructure nobody thinks about.

How the Shai-Hulud npm Attack Unfolded

The entry point was a maintainer’s GitHub account. Wiz describes the keyv and cacheable packages being compromised “following the compromise of a GitHub maintainer account, resulting in the publication of malicious package versions,” and sets out the order of events: the attacker first used the compromised identity to introduce IDE persistence payloads into the keyv repository itself, then shortly afterwards published a new version of keyv carrying the payload.

That order matters. The code went into the project before it went onto the registry, so the release that reached npm came out of the project’s own publishing process rather than around it. As far as that process could tell, an authorized identity had put code in the repository and cut a release. That is what it exists to do.

Two files went into each package. The first, setup.mjs, is a small obfuscated dropper wired into package.json as "preinstall": "node setup.mjs". It does not carry the payload. It downloads the Bun JavaScript runtime, unpacks it, and uses it to execute the second file: roughly 710 to 728 kilobytes of heavily obfuscated JavaScript, named math_init.js in some packages and Math_Symbol.js in others.

Reaching for Bun rather than the already-present Node runtime is a deliberate choice. It gives the payload a predictable execution environment regardless of the victim’s Node version, and it puts the real work in a process the package manager is not watching.

The first door: a poisoned source tree

For keyv and the cacheable packages, the malicious code existed in the upstream Git repository. That matters more than it sounds like it should.

It means the compromise survives anything built on the assumption that source is trustworthy and only the published artifact is suspect. Rebuild from source, and you rebuild the backdoor. Verify the artifact against the source, and it verifies. Check the provenance attestation, and it is genuine, because the build really did happen in the workflow it claims, from the commit it claims. The attestation binds the artifact to a source and a build process, and both were real; the source was simply already poisoned.

The campaign took that reasoning to its end point against a different target. JFrog found special-case logic aimed at the OpenSearch opensearch-js repository, which uses GitHub Actions OIDC for trusted publishing: inside a workflow run whose repository was opensearch-js and whose GITHUB_WORKFLOW_REF matched release-drafter.yml, the payload requested an Actions OIDC token scoped to npm:registry.npmjs.org, exchanged it for a publish token, published a malicious tarball, and generated a Sigstore bundle through Fulcio and Rekor so the result carried valid provenance from a trusted workflow context.

The most reliable defense against this door is somebody or something reading the diff.

The second door: 400 packages nobody wrote code for

Here is where the campaign got its scale, and where the mechanics differ from the entry point in a way that changes what defends against them.

The payload harvests npm tokens from ~/.npmrc and validates them against the registry. When it finds a token that has write permission and bypass_2fa set to true, it does the following, quoting JFrog’s description of the routine: it downloads the current latest tarball, copies the running sample to package/math_init.js, writes the loader to package/setup.mjs, replaces the package’s complete scripts object and sets scripts.preinstall to node setup.mjs, increments the patch version, then repackages and publishes with a direct authenticated PUT to the npm registry.

Read that sequence again and notice what is absent. No commit. No pull request. No CI run. No repository access of any kind. The source tree of every one of those several hundred secondary packages was clean the whole time and still is, because the malicious code existed only inside a tarball assembled on the attacker’s machine and pushed straight into the registry with a stolen credential.

That is the majority of this incident by package count: hundreds of maintainers whose repositories were never touched, whose code review was never bypassed, whose CI never ran anything unusual, and whose users installed malware anyway.

What the Payload Took

The credential collector is thorough in a way that suggests a list assembled over multiple campaigns rather than written for this one. It walks hundreds of configured paths across Linux, macOS and Windows, taking package manager tokens, cloud credentials from config files and instance metadata services, Kubernetes service account tokens, the whole HashiCorp Vault KV store, SSH keys, VPN profiles, browser credential stores, Stripe and Slack tokens, and keys for AI services including OpenAI, Anthropic, Cursor and Gemini.

On GitHub Actions runners it also dumps process memory for secrets the runner has already decrypted. Underneath all of it sits a sweep across more than 200 glob patterns hunting .env files, private keys, Terraform state and KeePass databases.

Encrypted results go into public GitHub repositories created under the victim’s own account, with descriptions matching the “Shai-Hulud: Here We Go Again” pattern. Ox Security counted more than 800 such repositories discoverable on GitHub. A fallback HTTP channel points at a domain registered on 22 May 2026, more than two months before it was used.

Ox Security also reports a sabotage mechanism the other writeups do not describe: if the stolen GitHub token is revoked, the malware issues deletion commands against the infected machine. Treat that as reported by one vendor rather than independently confirmed, but factor it into how you sequence remediation. Revoking credentials before you have isolated the host may not be free.

It also went after your editor

The GitHub side of the propagation targets developer machines rather than the registry, and it is the part most likely to still be sitting unnoticed in a repository somewhere.

Using stolen GitHub personal access tokens, the payload commits five files across up to 50 branches per repository: .vscode/tasks.json, .vscode/setup.mjs, .claude/math_init.js, .claude/settings.json and .claude/setup.mjs. The tasks.json entry defines a task named “Environment Setup” that runs node .claude/setup.mjs, and the settings.json entry registers a SessionStart hook. Execution happens when a developer opens the repository in VS Code or starts an AI coding session.

The commits are titled chore: update config and carry a forged co-author line attributing them to [email protected]. Where the stolen token also carries workflow scope, the payload writes a GitHub Actions workflow that exfiltrates Actions secrets by packing them into an artifact and downloading it, on a branch named dependabot/github_actions/format/setup-formatter.

Every one of those names is chosen to survive a skim. A branch that looks like Dependabot, a workflow that looks like CodeQL, a commit that looks like config housekeeping, an artifact that looks like a formatter, and a co-author line that looks like an AI assistant a lot of teams now have committing to their repositories legitimately.

Why Traditional Scanners Missed the Shai-Hulud npm Attack

The tooling most organizations rely on was built to answer a question this attack does not ask.

Vulnerability scanners compare your dependency tree against databases of known CVEs. On 4 August there was no CVE for keyv 6.0.0, so there was nothing to match. By the time advisories existed, the four-hour window had closed and the worm had moved on.

This is not a criticism of the scanners. Known-vulnerability detection is a different job, and it is one they do well.

Registry-side account controls also did what they were designed to do. Two-factor authentication protects a publishing account against someone who has only a password. It does not protect against a token minted by that account for automation, and the worm specifically looked for tokens flagged to bypass 2FA precisely because those are the tokens that work without a human present. Many automated npm publishing setups have historically relied on exactly that kind of credential, a token that can publish without a human present.

Provenance and signing are the hardest case, because in this campaign they worked exactly as specified and produced a green result anyway. The OpenSearch attempt is the clearest example: a malicious tarball published through a genuinely trusted workflow, with a Sigstore bundle minted through Fulcio and Rekor to match.

Provenance answers “did this artifact come from the source and pipeline it claims,” and the answer was yes. That question is worth answering and does not become useless here. But a green check tells you where an artifact came from, not whether the thing that came from there is safe.

Reachability analysis has the same shape of gap. Establishing that a vulnerable code path is never called is useful for prioritizing CVE work. A preinstall hook does not need to be reachable from your application. It runs as part of installing the dependency, before your application ever calls into it.

Two Doors Need Two Different Locks

The two entry paths in this campaign are defeated by entirely different things, so it is worth being precise about which TuxCare mechanism closes which door.

SecureChain for npm delivers open source components that TuxCare rebuilds from source in its own build infrastructure and publishes to its own catalogue, with provenance attestations and a CycloneDX SBOM alongside each artifact. Customers point their package manager at SecureChain instead of at the public registry, so TuxCare becomes the default place their builds resolve from.

There are two ways to run that. Consume only what is in the SecureChain catalogue, or put a registry manager in front of it so anything not yet in the catalogue falls through to the public registry.

The Second Door Closes on Publishing Rights

Take the propagation mechanism first, because it is the larger half of the incident and the cleaner story. The worm’s method was to fetch a published tarball, inject two files, replace the scripts block, bump the patch version and push the result to the registry with a stolen token. That attack requires one thing above all others: the ability to put a new version of a package into the place where your resolver will look for it.

For any component in the SecureChain catalogue, the version your build resolves is the one TuxCare built from that project’s source in its own CI. The attacker’s republished tarball is not in that path, and no amount of publishing to the public registry puts it there. The malicious files in those several hundred secondary packages existed only inside a tarball pushed to npm. They were never in the source. A build from source does not reproduce them, and there is nothing to detect because there is nothing there.

The mechanism is worth stating plainly. The technique that carried this campaign to scale needs publishing access to whatever your build resolves from. If that is the public registry, a stolen token is enough. If it is the SecureChain catalogue, a stolen npm token buys the attacker nothing, because publishing to npm does not put a version in front of your build.

The First Door Closes on Reading the Diff

The entry point is a harder problem, and it is the one the industry has mostly been avoiding, because the honest answer is that somebody has to look at the code.

SecureChain runs malicious-code detection in its package onboarding pipeline. The detector reviews the source-code diff between a new package version and the version before it, looking for behavioral patterns associated with supply chain compromise: suspicious build-time execution, obfuscation, data exfiltration and other indicators of malicious intent. Build-time execution hooks are explicitly in scope, which covers npm preinstall and postinstall, Python setup.py execution paths and Maven plugin invocations.

A flagged version is held out of release. It does not enter the catalogue, which means it never reaches a customer’s build in the first place.

Consider what that detector would have been looking at on 4 August. The diff between the last clean keyv and keyv 6.0.0 contains a new file called setup.mjs, a second new file of roughly 710 kilobytes of heavily obfuscated JavaScript, and a preinstall entry appearing in package.json where a caching library previously had none.

Every one of those is a signal the detector is built to read: obfuscated content appearing in a source tree, a build-time execution hook arriving where there was none, and both landing in the same release of a library whose entire job is to store key-value pairs. The detector keys on what a change does rather than on matching known-bad files, which is why repackaging the same technique under different filenames does not get it past the gate.

Source-level detection is the SecureChain mechanism that directly addresses the first door. Rebuilding from source does not help when the source is what was poisoned. That was the argument we made about provenance in June, and this campaign did not change it. It sharpened it, by showing both failure modes in the same afternoon.

Mitigation Without SecureChain

If your builds resolve straight from the public registry, these are the controls that would have limited this campaign, in the order they pay off.

Get to npm 12 or later on every developer machine and every CI runner. The preinstall default change is the difference between a poisoned tarball sitting in node_modules and a poisoned tarball that executed. If you cannot upgrade yet, install with --ignore-scripts and handle the packages that genuinely need build steps as named exceptions.

Set a release-age floor so your builds never install a version the day it appears. All four major package managers ship this natively now: npm has min-release-age from 11.10.0, bun has minimumReleaseAge from 1.3.0, and pnpm and yarn both have equivalents with per-package exclusions. Three to seven days costs you very little and would have covered the entire four-hour window of this campaign. If you take one configuration change away from this post, that is the one.

Audit npm tokens for write permission combined with 2FA bypass, which is the exact combination the worm hunted. Publishing tokens should be short-lived, scoped to the packages that need them, and issued to automation rather than to people. Prefer trusted publishing where the workflow supports it, while understanding from the OpenSearch case that it moves the trust boundary onto your workflow definitions rather than removing it.

Treat CI runners as production systems that handle secrets, because this payload dumped runner process memory. Scope OIDC trust policies to specific repositories and specific workflow files rather than to an entire organization, and require review on changes under .github/workflows.

Review what your editor and your AI coding tools execute automatically from a repository. The .vscode/tasks.json and .claude/settings.json vector works because opening a project runs configuration that came with the project. Treat those files as executable content in code review, because they are.

Get SecureChain →

Detection

Search every branch of every repository, not just default branches, for the five hook files: .vscode/tasks.json containing an “Environment Setup” task, .vscode/setup.mjs, .claude/setup.mjs, .claude/math_init.js, and .claude/settings.json with a SessionStart hook. Look for commits titled chore: update config, particularly with a co-author line naming [email protected].

Grep dependency trees and package caches for the filenames setup.mjs, math_init.js, Math_Symbol.js and router_runtime.js. JFrog published the SHA-256 hash of the payload it pulled out of keyv 6.0.0, so match on that rather than on filenames alone where you can.

In GitHub audit logs, look for the dependabot/github_actions/format/setup-formatter branch, workflow runs named codeql_analysis.yml or “Run Copilot” that create and delete an artifact quickly, and any artifact named format-results. Look for repositories created under your organization’s accounts with descriptions matching the “Shai-Hulud: Here We Go Again” pattern.

On the network side, look for connections to npm-cache.com, and for outbound traffic to public Ethereum RPC endpoints from build infrastructure that has no business talking to a blockchain. The payload reads its current C2 domain list from an Ethereum mainnet contract, so both are useful detection signals and neither is a reliable block.

Shai-Hulud npm Attack Remediation Checklist, in Priority Order

  1. Determine whether preinstall scripts could have run at all. Check the npm version on every machine and runner that installed dependencies on or after 4 August 2026, and whether --ignore-scripts was in effect. If hooks could not run, your remaining work is dependency hygiene rather than incident response.
  2. Isolate before you revoke. Preserve tarballs, npm logs, CI logs, GitHub audit logs and runner images first. Given the reported deletion behavior on token revocation, sequence isolation ahead of credential rotation on any host you believe executed the payload.
  3. Pin lockfiles to versions published before 4 August 2026 for every affected package, and clear package manager caches so the poisoned tarballs are not sitting locally.
  4. Revoke npm tokens first, starting with anything holding write permission and 2FA bypass, then GitHub personal access tokens and Actions tokens.
  5. Rotate everything the collector could read, on the assumption that it read it: cloud credentials, Kubernetes service account tokens, Vault contents, database credentials, SSH keys, VPN profiles, Stripe and Slack tokens, and API keys for AI services.
  6. Sweep all branches of all repositories for the five hook files and the marker commits, and remove them.
  7. Rebuild CI runners and developer environments from clean images. Cleaning individual files is not the same as knowing the machine is clean.
  8. Review npm trusted publishers and GitHub OIDC trust policies, with particular attention to release automation workflows, since those were specifically targeted.
  9. Only then move to prevention: release-age floors, npm 12, token scoping, workflow review requirements.

Why This Keeps Working

Attribution is unsettled. Researchers cited by CyberScoop noted they do not yet have hard links tying this to TeamPCP, the actor that compromised more than a thousand packages earlier in 2026, though the possibility is on the table. Several firms reported the same payload and the same pattern across every affected package, which points to a single actor or a tightly coordinated cluster rather than copycats.

The attribution question is less interesting than the structural one. The same technique keeps working because it is not really a technique; it is an observation about how package ecosystems are built: anyone who can publish a version of a package can execute code on every machine that installs it. That is not a bug in npm. It is what a lifecycle script is for, and thousands of legitimate packages depend on the behavior.

Layer onto that the fact that publishing rights are held by individuals, that individuals hold tokens, that tokens sit in files on laptops, and that the payload’s first act is to go looking for those files. The worm’s propagation is not an exotic capability; it is the ordinary publishing workflow executed by malware instead of a maintainer.

In June the argument was that a green provenance check is one signal in a trust decision rather than a verdict. In August the argument is narrower and more mechanical: if the version your build resolves is whatever an attacker with a stolen token most recently pushed, then every other control you have is downstream of a decision you did not make.

So make that decision on purpose. Go and look at what your builds resolved on 4 August, then set a release-age floor before the next wave, because there will be one. If you would rather the versions your builds resolve came from a catalogue somebody rebuilds and reviews instead of from whoever holds a publishing token, talk to us about SecureChain for npm.

FAQ

Is this the same as the Shai-Hulud campaign from September 2025?

Same lineage, and it names itself as such, but not the same event. The August 2026 Shai-Hulud npm attack entered through keyv and cacheable rather than the packages hit previously, and CyberScoop describes it as a variant of Mini Shai-Hulud. By Wiz’s environment-prevalence numbers it reached considerably further than its predecessors.

The package’s GitHub repository looks clean. Does that mean the version I installed is clean?

No, and this is the detail most likely to be missed. For the several hundred secondary packages the attacker never touched the source repository at all. The malicious code existed only in the tarball published to npm. A clean repository tells you nothing about what your package manager actually downloaded. Check the version your lockfile resolved, not the source.

Does a valid signature or provenance attestation mean a package is safe?

No. In the OpenSearch case this campaign generated a genuine Sigstore bundle, through Fulcio and Rekor, for a malicious tarball published from a trusted workflow. Provenance establishes origin. It does not evaluate content.

We use a private registry that proxies npm. Are we protected?

Only if it does more than pass artifacts through. A caching proxy will happily cache and serve the poisoned version, usually faster than upstream would. What matters is whether anything inspects, rebuilds or delays what comes through it.

We run a vulnerability scanner on every build. Why did it not catch this?

There was no known vulnerability to find. Scanners match your dependency tree against CVE databases, and on 4 August the poisoned versions were hours old with no advisory attached. Known-vulnerability scanning and malicious-code detection are different problems needing different tools.

What would SecureChain have done here?

Two different things for the two entry paths. For the several hundred packages compromised by tarball republishing, the poisoned version is not in the path your build resolves from, because SecureChain serves the version TuxCare built from source rather than proxying whatever upstream published most recently. For the initial keyv and cacheable compromise, where the code went into the repository first, the malicious-code detector reads the version diff at onboarding and holds the release, so the poisoned version never enters the catalogue. Both apply across the SecureChain catalogue, for every component in it.

Summary
Shai-Hulud npm Attack: keyv, Affected Versions, What to Do
Article Name
Shai-Hulud npm Attack: keyv, Affected Versions, What to Do
Description
The Shai-Hulud npm attack hit 400+ packages in four hours via keyv and cacheable. Affected versions, IOCs, and a prioritized remediation checklist.
Author
Publisher Name
TuxCare
Publisher Logo

Join 4,500+ Linux & Open Source Professionals!

2x a month. No spam.

Table of Contents
Get the open-source security answers you need

Join 4,500+ Linux & Open Source Professionals!

2x a month. No spam.