What a signature proves: a supply chain attack that carried valid provenance

Provenance proves which commit a package was built from, not that the commit was authorized.

On the morning of 4 August 2026 a self-propagating worm went through npm. One incident report counts 444 packages and 2212 poisoned versions in under four hours; a second, independent analysis puts it at over 400 packages. The numbers are not the interesting part. The packages that started the attack carried a valid signature and valid provenance, which is to say they passed cleanly through the verification layer the ecosystem has spent three years building.

What provenance proves

provenance proves which commit was built. It cannot prove the commit was authorized.

That sentence is from the incident report itself, and it summarises the whole story. Provenance cryptographically binds a package to the source commit and the workflow that produced it. The one thing it does not bind is whether that commit was pushed by somebody entitled to push it. In this attack a maintainer account was compromised, code was pushed straight to the main branch, and the packages were published by that repository’s own legitimate workflows. The resulting chain really was built correctly; it simply carried the wrong thing.

The second analysis describes the same mechanism from another angle: because the attacker controlled the source repository and legitimate workflows ran the code, valid SLSA Build Level 3 provenance was issued. Propagation followed the same logic: an npm token stolen from an infected machine was used to enumerate every package that token could write to, and to republish them. Every link in the chain, taken on its own, follows the rules.

The good news: the identity layer really did harden

None of this means supply chain defence failed, and that is worth writing down. npm’s trusted publishing moves publishing off long-lived tokens and onto short-lived, workflow-specific identities established over OIDC; in the documentation’s own words those tokens cannot be extracted or reused. Three providers are supported, GitHub Actions, GitLab CI/CD and CircleCI, and self-hosted runners are not on the list yet.

That direction has a history. After the attack wave in autumn 2025, GitHub published a plan for npm publishing security: phasing out classic tokens, a seven day lifetime for granular tokens and a shorter one still for those that carry publish permission, FIDO-based two-factor authentication instead of time-based one-time codes, and disallowing token-based publishing by default.

All of that is real improvement, and all of it closes the same class: publishing with a stolen long-lived key. What happened on 4 August was not in that class. The attack went after an account and a repository rather than a token, which is one layer above the defence. A security measure that works usually looks like this: the door it closed really is closed, the attacker moves to the next door, and from the outside it looks as though nothing changed.

The two cheap levers that are left

The first two items on the incident report’s mitigation list have, interestingly, nothing to do with cryptography.

  • Turning install scripts off. In a tree installed with --ignore-scripts the preinstall dropper never runs at all, so the first link of the chain breaks on one configuration line.
  • Release age. A few days of delay before adopting a freshly published version would have kept every poisoned version of this attack out of any automated upgrade, because all of them were published inside the same four hours.

The second one is now a package manager setting. In pnpm, minimumReleaseAge defines a waiting period in minutes and applies it to every dependency, transitive ones included; it was added in v10.16.0 and its default is 1440, a full day, from v11 onwards. A package manager waiting a day by default is the most concrete outcome of this whole argument: security stops being an add-on and becomes what installing does. minimumReleaseAgeExclude is the inevitable other half of the same decision, because you have to be able to say that an urgent patch is not something to wait on.

Not one incident, the continuation of a series

The first packages published in this attack sit somewhere that says more than any version number: the incident report puts their weekly download counts at 153.7 million, 149.9 million and 147.6 million. These are not packages applications install directly, they are other packages’ dependencies, which means the infection surface runs through a layer nobody deliberately chose. What takes up the most room in a dependency tree is usually the thing nobody can name.

The second analysis describes the incident as the continuation of a campaign series that began in September 2025, that is, a repeat attempt at the same target through a different vector. How that is read matters: not a one-off accident but a method retuned against each new layer of defence. Each round, the door that closed really is closed and the attack moves one layer up: from the token to the account, from the account to the workflow, from the workflow to the configuration files on a developer’s machine.

So “which tool protects us” is the wrong question, and “which layer does the next round move to” is the right one. All three of today’s answers point the same way: make the publishing identity short-lived, stop installation from running code by default, and do not adopt a freshly published version immediately. None of them targets a particular technique; all three target the two things an attack needs in order to work, time and automation.

What it means in this repository

This repository is pinned to pnpm 9.15.0, so minimumReleaseAge is not available here today. Writing that down is part of measuring: naming a defence you have not actually deployed is the worst of both worlds. What does exist is the eighth step of the gate, pnpm audit --prod --audit-level=high. And what that step is worth is already written in the repository’s own notes, a floor rather than a proof: it knows about published advisories and nothing about unpublished ones. On 4 August the poisoned versions were minutes old, so that step would have passed green that day.

There is a second observation, and it is worth writing precisely because it is uncomfortable: among the payload’s targets were editor and assistant configuration files, and the persistence hook went there rather than into a source file. Treating those files as settings and keeping them outside code review is no longer a defensible distinction, because a task definition is also a thing that runs.

In practice it comes down to three moves. First, turn install scripts off by default and open them one package at a time. Second, put release age behind a setting; the cost is a few hours of delay, the return is staying out of automated adoption. Third, stop using a signature as the answer to the wrong question: valid provenance says this package came out of this repository, not that the right thing went into it, and the thing that answers the second question is still human review.

Tags

  • Security
  • Node.js
What a signature proves: a supply chain attack that carried valid provenance · Deniz Barış Yıldırım, Portfolyo OS