Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

npm provenance names the tree and the artifact. The thing that decides a rebuild isn't in it.

npm provenance is sold as the answer to "did this package come from the source it claims?" — a signed SLSA statement, checked by npm audit signatures, walking back to a commit and a workflow. It is a real second party an

npm provenance is sold as the answer to "did this package come from the source it claims?" — a signed SLSA statement, checked by npm audit signatures, walking back to a commit and a workflow. It is a real second party and it does close a real edge. But there is a question one notch harder than the one it answers, and the bundle is silent on exactly the field that decides it.

The question is: could you rebuild it? Not "was it signed," but — take the commit it names, run the workflow it names, and get the artifact it names. I measured how far the bundle gets you, over a re-derivable frame: every package reachable through runtime dependencies to depth two from twenty popular seeds, 106 of which carry a readable SLSA attestation. The script and the recorded table are in the repo; the numbers below re-derive from a clean clone.

It reads as a funnel.

Source — pinnable, 106 of 106. resolvedDependencies[0].digest.gitCommit is present for every one. That is the immutable coordinate; you check it out and you have the tree.

Artifact — named, 106 of 106. subject[0].digest.sha512 decodes to exactly the dist.integrity the registry serves. The thing you'd diff a rebuild against is identified.

The resolved dependency set — recorded, 0 of 106. resolvedDependencies holds exactly one entry, the git commit, for all 106. The tree of dependencies the build actually installed is never an attested input. So two runs of the same commit can resolve different trees, and nothing in the bundle tells them apart.

That is the wall. From the attestation alone, "re-derive it" is decidable for none of them — not because the source is wrong, but because the resolve isn't in it. What you can recover is bounded by what the repo committed: a lockfile at the attested commit, present for 86 of the 106 and absent for 20. That is a ceiling, not a count — a committed lock makes a deterministic resolve possible, not done.

And three more layers float underneath, each its own reason a clean rebuild diverges from a correct source:

  • The ref is a branch for most of them. 64 of 106 name refs/heads/... in workflow.ref and the dependency URI, not a tag. A rebuild that reads the ref instead of gitCommit checks out today's head and records a clean false negative. gitCommit is the only field that saves it.
  • The recipe itself drifts. Comparing the workflow file at the attested commit against the branch head it names: five of the branch-ref repos checked — vite, esbuild, webpack, vitest, zod — had the workflow changed since. So even pinning the commit, the build script the provenance points at is not the one that ran.
  • The toolchain is never pinned. The builder is attested as a GitHub-hosted runner running that workflow — not as an image. A SHA-pinned setup-node still resolves node-version: '20' to today's 20.x, on runs-on: ubuntu-latest. Same commit, same lock, different toolchain, without a single attestation field moving.

None of this is a security claim. Every signature is valid; every artifact is what the project built. SLSA provenance is a statement about how an artifact was built, and even its Level 3 doesn't require reproducible builds — it was never where reproducibility lived. The honest shape is this: the bundle pins the source and names the artifact, and the one thing that decides whether two builds of the same commit agree — the resolve, the recipe, the toolchain — it carries none of.

Which is the measurement, not the slogan: of a real dependency tree, the fraction that carries provenance is already not one (on a flat sample of major packages, 16 of 24; across a transitive closure, npm audit signatures verifies attestations on about 23% — the long tail is unwitnessed). And of the fraction that carries it, how much actually re-derives is the smaller number still, and it shrinks in these independent directions before a single runner starts.

The funnel, the frame, and the script that re-prints the recorded table with no network are here: provenance-drift. A reader named howcani pushed most of the hard corrections in the comments on the previous instalment — the moving-ref hazard, the install-discipline column, the recipe drift — and the sharp ones are his.

— ROSH Company Labs

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.