DEV Community

Christo
Christo

Posted on

Two npm worms shipped with valid provenance this year

I spent most of this year believing a green provenance check on npm meant something stronger than it does. Two incidents fixed that, and the second one changed what I build.

If you haven't used it: when a package is published from a GitHub Actions workflow using an OIDC token, npm records a signed attestation naming the repository, the workflow file, the ref and the commit that produced the tarball. npmjs.com shows it on the package page. npm audit signatures checks it. It's real cryptography and it does exactly what it claims.

What it claims is narrower than what people read into it.

The May one

On 11 May 2026, 84 malicious versions went out across 42 @tanstack/* packages. It was the first documented worm to publish validly-attested malicious packages, and the chain is worth walking through slowly because every step is boring on its own.

A workflow called bundle-size.yml ran on pull_request_target for fork PRs and checked out the fork's merge ref. That's the classic pwn request: untrusted code from a fork executing in the base repository's security context. On its own that's bad but bounded, because the workflow doesn't publish anything.

That code poisoned the pnpm store cache. Here's the part I didn't know: actions/cache writes aren't gated by workflow permissions, and cache scope is shared between pull_request_target runs and pushes to main. So a PR running in the base repo's cache scope can poison entries that production workflows on main will later restore.

Which is what happened. release.yml ran on main, restored the poisoned cache, and executed the payload during its build.

Then the clever bit. Rather than tampering with the publish step, the payload located the Actions Runner.Worker process through /proc/*/cmdline, read /proc/<pid>/maps and /proc/<pid>/mem, dumped the worker's memory, extracted the OIDC token, and POSTed directly to registry.npmjs.org. It never went through the workflow's own publish step at all.

The postmortem records what that means for anyone checking provenance:

The token's attested identity still matched TanStack/router release.yml@refs/heads/main.

Every packet of that is true. The build did happen in that repo. It did run that workflow, on that ref. The attestation isn't lying and the signature verifies. The code being built had just been swapped underneath it.

The August one

On 4 August the keyv maintainer's GitHub account was taken over. keyv is around 127M weekly downloads, and the worm spread through cacheable, flat-cache and file-entry-cache into 400+ packages inside a day.

Those releases carried valid provenance too, for the simplest possible reason: the attacker used the project's own release workflow. There was nothing to forge. Snyk's write-up puts the boundary in one sentence:

Provenance can faithfully attest a build whose source or workflow context has already been compromised.

Two incidents, one year, both with a green check mark.

What provenance is actually for

None of this makes provenance useless, and I want to be careful not to overcorrect. It rules out a real class of attack: a package published from somewhere other than the project's pipeline, by someone who took your npm token but not your GitHub org. That's worth having.

But it answers "did this come from the right pipeline", not "was the pipeline honest", and those get conflated constantly. As one writeup put it, a signal that can't distinguish a legitimate build step from a malicious one is notarization, not verification.

The practical consequence is that the presence of provenance is close to worthless as a trust input, because at this point the packages you most want to catch are the ones that have it.

The bit that is useful

Provenance carries more than a boolean, and the rest of it is comparable.

The attestation names a repository, a workflow path, a ref and a commit. Any of those can move between releases, and when they move it's usually either a deliberate infrastructure change or something you want to know about. A package that has shipped from release.yml@refs/tags/v1.4.0 for two years and now ships from hotfix.yml@refs/heads/main is not necessarily compromised. It's just a question worth asking before an upgrade lands in your lockfile.

Neither incident above would have tripped that check, and I'd rather say so than imply otherwise. In both cases the identity was stable and correct. What it catches is a different, quieter shape.

So I built it into the tool I maintain for auditing install scripts. Running a diff across two versions now resolves both attestations and compares them:

$ npm-script-lens diff @prisma/engines@6.0.0 @prisma/engines@6.16.2
@prisma/engines@6.0.0 → @prisma/engines@6.16.2
UNCHANGED: postinstall
PROVENANCE IDENTITY CHANGED  ref refs/heads/main → refs/heads/6.16.x
Enter fullscreen mode Exit fullscreen mode

That's a real result against the live registry, not a constructed example. It's benign, a release branch rather than main, and that's the point: you look, you understand it, you move on. Exit code is 1, same as when an install script changes, so CI can hold the upgrade until someone glances at it.

You can also pin the expectation in policy, so a package that starts building somewhere else stops being auto-approved:

{ "autoApprove": { "expectProvenance": { "keyv": "jaredwray/keyv:.github/workflows/release.yml" } } }
Enter fullscreen mode Exit fullscreen mode

Credit where it's due

Almost none of this is novel on its own, and the existing tools deserve naming.

npmjs.com already shows the build environment, source commit and build file on the package page. npm audit signatures verifies signatures and attestations across your tree. And cosign already pins an expected identity, cryptographically, which is strictly stronger than what I do:

cosign verify-blob-attestation --bundle npm-provenance.sigstore.json --new-bundle-format \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  --certificate-identity-regexp="^https://github.com/npm/node-semver/.github/workflows/release-integration.yml.?" \
  semver-7.6.3.tgz
Enter fullscreen mode Exit fullscreen mode

If you need real assurance about one package, use that. My tool reads the claims the registry serves over TLS and verifies no signatures at all, which is the same trust boundary as the tarball itself. I say that in the README because it's the first thing a security reader should want to know.

The gap I found worth filling was scale and time: cosign checks one artifact against one bundle you already fetched, and the npmjs.com page covers the one package you're already looking at. Neither tells you what happened across 400 transitive dependencies between last week's lockfile and this week's.

What actually helps

If you want the unglamorous answer to both incidents above, it isn't a detector. TanStack's malicious versions were publicly identified within about 25 minutes. keyv's were pulled within hours. Nearly every npm worm gets caught fast, because a lot of people are watching.

So the thing that would have saved you is refusing to install anything published in the last few days. It detects nothing. It just declines to go first, and it costs you a lockfile that lags reality by 72 hours.

That's a worse blog post than provenance identity and a better control.


Disclosure: I use AI heavily when building and writing. The research, incident chains and quotes here are from primary sources, linked above, and the command output is real.

Top comments (0)