The Attestation Was Valid. The Source Wasn't.

Attackers keep shipping malicious npm releases with valid provenance. I tried to break the popular fix, and it broke.

Single continuous ink line looping and doubling back between two curved brackets
the-attestation-was-valid A valid npm provenance attestation proves which pipeline built a package, not that its source was honest. What the tensorlake and GHAPPIER attacks show, and the check that failed my test. npm provenance, trusted publishing, supply chain attack, tensorlake npm compromise, GHAPPIER, dforge-mcp, TanStack, SLSA source track, ignore-scripts, lifecycle hooks, staged publishing

A valid npm provenance attestation proves which pipeline built a package. It does not prove the source that pipeline was handed was honest. Attackers used that gap last month and again last week, and the green check stayed green both times.

TL;DR

Stop treating provenance as the install gate. Require a second approver on the publishing branch and on each publish, and keep the lifecycle-hook check as a screen: it passed 2 of my 4 test packages that still ran their loader.

The latest case is tensorlake@0.5.144. Corgea's write-up says the release workflow published it to npm at 01:12 UTC on 8 October 2026, signed, from the project's own repository. Corgea puts the reason in one sentence: "The attestation stayed green because the source tree itself had already been weaponized." Nothing was forged. Nothing malfunctioned. The receipt was accurate, and the thing it described was bad.

What the receipt actually says

It helps to read the promise before arguing with it. npm's own page on provenance says that a package with established provenance "does not guarantee the package has no malicious code." What you get instead is "a verifiable link to the package's source code and build instructions." That is a claim about origin. It is a good claim, and it was never a claim about intent.

The standard behind it draws the same line. Version 1.0 of the SLSA specification describes provenance as what entity built the artifact, what process it used and what the inputs were, and notes that the source aspects were removed "to focus on the Build track." The current version, 1.2, adds a Source track with its own levels, the top one titled "Two-party review." So the people who wrote the spec agree that the build record and the source record are separate things.

None of this is an argument against provenance. It is a good receipt. A receipt tells you what was bought. It does not tell you whether the shop was robbed first.

Push access was publish access

The September case shows how little the attacker needed. CloudSEK's report, published 20 September, says someone used a maintainer account of @dforge-core/dforge-mcp for 105 minutes on 9 September. They changed three lines so that any push to the main branch started the release workflow, rewrote the workflow fourteen minutes later so it could publish unattended, and shipped the loader as version 0.2.21. It was the registry's latest release for 35 minutes and 38 seconds before the maintainer reverted and published a clean 0.2.22.

CloudSEK describes the attestation as "an accurate record of a dishonest input," and says why no npm credential was needed: "under trusted publishing the registry trusts the repository's CI identity, so push access was publish access." The attestation names the attacker's own commit. CloudSEK says it cannot say how the attacker got the access. It suspects a developer machine infected through a malicious extension or package, and does not claim to have confirmed it.

Tensorlake ran the same play with a smaller edit. Corgea, citing Tensorlake's incident pull request, says the attacker pushed a chain of direct commits to main on 7 October through the GitHub web UI, then dispatched publish_npm.yaml by hand. The malicious package.json gained "preinstall": "node lib/setup.mjs". The loader Corgea printed checks CI, GITHUB_ACTIONS, GITLAB_CI and RUNNER_ENVIRONMENT, which Corgea reads as an attempt to stay off the noisiest CI surfaces. It also installed a persistence process called gh-token-monitor. Corgea says that process reacts destructively when a stolen GitHub token stops working, which changes the order in which you clean up.

A third door, same green check

The May compromise of TanStack was different, and it is worth keeping separate. Snyk reports 84 malicious artifacts across 42 @tanstack packages on 11 May, with attestations that were "legitimate." TanStack's own postmortem describes the route: a pull_request_target workflow, a poisoned cache that the release workflow later restored, and an OIDC token extracted from the runner's process memory. The postmortem says the malicious versions went straight to the registry, bypassing the workflow's own publish step, using a token taken from the runner. The identity was real and the path to it was not. Same receipt, different hole.

I tried to break the fix

Corgea reports that Tensorlake's repair is to install with npm ci --ignore-scripts and run a script that fails CI if a published manifest declares preinstall, install, postinstall, prepare or related hooks. Corgea calls it a narrow control that would have stopped this release shape. That is the recommendation most readers will copy, so I tested it before repeating it.

I made four local packages with a harmless loader that writes a marker file instead of doing damage, and ran everything offline. A has a preinstall hook. B has no hook and runs its loader when the bin is launched. C is B with the CI-variable skip copied from the code Corgea printed. D is a hook plus the CI skip, the Tensorlake shape. The check is my own reimplementation of what Corgea describes, since I did not see their script.

