# The Provenance Was Valid. The Commit Was Not.

> An npm provenance attestation binds a tarball to a repository, a workflow and a commit. Reading the payload shows what it never claims.

- Published: 2026-08-30
- Tags: npm, nodejs, tooling
- Source: https://jsledger.com/blog/provenance-proves-the-build-not-the-intent/
- Language: en-US
- Author: Jonah Vail

---
`npm audit signatures` says this site's dependency tree is in reasonable shape:
451 packages with verified registry signatures, 105 of them carrying verified
attestations. On July 14, 2026, five releases across four AsyncAPI packages were
published with attestations exactly as valid as those 105, and every one of them
shipped code no maintainer had approved.

The attestations were not forged, and nobody bypassed them. They were correct.
That is not a contradiction, and seeing why means reading what an attestation
actually contains — so this article reads one, field by field.

## Provenance is on, and mostly unread

The check takes a few seconds. On this repository, with npm 11.19.0 on Node
24.20.0:

```bash
npm audit signatures
```

```
audited 451 packages in 14s

451 packages have verified registry signatures

105 packages have verified attestations
```

Two numbers, and the second one is the interesting one. A registry signature is
npm attesting that it served these bytes. An attestation is a claim about where
the bytes came from before npm ever saw them. The command reports how many
packages carry one. It does not tell you what the claim says, and most people
who run it have never looked.

The [lockfile article](/blog/reading-a-lockfile/) left provenance at exactly this
point, describing it as what closes the gap between a tarball and the repository
it claims to come from. That is true, and it is worth being much more specific
about which gap.

## Five correct attestations on five malicious releases

