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.
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
- tensorlake npm compromise used a preinstall loader, signed provenance, and a token-triggered wipe path — Corgea research write-up of tensorlake@0.5.144 (published 2026-10-08 01:12 UTC): direct commits to main, manual workflow dispatch, valid provenance, CI-variable skip, gh-token-monitor, and the --ignore-scripts plus lifecycle-hook tripwire fix.
- GHAPPIER malware loader npm supply chain attack — CloudSEK report (2026-09-20) on @dforge-core/dforge-mcp 0.2.21: 105 minutes of maintainer-account use on 2026-09-09, workflow rewritten to publish unattended, valid provenance naming the attacker's commit.
- Attackers Abuse npm Trusted Publishing in GHAPPIER Campaign — Infosecurity Magazine (2026-09-21, Alessandro Mascellino) on CloudSEK's GHAPPIER report; states the loader fired on launch of the MCP server rather than on install.
- TanStack npm packages compromised — Snyk analysis (2026-05-11): 84 malicious artifacts across 42 @tanstack packages, with legitimate Sigstore attestations.
- TanStack npm supply-chain compromise postmortem — TanStack's own postmortem (2026-05-11, updated 2026-05-15): pull_request_target, cache poisoning across a trust boundary, and OIDC token extraction from runner memory.
- Generating provenance statements — npm documentation: provenance does not guarantee a package has no malicious code; it links a package to its source and build instructions. Page last edited 2026-05-04 (no publication date shown).
- Trusted publishing for npm packages — npm documentation: trusted publisher binding fields, automatic provenance, stage-only permissions requiring maintainer 2FA approval, disallow-tokens setting. Page last edited 2026-10-06 (no publication date shown).
- SLSA v1.0 Security levels — SLSA v1.0 (marked Retired; v1.2 current): build track levels; source aspects removed 'to focus on the Build track'. No publication date shown on the page.
- SLSA v1.2 Source track requirements — SLSA v1.2 (status Approved): Source track levels 1-4, Level 4 is two-party review. No publication date shown on the page.
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 →