pkg  hook         tripwire   npm ci --ignore-scripts   CI install+observe   launch bin (no CI vars)
A    preinstall   FAIL       no marker                 marker               n/a
B    none         pass       no marker                 no marker            marker
C    none         pass       no marker                 no marker            marker (none when CI vars set)
D    preinstall   FAIL       no marker                 no marker            n/a

The hook check does its job on A and D. Corgea is right about the shape it was written for. But B and C pass it, --ignore-scripts prevents nothing once the package is started, and the CI job that installs and watches for side effects saw something only from A, the one loader with no CI check. Against D it saw nothing, because D was built to stay quiet in exactly that job. That fits how CloudSEK's incident went: Infosecurity Magazine reports the dforge loader fired when the MCP server was launched rather than on install. A loader like that does not wait for a hook.

Then I tried the other route. In a scratch Git repository I put the check script in the tree beside a release script that runs it before signing. One direct commit added a preinstall hook and replaced the check with a line that prints "check passed." The release step printed a pass and would have signed provenance for that commit. The check lives in the same tree as the attacker.

So the recommendation as I first wrote it down does not survive. It is a screen for one install shape. It tells you nothing about whether the source is honest, and an attacker who can push to the branch can switch it off. The full input and output are in the notes for this article.

What survives is outside the tree

In every run, the same control held: who can change the branch, and who has to approve the publish. Neither lives in a file the attacker can edit with a push.

Make the publish need a second person. npm's trusted publishing page says a trusted publisher can be configured with stage-only permissions, so that every CI publish goes through a staged flow, "requiring a maintainer to review and approve each package with 2FA" before it becomes public. Pair it with the setting that disallows tokens. The same page lists what a GitHub trusted publisher is bound to: owner, repository, workflow filename, and an optional environment. The page lists no branch among them.

Make main need a second reviewer. This is what SLSA's source level four, "Two-party review," names. In September the attacker needed 105 minutes and push access. A rule that nothing reaches the publishing branch without another person means a stolen push credential is no longer enough by itself, unless that account is allowed to bypass the rule, so check who is.

Treat a new release as unproven for a day. The dforge release was the latest version for 35 minutes and 38 seconds. A lockfile and a deliberate update schedule would have missed it, and an auto-updater would not. That is my inference from those numbers, not a finding from the reports.

Keep the hook check anyway. It is cheap and it fits Tensorlake. Just stop calling it a gate.

What I did not test

These controls have limits I did not test. I did not configure a real staged publish or a real branch rule, since that needs accounts and a live registry. And an approver has to see something to approve. CloudSEK says the dforge payload was one line, number 3320, of a 99 KB file. A person clicking approve on a tarball will not find that without a diff against the last release. If your publish approval is a button and not a diff, you have added a step, not a control. That is the pressure I described in the AI code review bottleneck: when review capacity is the limit, a step nobody has time to do properly turns into a click.

If you installed it

Corgea's response guidance for a machine where tensorlake@0.5.144 ran is to treat it as compromised, and the order matters: back up first if you need the data, remove the gh-token-monitor persistence before revoking GitHub tokens, then rotate GitHub, npm, cloud, SSH and other credentials from a different clean machine. Revoke first and, per Corgea, the monitor could delete local files. Corgea says public sources name 0.5.143 as the last known safe release. This is a dependency decision before it is a security one. If you do not know who can push to the repositories you depend on, you have borrowed against them.

I watched a friend's bulletin board get taken in 1987 when a caller got the sysop to read out his own backdoor command. The command worked exactly as designed. It was just in the wrong hands, which I wrote up in watching a friend get hacked. Provenance has the same shape. Every check passed, because every check was about the door and none was about the hand.

Before Your Next Release

Five checks. The first four live outside the repository, which is the point.

  • List who and what can push to the branch your publish workflow runs from, including bots, deploy keys and admins who can bypass the rule. Anyone on that list can publish as you.
  • Require a second person on that branch. Write down the accounts allowed to skip the rule, and remove every one you cannot name a reason for.
  • Set the npm trusted publisher to stage-only and disallow tokens, so a CI publish waits for a maintainer's 2FA approval. Make the approval show a diff against the last release, not just a button.
  • Keep the lifecycle-hook check and --ignore-scripts in CI, and label them a screen. Then run the package's entry point once in a scratch directory, with no CI variables set, and look at what it touches.
  • Hold new versions of your dependencies for a day. If you have ever installed tensorlake 0.5.144, remove the gh-token-monitor persistence before you revoke any GitHub token.

The Bottom Line

Provenance tells you which workflow built the package. Keep it, and read it as a receipt. The decision about whether to trust the source is made earlier, by whoever controls the publishing branch and whoever approves the publish. If both of those are one stolen laptop, the signature will verify and tell you nothing. Check who can push, and whether the approval step shows a diff.

"Every check passed, because every check was about the door and none was about the hand."

Sources

Who Can Publish Your Dependencies?

If you tightened your publish path after one of these incidents, tell me which control held and which one you removed.

Send a Reply →