Key Takeaway
The keyv npm worm of 4 August 2026 shipped through legitimate GitHub Actions and npm OIDC publishing with valid SLSA provenance, because provenance attests build integrity, not source integrity. Gate releases on reviewed source, install-script policy and release-to-commit correlation, and remove persistence before rotating any credentials.
The first thing most people did with the keyv incident was check the badge. The poisoned keyv 6.0.0 had a provenance attestation, it named GitHub Actions as the publisher, and every signature verified. That is the uncomfortable part. The control worked exactly as designed and the package still carried a credential-stealing worm.
This post reads the incident through the primary write-ups I could fetch: the Harness analysis of the ChainDrop worm, a commit-by-commit timeline from lilting.ch, and npm's and SLSA's own documentation on what provenance claims. It stays on one question. Why did valid provenance not help, and what should a release gate check besides a signature? Nothing here is an exploit; it is a defender's reading, with the cleanup order that matters most.
What happened on 4 August 2026, in order
Harness describes a self-propagating npm worm that spread on 4 August from the keyv and cacheable ecosystem into packages controlled by several unrelated organisations. The lilting.ch write-up reconstructs the keyv repository side with timestamps, all UTC:
- 09:02:37 - commit ee2681a9 adds the payload files and a lifecycle hook.
- 09:04:30 - commit d8c850c7 adds .claude/settings.json and .vscode/tasks.json. The article notes this commit had a verified signature, but that author fields are free text, so a valid signature does not say who held the token.
- 09:23:50 - commit f97eabcd removes the preinstall test line.
- 09:35:00 - keyv 6.0.0 is published with a malicious preinstall that runs node setup.mjs.
Harness, citing SafeDep, counts 2,234 poisoned versions across 444 package names reaching 12 organisations in under four hours. The four core packages, keyv, flat-cache, file-entry-cache and cacheable-request, receive about 1.88 billion downloads per month combined. Counts differ by vendor: lilting.ch quotes Aikido at 434 packages across 1,381 versions, and Harness itself says its figures reflect reporting as of 5 August and may change. I use SafeDep's numbers because two sources agree on them, and treat every total as a snapshot.
Why the provenance was valid: it answers a narrow question
npm's documentation says provenance links a package to its source code and build instructions, and states plainly that it does not guarantee the package has no malicious code. SLSA is narrower still. Its v1.0 Build track exists so a consumer can verify an artifact was built as expected, and the spec notes the source aspects were removed to focus on that track. In keyv's case the build was exactly what the workflow produced. The input was already poisoned, because the attacker pushed the payload to the repository before the tag was built. Harness quotes Socket: provenance attested to build integrity, not source integrity.
| Question a consumer cares about | Does valid provenance answer it? | Where the answer actually lives |
|---|---|---|
| Which workflow and repository built this tarball? | Yes | The attestation itself, signed and logged by Sigstore per npm's docs |
| Was the source reviewed before release? | No | Branch protection, merged pull request history, required reviewers |
| Does the release match an approved commit or tag? | Only that it matches the commit it names | A gate that correlates tag, pull request and previous release |
| Did a lifecycle script or workflow file change? | No | Diff of package.json, CI workflows, editor and agent config |
| Should install scripts run on my machine? | No | Your own install policy, such as ignore-scripts |
| What secrets could the runner reach? | No | Runner isolation and least-privilege identities |
Read the table by its second column. Provenance is a strong answer to a question attackers rarely ask of you, namely whether someone swapped the tarball after the build. This attack did not swap anything. It moved upstream of the build, to the one place the attestation does not look.
OIDC trusted publishing removed the token, not the trust
npm's trusted publishing lets a workflow publish through OpenID Connect with short-lived credentials and no long-lived npm token. You configure, per package, the GitHub user or organisation, the repository, the workflow filename and an optional environment name. That is a real improvement: there is no token in a secret store to steal. It also means the identity is now the workflow, so whoever can get commits and tags into that workflow's repository can publish.
# .github/workflows/publish.yml
# This file is what npm's trusted-publisher entry points at (workflow filename,
# no path). Anything that lands on a tag this workflow builds gets published,
# so the question is who can put commits and tags there, not who holds a token.
name: publish
on:
push:
tags: ["v*"]
permissions:
contents: read
id-token: write # mints the OIDC token npm exchanges for a publish
jobs:
publish:
runs-on: ubuntu-latest
# Optional environment name on the npm side. Protection rules on the GitHub
# side are where a human pause can be forced before the publish step.
environment: npm-release
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
# Wrong: a plain "npm ci" runs every dependency's lifecycle scripts
# on the runner that is about to hold the publish identity.
# Right: install without scripts, build explicitly.
- run: npm ci --ignore-scripts
- run: npm run build
- run: npm publishThree details from npm's page are worth keeping in mind. Trusted publishing does not disable existing tokens, and npm recommends requiring two-factor authentication and disallowing tokens, then revoking unused ones. Self-hosted runners are not supported. And the optional environment name is the hook where a human approval can sit, which is the only step in this flow that is not automatic.
The workflow above is the shape that was exploitable and the shape to harden. Note the install step. A runner that holds the publish identity should never run dependency lifecycle scripts, because that is the same code-execution primitive the worm used on the machines it reached.
Persistence that needs no npm install
The part of this incident that sets it apart from older npm worms is where it lives afterwards. Harness reports persistence tied to VS Code and Claude Code project configuration, so opening an affected repository in either tool could trigger the code without any package install. The lilting.ch article names the files: a SessionStart hook in .claude/settings.json that runs node .vscode/setup.mjs when a Claude Code session starts, and a folderOpen task called Environment Setup in .vscode/tasks.json. It adds that the VS Code path requires the workspace to be trusted and the user to choose Allow.
The second mechanism is a token monitor. Per lilting.ch, a script at ~/.local/bin/gh-token-monitor.sh calls api.github.com/user every 60 seconds with the stolen GitHub token. If the status is 4xx, which is what a revoked token returns, it runs a remotely supplied handler through eval. Harness gives the same warning in general terms: find and remove the monitor before revoking or rotating credentials. The script below only lists; it removes nothing.
#!/usr/bin/env bash
# Hunt for the persistence described in the lilting.ch write-up. Read-only:
# it lists, it deletes nothing. Run it BEFORE you revoke any token.
# 1. The token monitor (a 60-second poll of api.github.com/user).
ls -la ~/.local/bin/gh-token-monitor.sh \
~/.config/gh-token-monitor/ \
~/Library/LaunchAgents/com.user.gh-token-monitor.plist \
~/.config/systemd/user/gh-token-monitor.service \
/tmp/gh-token-monitor.*.log 2>/dev/null
# 2. Editor and agent hooks planted inside repositories you cloned.
# These fire on folder open or session start, with no npm install at all.
find ~/code -type d \( -name node_modules -o -name .git \) -prune -o \
\( -path '*/.claude/settings.json' -o -path '*/.claude/setup.mjs' \
-o -path '*/.claude/math_init.js' -o -path '*/.vscode/tasks.json' \
-o -path '*/.vscode/setup.mjs' \) -print
# 3. Which of your repos resolved a poisoned keyv at all?
grep -rn --include=package-lock.json -E '"node_modules/keyv"' -A2 ~/code \
| grep -E '"version": "6\.0\.0"'Run it from a machine you can still trust and treat any hit as a reason to disconnect that machine from the network before you touch a token. Files that sit inside a cloned repository count too. If you cloned the keyv repository during the poisoned window, the IDE path exists even if you never ran npm install.
Install policy: three npm settings that would have mattered
The malicious releases used a preinstall hook, so the most direct control is refusing to run lifecycle scripts from dependencies. Harness lists disabling lifecycle scripts in CI where they are not needed as a general control, and npm documents the settings below. min-release-age and allow-git are described on the CLI 11.21 documentation page; check that your npm version supports them before relying on them.
# .npmrc (npm 11.x config, per docs.npmjs.com/cli/v11/using-npm/config)
# Dependencies do not get to run code at install time. Default is false.
ignore-scripts=true
# Only resolve versions published more than N days ago. Default is null (off).
# The poisoned keyv releases were live for hours, not days, so a cooldown
# starves this class of attack. It does not stop a long-lived compromise.
min-release-age=7
# Do not let a dependency pull from a git reference you never reviewed.
# "root" allows only git dependencies listed in your own package.json.
allow-git=rootA cooldown is a bet on how long attackers stay undetected. The keyv 6.0.0 release went out at 09:35 UTC on 4 August and the wave was over within hours, per Harness, so a seven day delay would have skipped it. It would not help against a package that stays poisoned for a month. ignore-scripts is the control that does not depend on timing, at the cost of native modules that need an explicit build step.
A release gate that looks at source, not signatures
Harness argues a signed artifact is one input to a risk decision, and lists what to evaluate: source review, new scripts, unusual behaviour and source-to-artifact consistency. Here is a gate I would run in a job that must pass before the publish job. It asks three cheap questions that a direct push to the default branch fails. This is my own sketch of those ideas, not something either write-up published, and I have not run it against the keyv history.
#!/usr/bin/env bash
# release-gate.sh: run in a job BEFORE the publish job. Fails the release
# when the tagged commit does not look like reviewed, expected work.
set -euo pipefail
TAG="$1"; PREV="$2"; REPO="$3" # e.g. v6.0.0 v5.9.0 acme/widget
SHA=$(git rev-list -n1 "$TAG")
# 1. Was the tagged commit reached through a merged pull request?
# A payload pushed straight to the default branch has none.
MERGED=$(gh api "repos/$REPO/commits/$SHA/pulls" \
--jq '[.[] | select(.merged_at != null)] | length')
[ "$MERGED" -ge 1 ] || { echo "no merged PR for $SHA"; exit 1; }
# 2. Did this release touch things a library release never should?
git diff --name-only "$PREV" "$TAG" -- .claude .vscode .github/workflows \
| grep . && { echo "agent, editor or CI config changed"; exit 1; } || true
# 3. Did a lifecycle script appear in package.json?
git diff "$PREV" "$TAG" -- package.json \
| grep -E '^\+.*"(pre|post)?install"' \
&& { echo "install script added"; exit 1; } || true
echo "gate passed for $TAG"The first check is the strongest. The lilting.ch timeline shows commits landing minutes apart and a tag built straight after them. A rule that the tagged commit must be reachable through a merged pull request would have refused that, if the repository had it enforced. Checks two and three are noisy on a project that legitimately edits workflows, so treat them as a human review trigger, not an automatic ban.
Put the gate on the publish job with needs, and put required reviewers on the GitHub environment that job uses. A gate that only reports is a dashboard; a gate in the dependency chain of the publish step is a control.
Where these controls fall short
None of this is free. ignore-scripts breaks packages that compile native code or download binaries in an install script, so you pay with an allow list and some build engineering. A release gate on a small library with one maintainer has nobody to review the pull request, which makes check one a rubber stamp. And a seven day cooldown delays real security fixes along with the poisoned ones.
The worm's own design also limits what you can verify. Unpublishing removed the poisoned versions from the registry, but lilting.ch notes lockfiles, CI caches and already-unpacked node_modules directories still hold them. Vendor counts also disagree, so check the resolved versions in your own lockfile rather than trusting any single list of bad names.
The expensive mistake is rotating first. If a machine ran an affected version, the token monitor may be polling with a token that is about to be revoked. Remove the monitor and the hooks, disconnect the machine, and only then rotate from a clean environment.
I would also not read this as a case against provenance. It still defeats a different attack, the tarball swap, and SLSA Build L3 is aimed at tampering during the build. The lesson is about scope: a control that proves where something was built tells you nothing about whether the thing deserved to be built.
What to do on Monday
Ordered by how little it costs and how much it removes. The cleanup order follows lilting.ch and the incident steps in the Harness article.
- Search your lockfiles and CI caches for resolved keyv 6.0.0 and for any of the four core packages released on 4 August, and note which developer machines and runners installed them.
- On any machine that did, disconnect from the network, then run the listing script above and remove the token monitor files and any planted .claude and .vscode files.
- Check .github/workflows for jobs named Run Copilot or Copilot Setup that you did not add, and review CI history if you find one.
- From a clean machine, rotate npm, GitHub, cloud, Kubernetes, Vault, SSH, Docker and database credentials, then rebuild affected systems and downstream artifacts.
- Turn on ignore-scripts in CI and in the publish workflow, and trial min-release-age on one repository first.
- Add a release gate to every package you publish, set required reviewers on the publish environment, and revoke npm tokens that trusted publishing made redundant.
If you maintain a package, remember that your publish workflow is now the credential. Protect the branch and the tag rules that feed it as carefully as you used to protect an npm token.
Valid provenance told everyone the truth: GitHub Actions built this code. It could not say whether the code should have been there. Treat a signature as one input, put reviewed source, install policy and release correlation beside it, and when a worm does land, remove what watches your tokens before you revoke them.
Sources
- Harness: ChainDrop npm worm shows why valid provenance is not enough
- lilting.ch: keyv 6.0.0 shipped valid npm provenance and a daemon that fires when you revoke
- The Hacker News: keyv-linked npm worm poisons hundreds of packages
- npm Docs: generating provenance statements
- npm Docs: trusted publishing for npm packages
- SLSA: build track levels, v1.0
- npm Docs: CLI configuration (ignore-scripts, min-release-age, allow-git)

