# Keyv and Cacheable npm Supply Chain Compromise

> Markdown version of an Informer Tech article. Canonical HTML page: https://www.informertech.com/post/keyv-cacheable-npm-supply-chain-compromise
> Source: Informer Tech, https://www.informertech.com/

- **Canonical URL:** https://www.informertech.com/post/keyv-cacheable-npm-supply-chain-compromise
- **Publisher:** Informer Tech (https://www.informertech.com/)
- **Category:** Security Threats
- **Author:** Darya Mehrabi
- **Published:** 2026-08-05
- **Description:** Details on the Keyv and Cacheable npm compromise, malicious preinstall hooks, credential theft, propagation, and response steps.

---

An active npm supply chain compromise affected widely used packages in the <code>keyv</code> and <code>cacheable</code> ecosystems, along with packages owned by other maintainers. At least 10 packages were published with a malicious <code>preinstall</code> hook named <code>setup.mjs</code>.

The hook downloads a standalone Bun runtime, launches an obfuscated second-stage payload, collects cloud and CI credentials, and can republish trojanized npm packages using stolen npm tokens. New affected packages were appearing during the investigation.

The affected packages account for tens of millions of weekly downloads. Because these libraries often sit deep within dependency trees, many impacted users may not have installed them directly.

Key activity associated with the compromise includes:

- Collecting cloud, CI, GitHub, npm, Vault, and Kubernetes credentials
- Republishing altered packages through stolen npm credentials and npm OIDC trusted publishing
- Sending encrypted stolen data to GitHub repositories and DNS-resolved destinations
- Adding <code>.claude</code> and <code>.vscode</code> autostart files that can run the loader when a cloned repository is opened

## Why Keyv and Cacheable Dependencies Have Broad Reach

<code>keyv</code> is a key-value storage abstraction that supports adapters for Redis, SQLite, Postgres, MongoDB, and other services.

The related <code>cacheable</code>, <code>cacheable-request</code>, <code>flat-cache</code>, and <code>file-entry-cache</code> packages are caching libraries from the same ecosystem. Their position in common dependency trees creates indirect exposure. For example, a dependency chain can run from <code>eslint</code> to <code>file-entry-cache</code>, then <code>flat-cache</code>, and then <code>keyv</code>.

That means a project can be affected even when none of these packages were added directly.

## Timeline of the npm Package Compromise

All listed times are UTC and based on npm registry publish timestamps.

- Scoped <code>@keyv/*</code> packages, including <code>@keyv/redis</code>, <code>@keyv/sqlite</code>, and <code>@keyv/mongo</code>, published <code>6.0.0</code> tarballs between 09:30 and 09:32. Those tarballs did not contain the confirmed <code>preinstall</code> hook, but they should be treated as suspect because of the maintainer account compromise.
- <code>keyv@6.0.0</code> was published at 09:35 and was the first confirmed version containing the malicious <code>preinstall</code> hook.
- The malicious hook in <code>keyv@6.0.0</code> was detected roughly six minutes after publication, before the later burst of affected <code>cacheable</code> packages.
- <code>@thiennq/docs-viewer@1.6.2</code>, outside the <code>keyv</code> and <code>cacheable</code> namespaces, was published at 09:38. This showed that the campaign had reached at least one account outside those two package families.
- Around 09:39, the <code>jaredwray/keyv</code> repository showed force pushes to <code>main</code>, repeated removal of the <code>v6.0.0</code> tag, a commit adding <code>setup.mjs</code> and <code>Math_Symbol.js</code> to <code>@keyv/*</code> packages, and further activity on the <code>v5</code> release branch.
- Between 10:09:44 and 10:14:41, the <code>cacheable</code> family was published in a burst. Affected versions included <code>@cacheable/net@2.1.1</code>, <code>@cacheable/node-cache@3.1.2</code>, <code>cacheable@2.5.1</code>, <code>flat-cache@6.1.24</code>, <code>cacheable-request@13.0.20</code>, <code>@cacheable/memory@2.2.1</code>, <code>file-entry-cache@11.1.6</code>, <code>@cacheable/utils@2.5.1</code>, and <code>cache-manager@7.2.10</code>.

## How the Malicious npm Preinstall Hook Works

The compromise is delivered through the npm lifecycle. The published library code in the package <code>dist</code> output matches the clean <code>6.0.0-rc.1</code> build by SHA-256. The malicious changes are limited to <code>package.json</code> and two added lifecycle files.

The altered <code>package.json</code> adds <code>setup.mjs</code> and <code>Math_Symbol.js</code> to the package files and runs the loader before installation:

<pre class="ql-syntax" spellcheck="false">"files": [ "dist", "LICENSE", "setup.mjs", "Math_Symbol.js" ],
"scripts": {
  "preinstall": "node setup.mjs"
}
</pre>
Because the package's normal library files remain unchanged, it can behave as expected after installation while the installation host has already been compromised.

### Stage 1: <code>setup.mjs</code> Downloads and Runs Bun

The first-stage <code>setup.mjs</code> file is a lightly obfuscated Node script. If Bun is not already installed, it downloads a standalone Bun runtime matched to the host platform and architecture.

The loader checks for environments including Alpine and musl. It can unzip the Bun download with the system <code>unzip</code> utility, Windows PowerShell <code>Expand-Archive</code>, or a built-in JavaScript ZIP parsing fallback.

After downloading Bun, the loader runs the second-stage payload:

<pre class="ql-syntax" spellcheck="false">execFileSync(bunBinary, [payloadPath], { stdio: "inherit", cwd: D });
</pre>
The Bun download uses HTTPS but does not verify a checksum or signature. Temporary <code>bun-dl-*</code> directories are removed after execution, limiting remaining disk artifacts.

The loader uses <code>Math_Symbol.js</code> in the npm package. A loader used in <code>.claude</code> and <code>.vscode</code> repository files instead references <code>math_init.js</code>, which contains the same payload.

### Stage 2: <code>Math_Symbol.js</code> Credential Theft and Propagation

<code>Math_Symbol.js</code> is a roughly 728 KB Bun bundle. Its strings are protected with polymorphic basE91 encoding. The recovered internal module labels include:

- <code>[collector]</code>
- <code>[dispatcher]</code>
- <code>[provenance]</code>
- <code>[publish]</code>

The payload targets a wide range of credentials and secrets. It actively queries AWS instance metadata and reads AWS credential chains and Secrets Manager data across regions.

It also targets:

- GCP service account private keys
- Azure client secrets
- HashiCorp Vault tokens
- Kubernetes service account tokens
- npm tokens
- GitHub Actions organization and repository secret metadata
- Secrets found in environment variables, files, and running processes
- Generic keys, bearer tokens, and private-key blocks found through a TruffleHog-style regular-expression scan

GCP and Azure credentials appear as file and regular-expression targets rather than confirmed API calls.

## npm Worm Behavior and Trojanized Package Publishing

The credential collection process feeds directly into self-propagation. The payload uses npm registry endpoints to identify packages maintained by the compromised identity, obtain publishing credentials through npm OIDC, and publish modified versions.

For each target package, the payload can:

- Download the existing tarball.
- Add the malicious <code>preinstall</code> hook and payload files.
- Recalculate integrity and shasum values.
- Increase the package version.
- Publish the modified package to the npm registry.

The payload also contains a provenance component that can create DSSE attestation envelopes, request Fulcio signing certificates, and submit Rekor transparency-log entries. This allows republished packages to have newly generated, verifiable Sigstore provenance.

Separately, <code>keyv@6.0.0</code> shipped with a passing attestation because the legitimate release workflow built from source that had already been trojanized. The observed <code>keyv</code> provenance and the payload's ability to republish packages are distinct findings.

## Exfiltration, Repository Hooks, and Host Persistence

The payload avoids relying on one fixed command-and-control destination. A <code>GitHubSender</code> component can create GitHub repositories and commit collected data through the GraphQL <code>createCommitOnBranch</code> mutation.

A separate <code>DomainSender</code> component resolves destinations through DNS, checks their availability, and sends encrypted data. The payload includes an operator-controlled public key stored as an AES-256-GCM-encrypted constant. Collected information is also handled with AES-256-GCM encryption, using a 12-byte IV, 16-byte tag, and ciphertext.

The data sent to GitHub repositories and DNS-resolved destinations is ciphertext.

### <code>.claude</code> and <code>.vscode</code> Autostart Hooks

The source repository includes autostart hooks designed to execute the loader when someone opens a cloned repository. These files are:

- <code>.claude/settings.json</code>, using a <code>SessionStart</code> hook
- <code>.vscode/tasks.json</code>, using a <code>folderOpen</code> task

These hooks can execute the same loader without requiring <code>npm install</code>. That creates exposure for developers and AI coding agents that open a cloned repository.

### Dead-Man’s Switch for Stolen GitHub Tokens

The payload also installs a host-level dead-man’s switch. It stores a stolen GitHub token and a handler command in:

<pre class="ql-syntax" spellcheck="false">~/.config/gh-token-monitor/{token,handler}
</pre>
The files use mode <code>600</code>. On macOS, the payload persists through a LaunchAgent named <code>com.user.gh-token-monitor</code>. On Linux, it creates a <code>systemd</code> user service and enables lingering through <code>loginctl enable-linger</code>.

A watcher script at <code>~/.local/bin/gh-token-monitor.sh</code> checks the GitHub API with the stolen token every 60 seconds. When the token receives an HTTP 4xx response, such as after revocation or rotation, the script evaluates a remotely supplied handler string, removes its state, and exits. It also clears itself after a 24-hour TTL.

The macOS LaunchAgent uses <code>RunAtLoad</code> and <code>KeepAlive</code>. The Linux service describes itself as “GitHub Token Validity Monitor,” making it resemble a developer utility at a glance.

## Impact of Affected Keyv and Cacheable Versions

Any environment that installed an affected package version and ran installation scripts should be treated as compromised.

On developer workstations and CI runners, the payload can collect:

- AWS, GCP, and Azure credentials
- HashiCorp Vault tokens
- Kubernetes service account tokens
- GitHub and npm credentials
- CI organization and repository secrets
- Other secrets matched by the payload’s regular-expression sweep

A compromised CI token can allow the campaign to spread to additional npm packages through the victim’s own npm identity. The shared maintainer ecosystem across <code>keyv</code> and <code>cacheable</code> creates a broad blast radius across common dependency chains.

The incident also shows that provenance attests to build integrity rather than source integrity. A signed and verifiable attestation can still accompany malware when the source supplied to the build process has already been altered.

## Recommended Response for Developers

Pin affected dependencies to the version immediately before the listed affected release and rebuild lockfiles.

Use exact version pinning and lock dependencies by integrity hash. Do not use caret or tilde ranges, and do not run <code>npm update</code> in a way that could pull a fresh 5.x or patched release while the maintainer account remains compromised.

Where practical, block the full <code>keyv</code>, <code>@keyv</code>, and <code>cacheable</code> scopes through a registry proxy or allowlist instead of blocking individual versions only.

## Recommended Response for Security Teams

Remove the dead-man’s switch and implant before rotating credentials. Revoking or rotating a stolen token can trigger the watcher to evaluate its remote-supplied handler.

Check for and remove:

- <code>~/.local/bin/gh-token-monitor.sh</code>
- <code>~/.config/gh-token-monitor/</code> including <code>token</code>, <code>handler</code>, and <code>started_at</code>
- <code>~/Library/LaunchAgents/com.user.gh-token-monitor.plist</code> on macOS, then unload the LaunchAgent
- <code>~/.config/systemd/user/gh-token-monitor.service</code> on Linux, then run <code>loginctl disable-linger</code>
- <code>/tmp/gh-token-monitor.{out,err}.log</code>

Also look for the installer and associated artifacts:

- <code>node setup.mjs</code> launching a downloaded Bun binary
- <code>bun-dl-*</code> temporary directories
- <code>Math_Symbol.js</code> or <code>math_init.js</code>
- <code>.claude/settings.json</code> and <code>.vscode/tasks.json</code> autostart hooks

An equivalent monitoring mechanism may also exist for an npm token.

After removing the switch and implant, rotate every credential reachable from the affected host. This includes npm tokens, GitHub personal access tokens, <code>GITHUB_TOKEN</code>, AWS, GCP, and Azure credentials, Vault tokens, Kubernetes service account tokens, and CI organization and repository secrets.

Revoke npm and GitHub tokens rather than only rotating them. Audit npm accounts for unexpected package versions and review GitHub for newly created repositories or unexpected commits.

## Indicators of Compromise

### File Hashes

<table class="ql-table" cellspacing="0" cellpadding="0" data-table-id="nij1n25k08f" data-full=""><colgroup><col contenteditable="false" width="50%" data-full="true" data-table-id="nij1n25k08f" data-col-id="usjofcu9eep"><col contenteditable="false" width="50%" data-full="true" data-table-id="nij1n25k08f" data-col-id="dd6kg9ptb2p"></colgroup>
<tbody>
<tr class="ql-table-row" data-row-id="9km7kvs3pc6">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="9km7kvs3pc6" data-col-id="usjofcu9eep">
 

SHA-256

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="9km7kvs3pc6" data-col-id="dd6kg9ptb2p">
 

File

 

</td>
</tr>
<tr class="ql-table-row" data-row-id="3pfjd5frh9p">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="3pfjd5frh9p" data-col-id="usjofcu9eep">
 

<code>54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668</code>

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="3pfjd5frh9p" data-col-id="dd6kg9ptb2p">
 

<code>setup.mjs</code> npm tarball preinstall loader

 

</td>
</tr>
<tr class="ql-table-row" data-row-id="7494x4qy6ba">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="7494x4qy6ba" data-col-id="usjofcu9eep">
 

<code>fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb</code>

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="7494x4qy6ba" data-col-id="dd6kg9ptb2p">
 

<code>setup.mjs</code> loader in <code>.claude</code> and <code>.vscode</code> repositories

 

</td>
</tr>
<tr class="ql-table-row" data-row-id="gura2d3wc9">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="gura2d3wc9" data-col-id="usjofcu9eep">
 

<code>9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc</code>

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="gura2d3wc9" data-col-id="dd6kg9ptb2p">
 

<code>Math_Symbol.js</code> npm payload and <code>math_init.js</code> repository payload

 

</td>
</tr>
</tbody>
</table>

### Tarball Hash

<table class="ql-table" cellspacing="0" cellpadding="0" data-table-id="bde4987mm05" data-full=""><colgroup><col contenteditable="false" width="50%" data-full="true" data-table-id="bde4987mm05" data-col-id="veubti7rsm"><col contenteditable="false" width="50%" data-full="true" data-table-id="bde4987mm05" data-col-id="dlkti58g6g"></colgroup>
<tbody>
<tr class="ql-table-row" data-row-id="jc4dme49nv">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="jc4dme49nv" data-col-id="veubti7rsm">
 

Package

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="jc4dme49nv" data-col-id="dlkti58g6g">
 

SHA-512

 

</td>
</tr>
<tr class="ql-table-row" data-row-id="bvuioudposk">
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="bvuioudposk" data-col-id="veubti7rsm">
 

<code>keyv-6.0.0.tgz</code>

 

</td>
<td class="ql-table-cell" colspan="1" rowspan="1" data-row-id="bvuioudposk" data-col-id="dlkti58g6g">
 

<code>37f9f847e9c3e520b47d83a9029e199dbc30c6a195a1d80467d0fb1a6fd5068728ad4d1a422995ca578359263afdac5d3b4fed7a6b9befad27de19cca6966952</code>

 

</td>
</tr>
</tbody>
</table>

### Network Indicators

- <code>169[.]254[.]169[.]254</code>
- <code>169[.]254[.]170[.]2</code>
- <code>github[.]com/oven-sh/bun/releases/download/bun-v1.3.13/</code>
- <code>registry[.]npmjs[.]org/-/whoami</code>
- <code>registry[.]npmjs[.]org/-/npm/v1/tokens</code>
- <code>registry[.]npmjs[.]org/-/npm/v1/oidc/token/exchange/package/</code>
