Key points and observations
- On August 4, 2026, a threat actor compromised hundreds of npm packages and used them to propagate a backdoor.
- Some of these npm packages have over 150 million weekly downloads.
- The injected payload can spread on its own, similar to the Shai-Hulud npm worm.
- To check whether you're affected, jump to How to know if you're affected.
What happened
On August 4, 2026, we identified a malicious commit (174f6a5) in the GitHub repository of the popular keyv npm package.
A few minutes later, the same user pushed similar commits to other projects, including jaredwray/cacheable and jaredwray/ecto.
Timeline
| Time | Event |
|---|---|
| August 4, 09:02:37 UTC | Unsigned commit 29c773…, adds setup.mjs, Math_Symbol.js, the malicious preinstall, package inclusion, an executing test, and release configuration. This is the earliest recovered malicious state. |
| 09:03:24–09:03:44 UTC | The account creates release/v6.0.0-latest-config and opens Keyv pull request #2042. This is the first captured incident-period public event. |
| 09:04:27–09:04:31 UTC | Five GitHub-signed github-actions[bot] commits add identical Claude and Visual Studio Code execution hooks and payload blobs to v5, v4, the release lineage, main, and an existing Claude-named branch. |
| 09:08:18 UTC | A comment from the account warns that automated commits added unexpected .claude and .vscode hooks and downloader code: “Do not merge or publish until the repository and credentials are investigated.” |
| 09:12:13 UTC | The release branch is reset non-fast-forward from 2fd79f… to 29c773…. The reset removes the visible repository hooks but retains the npm loader, second stage, preinstall, and executing test already present in 29c773…. |
| 09:13:25–09:13:27 UTC | Signed bot commit 5da403… restores the exact hook blobs. Pull request #2043 then closes with the comment “Closing: merging directly to main.” |
| 09:14:58–09:18:28 UTC | GH Archive records a bot push to main. Unsigned commit ee2681… then replays the 27-file 29c773… package delta directly onto hook-bearing main, which moves to that commit without a recorded pull request merge. |
| 09:23:50–09:24:37 UTC | Commit f97eabc… deletes only the 22-line test that directly invokes the loader, while retaining preinstall, both payloads, and the repository hooks. main moves to f97eabc…. |
| 09:29:50–09:35:01 UTC | Genuine SLSA provenance for keyv@6.0.0 enters Rekor, binding tag v6.0.0 and f97eabc… to .github/workflows/release.yaml and Actions run 30896232272. The confirmed malicious package publishes at 09:35:00.763. |
| 09:53:17–09:54:12 UTC | Signed bot bursts continue on v4, v5, and main. The Keyv v6.0.0 GitHub Release object is created at 09:54:12, after the npm publication. It remains unpublished until 11:28:45. |
| 10:06:08–10:07:17 UTC | Unsigned Cacheable commit 893f73f… reuses the exact Keyv payload blobs across ten workspaces. It is pushed to main, and GitHub release setup-files-v1 publishes with a body explicitly describing the two payload files and preinstall. |
| 10:09:44–10:14:41 UTC | Nine confirmed malicious Cacheable-family versions publish from 893f73f… through Actions run 30899303541: @cacheable/net@2.1.1, @cacheable/node-cache@3.1.2, cacheable@2.5.1, flat-cache@6.1.24, cacheable-request@13.0.20, @cacheable/memory@2.2.1, file-entry-cache@11.1.6, @cacheable/utils@2.5.1, and cache-manager@7.2.10. |
| 10:25:47–10:28:02 UTC | Unsigned Ecto commit 983ce1a… adds the same Keyv payload blobs and preinstall, is pushed to main, and becomes release v5.0.1. Confirmed malicious ecto@5.0.1 publishes at 10:28:01.451 from Actions run 30900695639. |
| 11:28:36–11:28:45 UTC | The Keyv v6.0.0 tag is deleted for the fourth observed time, then the GitHub release created at 09:54 publishes almost two hours after the npm artifact. A GitHub ReleaseEvent is therefore not a reliable npm publication marker in this incident. |
| 11:30:02–11:47:55 UTC | Independent GH Archive events record further signed github-actions[bot] pushes on v5, v4, and the old Claude-named branch. |
Analysis of the Payload
The following chain applies when a compromised package invokes the loader through a life cycle script and the package manager permits dependency life cycle scripts:
| Phase | Behavior | Result |
|---|---|---|
| Initial execution | npm invokes node setup.mjs through preinstall |
The loader runs during installation without an application importing the package |
| Runtime staging | The loader accepts an installed bun or downloads Bun v1.3.13 |
The second stage runs under Bun rather than the npm-spawned Node.js process |
| Backgrounding | Outside recognized CI, the second stage respawns itself detached | Collection can continue after the parent installation process ends |
| Collection | Files, environment variables, runner memory, and cloud secret stores are queried | GitHub, npm, cloud, CI, and application credentials may be exposed |
| Exfiltration and C2 | Contract-selected HTTPS endpoint, with a signed GitHub fallback for domain discovery; public-repository fallback for exfiltration | Encrypted results leave the host; a primary C2 response can deliver more code |
| Propagation | Valid npm or GitHub credentials are used conditionally | Additional packages or repository branches receive the payload |
The first-stage Bun loader
The 11 KB setup.mjs file is lightly obfuscated and has one job: start the second stage under Bun. It first invokes bun --version. Any executable named bun that exits successfully is accepted; the loader does not require version 1.3.13 when Bun is already installed.
If Bun is absent, the loader maps the host to one of the following release assets:
- Linux AArch64
- Linux x86-64 with glibc
- Linux x86-64 with musl
- macOS AArch64 or x86-64
- Windows AArch64 or x86-64
The musl check is narrow. It examines ldd --version output and looks for the case-sensitive string Alpine in /etc/os-release; there is no musl-specific AArch64 target. It then constructs an official Bun release URL under:
https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/<asset>.zip
The downloader uses a node User-Agent, permits up to five HTTP 301, 302, 307, or 308 redirects, and applies a 121-second timeout to each request. It places the archive in a temporary directory whose name begins with bun-dl-. There is no response-body size limit and no origin restriction after a redirect.
Extraction prefers the system unzip utility on every operating system. If unzip is unavailable on Windows, it tries PowerShell Expand-Archive; the final fallback is a built-in parser for stored or deflated ZIP entries. If an available external extractor fails, the code does not fall back to the internal parser. On non-Windows systems the extracted runtime is made executable with mode 0755.
Finally, the loader synchronously starts Bun with the second-stage path and inherits the current working directory, environment, and standard streams. A finally block removes the temporary directory. If Bun was already installed, the download, extraction, and temporary-directory behavior do not occur.
The loader obtains an official Bun release asset but does not cryptographically verify it. GitHub can also redirect release downloads to other hosts, so github.com is not necessarily the only network destination. Running the payload under Bun may evade monitoring limited to Node.js runtime internals, but host-level endpoint detection can still observe the process tree, files, and network activity.
Inside the second stage
The second stage begins with // @bun @bun-cjs and is packed into two physical lines. Its obfuscation combines a 4,613-entry Base91 string table, a 1,283-element constant array, per-call alphabet permutations, name shadowing, split strings, and flattened generator state machines. We counted 73 Base91 alphabet literals, all of them unique permutations.
Backgrounding and execution guards
Outside environments recognized as CI, the payload respawns its current executable with the same arguments using detached: true, ignores its standard streams, adds _NODE_RUNTIME_INIT=1 to prevent recursion, calls unref(), and exits the parent. It creates a single-instance lock named tmp.dpkg_14527.lock under the operating system's temporary directory. It also installs no-op handlers for SIGINT and SIGTERM.
The source includes Exiting as russian language detected!, but the obfuscated dispatcher resolves the corresponding condition to a function that always returns false. There is no reachable locale inspection on that branch. The string may be inherited from another build or left as misdirection; it is not an active geofence here.
Credential collection
The implementation has three fast collectors, five deeper collectors, and one conditional GitHub Actions collector. domain is an exfiltration sender, while npm token handling belongs to the propagation path.
| Source | Data acquired | Important conditions and limitations |
|---|---|---|
| File system | Full contents of selected files, with GitHub and npm token matches identified | 5 MiB limit per file, two concurrent readers, no aggregate cap |
| Shell and environment | Entire process.env plus gh auth token output |
Requires GitHub command-line interface (CLI) authentication for the command to yield a token |
| GitHub Actions runner | Secret names and values recovered from Runner.Worker memory |
Linux only, GITHUB_ACTIONS=true, and successful sudo access to /proc/<pid>/mem |
| AWS Security Token Service (STS) | Account, Amazon Resource Name (ARN), user ID, source, and whether credentials appear static | Resolves credentials first; STS collector itself reports identity metadata rather than the secret key values |
| AWS Secrets Manager | Secret strings or binary secret values | Uses credentials with sufficient permissions across 17 configured commercial regions |
| AWS Systems Manager Parameter Store | Parameter names and decrypted values | DescribeParameters pages of 50; GetParameters batches of 10 with WithDecryption:true |
| Kubernetes | Namespaces and Base64-decoded Secret data | In-cluster API host is required; five namespaces are skipped; kubeconfig API server parsing is incomplete |
| HashiCorp Vault | Key-value (KV) v1 or v2 values | First 100 top-level keys per mount; nested keys ending in / are skipped |
| GitHub Actions workflow abuse | Values exposed through ${{ toJSON(secrets) }} |
Requires a valid token with workflow scope and a repository where the injected workflow can run |
File system and runner collection
The file system configuration contains 290 Linux patterns, 129 macOS patterns, and 50 Windows patterns. Targets include environment files, shell histories, SSH material, Git, npm, PyPI, cloud, Kubernetes, Docker, Vault, Terraform, CI, and AI assistant credentials, along with cryptocurrency wallets, VPN configurations, /etc/shadow, and /proc/self/environ. Each matching file is read in full up to 5 MiB. The scanner does not enforce an overall count or byte limit.
On a Linux GitHub Actions runner, the payload pipes an embedded Python program into sudo python3. That program locates the Runner.Worker process, reads each readable mapping through /proc/<pid>/mem, and emits the bytes. A shell pipeline removes null bytes and extracts serialized records with an isSecret: true flag. The parser explicitly drops the github_token entry but retains other discovered names and values.
Cloud secret stores
AWS credential resolution tries environment variables, web identity, Amazon Elastic Container Service (Amazon ECS) metadata, Amazon Elastic Compute Cloud (Amazon EC2) Instance Metadata Service Version 2 (IMDSv2), and shared credential and configuration profiles. The code does not fall back to IMDSv1. Secrets Manager and Systems Manager iterate 17 hardcoded commercial regions rather than every AWS region.
The Kubernetes collector queries the in-cluster API, lists non-system namespaces, retrieves Secret objects, and Base64-decodes their data fields. It can extract a bearer token from kubeconfig, but does not parse the kubeconfig API server; it still depends on the in-cluster host environment variable. The Vault collector checks environment and local token files and includes Kubernetes and AWS authentication helpers. Its nominal AWS login sends only a role, which is insufficient for a normal AWS Identity and Access Management (IAM) login flow, so that branch is unlikely to succeed as written.
These imperfections are operationally relevant but do not diminish the breadth of the working paths. When the process runs with cloud or cluster privileges, the collectors attempt to convert those privileges into plaintext secret values rather than merely inventorying secret names.
How GitHub secret names become values
GitHub's representational state transfer (REST) endpoints for repository and organization Actions secrets return names and metadata, not secret values. The malware follows that model: it maps the REST responses to each secret's name. GitHub documents that these APIs do not reveal encrypted values.
The next step is active workflow abuse. For a validated classic GitHub token whose scopes include workflow, the payload:
- Enumerates up to 100 pushable repositories updated since September 1, 2025.
- Selects repositories with visible repository or organization secret names.
- Creates
dependabot/github_actions/format/setup-formatter. - Commits
.github/workflows/codeql_analysis.yml. - Starts a workflow that assigns
${{ toJSON(secrets) }}to an environment variable, writes it toformat-results.txt, and uploads that file as an artifact. - Downloads and reads the artifact.
- Deletes the workflow run and branch.
The injected workflow can recover values made available to its job. Environment-scoped secrets that the job cannot access remain out of reach, and the cleanup may leave audit-log, workflow-run, and artifact records even after the temporary branch is gone.
C2 discovery and encrypted exfiltration
The primary sender does not contain a fixed destination domain. Instead, it iterates a list of 73 Ethereum remote procedure call (RPC) endpoints and makes an eth_call to:
Contract: 0xE1f2395ee43e45A1556EC6438a88c31B83493103
Selector: 0x53ed5143
Block: latest
The response parser decodes an application binary interface (ABI)-encoded dynamic array of strings. This establishes the smart-contract dead drop directly; it is not an inference from a log string. Each RPC attempt has a 3-second timeout. The malware resolves each returned domain and performs an HTTPS GET to port 443 on /router. Only status 400 or 404 is considered healthy. It uses the first healthy domain for exfiltration. The RPC hosts are mostly legitimate public infrastructure.
If the contract path fails, the malware searches GitHub commits for thebeautifulmarchoftime , including the trailing space. Its parser then accepts a deliberately different marker, thebeautifulsnadsoftime, followed by a Base64-encoded domain and RSA signature using SHA-256. The signature is checked with a second embedded 4,096-bit public key before the domain is used.
Before transmission, the payload serializes results as JSON and compresses them with gzip. It generates a random 32-byte AES key and 12-byte initialization vector, encrypts the data with AES-256-GCM, and appends the 16-byte authentication tag. It wraps the AES key with RSA Optimal Asymmetric Encryption Padding (RSA-OAEP) using SHA-256 and an embedded 4,096-bit public key. The posted object contains the Base64-encoded encrypted envelope, the wrapped key, and a 32-character host fingerprint derived from host, user, CPU, memory, platform, architecture, home directory, and media access control (MAC) address data.
generateKeyPairSync() creates an ephemeral P-256 key for the Sigstore path; it is not part of the RSA envelope. The exfiltration public key is embedded, and the AES key flows directly into publicEncrypt() with RSA-OAEP and SHA-256.
C2 responses create a code-execution channel
After the primary sender receives HTTP 200, it parses the body as JSON and handles it as follows in deobfuscated form:
const responseBody = JSON.parse(await response.text());
if (responseBody.code) {
await eval(responseBody.code);
}
The response body is not signed, and the code is not sandboxed. Transport Layer Security (TLS) authenticates whichever contract-selected domain the process contacts, but it does not constrain what that domain returns. Anyone controlling a selected domain and its valid HTTPS endpoint can execute arbitrary JavaScript inside the Bun process.
The fixed code focuses on credential theft and propagation, but this response handling also makes the implant a general code-execution channel. The absence of a hardcoded wiper does not prevent the operator from deleting files, installing another implant, using collected credentials, or taking destructive cloud actions.
GitHub as the exfiltration fallback
When no primary domain sender is available, the payload looks for classic GitHub personal access or OAuth tokens beginning with ghp_ or gho_. It validates a token through /user, inspects the x-oauth-scopes response header, and requires a token capable of creating a public repository. It also checks that the user profile is publicly reachable.
The fallback creates a public repository with a randomized Dune-themed name and the description: Shai-Hulud: Here We Go Again
It writes Base64-encoded result files below results/ with names beginning results-<timestamp>-<counter>.json. Objects larger than 30 MiB are split into .p1, .p2, and subsequent parts. Uploads use the GitHub REST Contents API and retry HTTP 422 and server errors up to five times. The GraphQL commit functions elsewhere in the file are not used by this exfiltration path.
If the compromised user belongs to no GitHub organizations, the payload includes the GitHub token in the result object and commit message after applying Base64 twice. The exact relay marker is: IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients
The payload can search public commits for that marker, decode the token, and reuse it if it retains repo or public_repo scope. A public exfiltration repository can therefore double as a token relay.
Two propagation systems and one provenance path
The file contains several independent subsystems with different credentials, targets, and publication semantics.
Propagation with stolen npm tokens
For each collected token matching npm's granular-token format, the payload queries npm's token API and requires a matching record with bypass_2fa: true and package-write permission. Organization package enumeration also requires organization-write access. It then identifies writable packages, downloads the current latest tarball, and modifies it as follows:
- Copies the running second stage to
package/math_init.js. - Writes the embedded 11 KB loader to
package/setup.mjs. - Replaces the entire
scriptsobject with{"preinstall":"node setup.mjs"}. - Increments the patch version.
- Repacks and publishes directly with the stolen token.
Generic npm propagation runs on Linux and macOS. Replacing the complete scripts object can break legitimate build, test, or release behavior in the victim package. The registry publish request also sets rejectUnauthorized: false, disabling server-certificate verification for that operation.
Generic npm-token propagation does not generate provenance. It uses the stolen registry credential and a direct publish request.
Propagation across GitHub branches
A separate path uses GitHub credentials in an Actions environment to fetch up to 50 branches, excluding Dependabot- and Copilot-prefixed refs, and writes five files to each eligible branch:
.vscode/tasks.json
.claude/math_init.js
.claude/settings.json
.claude/setup.mjs
.vscode/setup.mjs
The Visual Studio Code (VS Code) task runs node .claude/setup.mjs when a folder opens. The Claude settings install a SessionStart hook that runs node .vscode/setup.mjs. These files can retrigger the loader when a developer opens the repository or starts a Claude session.
Provenance can authenticate malicious content
The provenance implementation is real but narrowly gated. It runs only when GITHUB_REPOSITORY contains /opensearch-js and GITHUB_WORKFLOW_REF contains release-drafter.yml. It targets @opensearch-project/opensearch, increments the patch version, and adds this optional dependency:
@opensearch/setup = github:opensearch-project/opensearch-js#d446803f4c3bc116263faa3499a1d3f95b2825de
That path attempts to construct an in-toto statement with Supply-chain Levels for Software Artifacts (SLSA) provenance for the modified tarball, request a GitHub OpenID Connect (OIDC) token for Sigstore, generate an ephemeral P-256 key, obtain a Fulcio certificate, submit the signed envelope to Rekor, attach a Sigstore bundle, request an npm-audience OIDC token, exchange it for a short-lived npm publishing token, and publish. Static analysis establishes the request sequence, not that the external services accepted a particular run. If provenance generation fails, the code still attempts a publish without the bundle.
npm provenance establishes where and how a package was built. In a compromised workflow, a valid attestation can faithfully bind a malicious source and an attacker-controlled build to the resulting package. It is neither forged nor a safety verdict. Provenance is valuable evidence, but consumers must still inspect the referenced source, workflow, and artifact.
How to know if you're affected
The situation is evolving quickly. Below, we maintain a dynamic list of npm packages that this campaign compromised and used to distribute the malicious payload. You can also access a CSV version on our GitHub repository.
Show the list of compromised npm packages
Loading latest data…
How Datadog can help
Datadog Code Security can identify hosts, containers, and build environments where the compromised version was installed. Refer to the in-app Security Research Feed card for more information.
Datadog also maintains the open source supply-chain security firewall (SCFW), which can proactively block known malicious packages at installation time. It can also log every single npm and PyPI package installed on a developer's workstation and ship it to your Datadog organization, allowing you to react when new malicious or compromised packages are discovered.
Indicators of Compromise
Files and embedded artifacts
| Artifact | SHA-256 |
|---|---|
| Stage-two payload, math_init.js or Math_Symbol.js | 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc |
| Embedded and propagated loader, setup.mjs | fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb |
| .vscode/tasks.json | 927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f |
| .claude/settings.json | 14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128 |
| Injected GitHub workflow | 3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7 |
| Runner-memory Python | 29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7 |
| Token-monitor installer | 619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4 |
The SHA-256 fingerprint of the exfiltration RSA public key in Distinguished Encoding Rules (DER) form is dc1e6a7ddb29390dd53cf1e5aac40ad9204ea7c6b83ef5656e7cb7a796808b67. The fallback-domain verification key fingerprint is 166be2b7b58a440f7b17520ffb0368be5d89c76661704b4945417eb04b9ada65.
Network and behavior
| Indicator | Context |
|---|---|
0xE1f2395ee43e45A1556EC6438a88c31B83493103 |
Ethereum C2 dead-drop contract |
0x53ed5143 |
eth_call selector used against the contract |
https://awqhnjewqjkl[.]icu:443/router |
C2 URL for exfiltration |
https://pypi-get[.]com:443/router |
C2 URL for exfiltration |
https://js-mirror[.]com:443/router |
C2 URL for exfiltration |
https://npm-cache[.]com:443/router |
C2 URL for exfiltration |
thebeautifulmarchoftime |
GitHub commit-search query; trailing space is significant |
thebeautifulsnadsoftime |
Marker parsed from signed fallback commit messages |
IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients |
GitHub token-relay marker |
Shai-Hulud: Here We Go Again |
Public GitHub exfiltration-repository description |
tmp.dpkg_14527.lock under the temporary directory |
Single-instance lock |
_NODE_RUNTIME_INIT=1 |
Detached-child recursion guard |
Dormant token-monitor paths
~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/token
~/.config/gh-token-monitor/handler
~/.config/gh-token-monitor/started_at
~/Library/LaunchAgents/com.user.gh-token-monitor.plist
~/.config/systemd/user/gh-token-monitor.service
/tmp/gh-token-monitor.out.log
/tmp/gh-token-monitor.err.log
Acknowledgements
Thank you to Ian Kretz, Tesnim Hamdouni, and Eslam Salem, who contributed to the making of this post.