Microsoft's
[write-up of the AsyncAPI compromise](https://www.microsoft.com/en-us/security/blog/2026/07/15/unpacking-asyncapi-npm-supply-chain-compromise-import-time-payload-delivery/)
states the outcome plainly:

> All five malicious versions were published through npm trusted publishing
> using GitHub OIDC and carried valid provenance attestations. The attestations
> accurately identified the legitimate repositories, commits, and workflows that
> created the packages, even though the triggering commits were unauthorized.

The route in was a workflow misconfiguration — a `pull_request_target` job that
checked out an untrusted head commit while running in the base repository's
security context, which exposed a bot token and allowed pushes to branches that
auto-publish. Three weeks later, on August 4, `keyv@6.0.0` went out at 09:35 UTC
as the first of eleven malicious releases across the cacheable family.
[Snyk's analysis](https://snyk.io/blog/inside-keyv-npm-compromise-preinstall-malware-trusted-provenance-ide-hooks/)
reaches the same boundary from the other side: "Provenance can faithfully attest
a build whose source or workflow context has already been compromised."

These incidents are not identical and it is worth not merging them. Microsoft
attributes the initial
[ChainDrop compromise](https://www.microsoft.com/en-us/security/blog/2026/08/04/chaindrop-supply-chain-compromise-anatomy-self-propagating-worm/)
to stolen maintainer credentials;
the AsyncAPI entry point was repository push access obtained through the
workflow. What they share is the step after: the project's own release pipeline
ran, honestly, over source it should never have been given.

## What the payload for `sigstore@5.0.0` says

The registry exposes attestations at a documented endpoint, and npm's own
[documentation](https://docs.npmjs.com/generating-provenance-statements/) is
careful about what they mean: establishing provenance "does not guarantee the
package has no malicious code." Each attestation is a DSSE envelope wrapping a
base64 payload, so reading one takes a single decode:

```bash
curl -sS https://registry.npmjs.org/-/npm/v1/attestations/sigstore@5.0.0 \
  | jq -r '.attestations[]
      | select(.predicateType=="https://slsa.dev/provenance/v1")
      | .bundle.dsseEnvelope.payload' \
  | base64 -d | jq '.predicate.buildDefinition'
```

```json
{
	"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
	"externalParameters": {
		"workflow": {
			"ref": "refs/heads/main",
			"repository": "https://github.com/sigstore/sigstore-js",
			"path": ".github/workflows/release.yml"
		}
	},
	"internalParameters": {
		"github": {
			"event_name": "push",
			"repository_id": "495574555",
			"repository_owner_id": "71096353"
		}
	},
	"resolvedDependencies": [
		{
			"uri": "git+https://github.com/sigstore/sigstore-js@refs/heads/main",
			"digest": { "gitCommit": "7d2900eca1c22b3f87c13987c8d4b7c9a29b733a" }
		}
	]
}
```

That is the whole build definition. Alongside it, `runDetails` names the builder
as `https://github.com/actions/runner/github-hosted` and records the workflow run
URL, and the statement's `subject` carries the package name and a SHA-512 digest
of the tarball.

## Every field commits to a build, none to a decision

Read the fields as claims and the boundary draws itself.

| Field | What it commits to |
|---|---|
| `subject.digest` | These exact tarball bytes |
| `externalParameters.workflow` | This repository, this workflow file, this ref |
| `internalParameters.github.event_name` | The kind of event that started the run |
| `resolvedDependencies[].digest.gitCommit` | The commit the build read |
| `runDetails.builder.id` | The build platform that executed it |

Every entry is a fact about machinery. `repository` says which repository, not
who may push to it. `gitCommit` identifies the commit, not its author and not
its reviewers. `event_name` says a push triggered the run, not that the push was
one anybody sanctioned. There is no field for a maintainer's approval, because
the format does not model approval — it models a build.

That is a coherent design rather than an oversight. A build system can observe
what it built from. It cannot observe whether a human meant it. The gap between
those two is where all three of the 2026 compromises lived, and no amount of
signing closes a gap the payload never spanned.

:::note
The subject digest is a SHA-512 over the tarball, and the lockfile's `integrity`
field is a SHA-512 over the same bytes. Decode the base64 in `integrity` to hex
and you get the attestation's `subject.digest.sha512` exactly — I checked, for
this package. They are one measurement recorded twice, which is why a valid
attestation and a valid lockfile entry can agree completely about a package that
is malicious.
:::

## The registry returns two attestations, not one

Ask for a package's attestations and two documents come back, which is easy to
miss because the badge on npmjs.com is singular. The second is much smaller:

```json
{
	"name": "sigstore",
	"version": "5.0.0",
	"registry": "https://registry.npmjs.org"
}
```

That is the publish attestation — the registry's own record that this name and
version were published to it. The distinction is visible in how each one is
verified. Inspect the bundles and the provenance attestation carries a
`certificate`, a short-lived Sigstore certificate issued against the workflow's
OIDC identity, while the publish attestation carries a `publicKey`, npm's own.
Different signers, different claims: one says a build produced these bytes, the
other says the registry accepted them.

Neither signer inspected the code. Both are honest about that.

## What provenance does close

It would be a bad reading of the incidents to conclude the attestation is
theater. It rules out a specific and previously common failure: a tarball
published from somewhere other than the declared build path. Before provenance,
a package's contents were whatever the publisher uploaded, and a maintainer
laptop with a long-lived token was indistinguishable from CI. Now a consumer can
require that a release came from a named workflow in a named repository, and
detect a publish that did not.

It also makes a compromise legible after the fact. The AsyncAPI attestations
named the commits, which is how the unauthorized pushes were identified at all.
An attestation that points at a bad commit is more useful than no attestation
pointing anywhere.

What it does not do is discharge the question of who can cause that workflow to
run. Provenance moves trust from the publishing step to the repository's access
controls, and a policy that says "require provenance" without also saying
anything about branch protection, workflow triggers or token scope has moved the
problem rather than solved it.

## Write the policy against the fields

If you are writing a rule for your organization, write it against what the
payload actually contains. Require an attestation, then check the repository and
workflow path are the ones you expect rather than merely present — a valid
attestation from an unexpected repository is a supply chain event on its own.
Record the `gitCommit` you installed, because during an incident that field
answers "what source is in my build" in seconds. And treat a dependency's branch
protection and release trigger configuration as part of its risk profile, since
that is the layer the attestation delegates to.

Run `npm audit signatures` in CI. It is fast, and 105 of 451 is a number worth
watching move.

What I have not tested is the failure mode of verification itself: what a
consumer sees when the transparency log is unreachable, or when a certificate
has expired relative to the tarball's publish time. Those paths matter for
anything that gates a deploy on this check, and I do not yet know how they
behave.
