The Publisher-Change Verdict: Upgrade, Hold, or Block a Flagged Release
Take one flagged release, pull its publisher and maintainer history, score five dimensions, and record an Upgrade, Hold, or Block verdict a second engineer would repeat.
Key takeaways
- Valid provenance does not clear a release: 84 malicious TanStack versions shipped in under six minutes with valid SLSA Build Level 3 provenance, because the attacker controlled the source account and the real pipeline signed the result.
- A flagged release gets a score of 0, 1, or 2 on each of five dimensions: publish-path continuity, account identity continuity, handover trail, contribution tenure of the new identity, and change content.
- Block and pin whenever change content scores 0, or when publish path and account identity both score 0. Upgrade only when no dimension scores 0, change content scores 2, and the total is at least 8 of 10.
- In the documented axios and Tensorlake compromises, scanners flagged the malicious release 6 to 11 minutes after publish, while axios stayed live for about 3 hours, so a 1-day cooldown outlasts the install-time window in those cases.
- PyPI keeps no ownership history and no API names the uploader, so you can only detect a publisher change on PyPI by comparing against ownership.roles snapshots you stored yourself.
- Refolk's index holds only 2 US profiles with supply-chain or open-source security engineer titles, against 40 with maintainer or OSPO titles. The verdict usually lands on a generalist engineer, so it needs a written rubric.
This is the decision standard for one specific moment. A package you already depend on ships a release from a different account, or without the provenance its earlier versions carried, or with a changed maintainer list, and someone has to decide today whether to take it. It is for the engineering manager, technical founder, developer-relations lead, or sourcer who ends up holding that call. It gives you five scored dimensions, a rule that turns the scores into Upgrade, Hold, or Block and pin, and a record format a second engineer can check line by line.
What does the publisher-change verdict decide?
The publisher-change verdict decides whether the person behind a flagged release is the legitimate maintainer. That answer determines whether you upgrade, hold the release behind a cooldown until named checks clear, or block it and pin the last good version. The verdict treats the release as a question about a person's identity, not about package hygiene.
A release is flagged when any of these is true compared with the last version you trusted:
- The publishing account (
_npmUseron npm) is different. - The previous version carried a provenance attestation and this one does not.
- The npm
maintainers[]list or the PyPIownership.roleslist has changed. - The PyPI
ownership.organizationhas changed. This is how a project ownership transfer looks from outside.
The three verdicts mean specific things:
- Upgrade. Take the release on your normal schedule, including the standard cooldown.
- Hold. Keep the cooldown in force and deny any exemption until a named list of checks clears. Every Hold names its checks and the person who clears them.
- Block and pin. Pin the last good version in your lockfile and refuse the flagged one until the maintainers publish an explanation you can verify.
Why provenance no longer settles it
Provenance is a signed attestation that binds a published tarball to the CI workflow that built it. It looked like the obvious shortcut: if the release came through the real pipeline, it came from the real maintainers. Worm-style compromises broke that shortcut. An attacker who controls a maintainer's source account can push to the branch, and the legitimate workflow then builds and signs the result.
What a provenance signature actually vouches for
- PersonThe human behind the account; provenance says nothing about this layer
- Source account and branchWhoever can push or merge to the default branch
- CI workflowThe trusted publisher identity named in the attestation
- TarballThe published file the signature is bound to
So the verdict has to look past the signature to the account that pushed the code, and past the account to the person.
Which registry fields show a publisher change?
On npm, the registry document records three things for each version: who published it, the maintainer list at publication time, and any provenance attestation. On PyPI you get the current ownership roles and a separate provenance endpoint, but no uploader and no history. Read each field for what it proves and for how it can mislead you.
| Field | Registry | What it proves | How it lies |
|---|---|---|---|
_npmUser | npm | Account that published the version | Under staged publishing, may name the approver |
_npmUser.trustedPublisher | npm | Publish came through a configured CI identity | A hijacked source account still publishes through real CI |
maintainers[] | npm | Maintainer list and emails at publish time | A changed email can sit on the same account name |
dist.attestations.provenance | npm | An attestation exists | Also present on token publishes with provenance |
ownership.roles | PyPI | Current owners and maintainers | Current state only; no history |
| Integrity API provenance | PyPI | Trusted Publisher kind, repo, workflow, environment | Uploader is never named |
On npm, fetch https://registry.npmjs.org/<package> and compare the versions entries for the previous and flagged releases. Practitioners have an open complaint that npm has no field equivalent to provenance for trusted or staged publishing. So read trustedPublisher and _npmUser together, and never rely on dist.attestations alone.
On PyPI, the JSON API gives you ownership.roles and ownership.organization. The Integrity API at /integrity/{project}/{version}/{filename}/provenance returns the Trusted Publisher identity behind the PEP 740 attestations. The JSON API does not expose the trusted-publisher configuration, which appears only on the project's Publishing page. One practitioner audit found that no API names the account that uploaded a file.
A missing attestation on PyPI means something only if earlier versions had one. A single-PR audit put attestation of the latest release at 44% of the top 500 projects. I treat that figure as unverified, but it means absence is common and is not a signal by itself.
I have not verified the equivalent fields for crates.io, RubyGems, or Packagist. Check each registry's own documentation before you apply this table to them.
The five dimensions and how to score each
Score each dimension 0, 1, or 2. A 2 means the release is continuous with the trusted history. A 1 means something changed but evidence you can verify explains it. A 0 means something changed and nothing explains it, or the evidence contradicts it. Write one line of evidence beside every score so a reviewer can check it without redoing your work.
| Dimension | 2: continuous | 1: changed, explained | 0: changed, unexplained |
|---|---|---|---|
| Publish path | Same trusted publisher, tag, PR-merged commits | Path changed; tag and file list match | No tag, or commits bypassed PRs |
| Account identity | Same account and maintainer emails | New account tied to known maintainer or org | New account or email, no link |
| Handover trail | None needed, or posted by an existing maintainer | Trail exists, only from new accounts | None, or flooded by new accounts |
| New identity tenure | No new identity, or broad history and normal escalation | Thin history, no pressure pattern | Pressure patterns or sudden elevation |
| Change content | Matches tagged source; no new scripts | New dependency that source imports | Unimported dependency, lifecycle script, .pth, blobs |
Publish-path continuity
This dimension proves the release took the same route from source to registry as the versions you trusted. That means the same trusted publisher, a tag that exists before the publish, and commits that reached the default branch through pull requests. Legitimate releases leave a gap you can see: the Playwright release was tagged 22 minutes before the npm publish.
When it lies: valid provenance gives a false clear when a hijacked source account pushes through the real CI. In the Tensorlake case, malicious commits landed under a maintainer's name and none went through a pull request. A clean trustedPublisher with direct commits to the default branch scores 0, not 2.
Account identity continuity
This dimension proves the same account, with the same contact identity, published the release. In the axios compromise the maintainer email changed at the same time as the publish path. Also watch for several release lines published from a new path in quick succession. Axios 1.14.1 and 0.30.4 went out 39 minutes apart, and LiteLLM 1.82.7 and 1.82.8 about 13 minutes apart.
When it lies: under staged publishing, _npmUser may name the npm user who approved the release, not the one who built it. On PyPI a change of uploader is invisible. One practitioner reports buying a sole author's expired email domain. In that case the account looks unchanged even though someone else now controls it.
Handover trail
This dimension proves that someone who was already trusted announced a legitimate change. Documented precedents:
- Corporate release-infrastructure change: @playwright/cli 0.1.19 dropped provenance and
_npmUsermoved to "microsoft1es". A GitHub issue in the Playwright repository documents the change. - Identity rotation after an incident: LiteLLM moved to new maintainer accounts and documented it in pinned issue #24518.
- Temporary token publishing while OIDC is set up: one project recorded this in a repository issue.
When it lies: attackers can manufacture a trail. During the LiteLLM incident, attackers posted 88 bot comments from 73 unique accounts in a 102-second window. A trail only counts toward a 2 if a maintainer account that existed before the change posted it. For foundation transfers and acquisitions I found no publicly established pattern of documenting artifact. In those cases I would look for the announcement on the foundation's or acquirer's existing channels and score 1 until I found it.
Contribution tenure of the new identity
This dimension proves the new identity has a real history beyond this one package. Record account age, contributions across other projects, and how the person gained permissions. Then check for the patterns in the OpenSSF/OpenJS alert on social-engineering takeovers:
- Friendly yet aggressive and persistent pursuit of the maintainer by unknown members
- Requests for elevated rights from new people
- Sock-puppet endorsements
- Binary blobs in pull requests
When it lies: tenure can make an attacker look trustworthy. The xz attacker's work represented 2.6 years of sustained, patient engagement. No numeric tenure threshold such as "six months" is publicly established, so do not invent one. Tenure can lower a verdict but never raise it above what path and content allow.
Building the cross-project footprint takes the longest in the whole procedure, because the evidence is scattered across commits, profiles, and talks. Refolk lets you ask for the person in plain English and returns their footprint across GitHub, LinkedIn, and the open web. It also shows who currently holds merge rights on the repository, which tells you whose confirmation to seek.
Change content
This dimension proves what will actually execute on your machines. Diff the published files against the tagged source and look for:
- New dependencies, and whether source actually imports them
- Lifecycle scripts such as
postinstall .pthfiles in Python packages- Binary blobs and unexplained changes to the file list
When it lies: attackers pre-stage dependencies so they do not look new. A clean decoy, plain-crypto-js 4.2.0, was published about 18 hours before the malicious 4.2.1 that axios pulled in. The test is whether anything imports the dependency, not how old it is.
How do the scores become Upgrade, Hold, or Block?
Block and pin when change content scores 0, or when publish path and account identity both score 0. Upgrade only when no dimension scores 0, change content scores 2, and the total is at least 8 of 10. Everything else is a Hold, and every Hold lists the checks that would move each low score to 2.
The rule in order:
- Block and pin if change content = 0.
- Block and pin if publish path = 0 and account identity = 0.
- Upgrade if no dimension is 0, change content = 2, and the total is 8 or more.
- Hold in every other case, with named checks.
These thresholds are my rubric, not a published standard. Their job is to make two engineers reach the same answer. Tenure is deliberately weak: a high tenure score cannot pull a release out of Block.
Path continuity against change content
The documented incidents land cleanly on this grid:
| Incident | Publish path | Provenance | Content signal |
|---|---|---|---|
| axios 1.14.1 | OIDC replaced by CLI token; email changed | Lost | Phantom dependency with postinstall |
| LiteLLM 1.82.7 | Stolen token, direct upload | Unverified | .pth file (in 1.82.8) |
| TanStack | Real pipeline | Valid SLSA L3 | Worm |
| Tensorlake 0.5.144 | Real workflow; direct commits, no PR | Valid | Worm |
Axios scores 0 on path, identity, and content, so it is a Block under either rule. Before its compromise, the legitimate 1.14.0 had been published through OIDC trusted publishing. LiteLLM's last legitimate CI-published version came eleven days before the malicious upload, so its path scores 0 and its content scores 0. TanStack and Tensorlake would pass an automated provenance check, but both score 0 on content, and Tensorlake also scores 0 on path because the commits bypassed review. The Playwright handover is the opposite case. It scores 1 on path and identity and 2 on content once you confirm the file list matches. That makes it a Hold that turns into an Upgrade when the issue is confirmed.
Step-by-step: scoring one flagged release
Work through seven checks in order, writing each result into the verdict record as you go, then have a second engineer review it. The time estimates below are rough and unsourced, but the full pass is roughly an hour and a half of focused work.
Scoring one flagged release
- Diff the registry snapshotCompare the previous and flagged versions on _npmUser, trustedPublisher, dist.attestations, and maintainer emails, or PyPI roles against your stored snapshot. About 10 minutes; done when the diff table is in the record.
- Check the publish path against the repoConfirm a matching tag exists and note the gap between tag and publish. About 10 minutes.
- Check the commit pathConfirm every commit reached the default branch through a pull request and look for a new committer. About 15 minutes.
- Look for a handover trailFind an announcement, pinned issue, org transfer, or PyPI ownership.organization change, and note who posted it. About 15 minutes.
- Check the new identity's tenure and footprintRecord account age, cross-project contributions, permission timeline, and any OpenSSF pressure patterns. About 30 minutes.
- Review the change contentCheck new dependencies and whether they are imported, lifecycle scripts, .pth files, blobs, and file-list changes. About 20 minutes; never skipped.
- Record the verdict and get a second reviewApply the verdict rule, name the checks for any Hold, and have a second engineer confirm the same verdict.
Sources disagree on ordering. pnpm's trustPolicy: no-downgrade runs the automated provenance check first and fails closed. That is a sound gate, because a downgrade stops the install before a human looks. Some security researchers argue that provenance must never clear a release on its own. Both positions fit this procedure: let the automated gate run first, and still do step 6 when steps 1 and 2 pass.
Package: Flagged version: Previous trusted version: Trigger: publisher changed / provenance lost / maintainers changed / PyPI ownership changed 1. Registry diff Field | Previous | Flagged _npmUser | | _npmUser.trustedPublisher | | maintainers (emails) | | dist.attestations.provenance | | PyPI ownership.roles / org | | (from stored snapshot) 2. Publish path Tag exists: Y/N Tag-to-publish gap: 3. Commit path All commits via PR: Y/N New committer on default branch: Y/N Publish path score (0/1/2): Evidence: Account identity score (0/1/2): Evidence: 4. Handover trail link: Posted by pre-existing maintainer account: Y/N Handover score (0/1/2): Evidence: 5. New identity Account age: Cross-project footprint: OpenSSF patterns seen: Tenure score (0/1/2): Evidence: 6. Change content New deps (imported in source? Y/N): Lifecycle scripts: .pth files: File-list diff: Content score (0/1/2): Evidence: Total: __/10 Any zero: Y/N Verdict: Upgrade / Hold / Block and pin If Hold, named checks and owner: - Cooldown exemption: denied / granted by: Scored by: Reviewed by:
Write one record per flagged release. Paste the registry diff verbatim and score every dimension, even when the verdict is obvious.
How does the publisher-change verdict go wrong?
Most wrong verdicts come from trusting one signal for something it cannot prove. Provenance gets read as identity, a new dependency gets read as a fresh dependency, and issue chatter gets read as a handover. Each failure below names the signal, how it misleads, and the check that catches it.
- Valid provenance gives a false clear. A hijacked source account publishes through the real CI, as happened with TanStack and keyv. Check: were all commits merged through pull requests, and is there a new committer on the default branch?
- The
_npmUserread misleads. Staged publishing records the approver, and token publishes can still carry attestations. Check: readtrustedPublisher, notdist.attestationsalone. - Pre-staging defeats the "new dependency" check. plain-crypto-js had existed for about 18 hours before the malicious version. Check: is the dependency actually imported in source? A dependency nothing imports, especially one that carries a lifecycle script, scores 0.
- A trust downgrade is a false positive. Playwright's move to microsoft1es was legitimate even though provenance disappeared. Check: look for a matching tag and an identical file list before you Block. Hold instead.
- A PyPI uploader change is invisible. No API names the uploader. Check: look for attestation presence and diff against your stored ownership snapshot.
- Tenure clears an attacker. The xz attacker had 2.6 years of tenure. Check: look for sock-puppet pressure and read the timeline of permission escalation, not just the account's age.
- The handover trail is manufactured. LiteLLM issue threads were flooded with bot comments. Check: treat issue chatter as unverified unless a maintainer account that existed before the change posted it.
- The cooldown silently fails. npm older than 11.10 ignores
min-release-age. A pnpm 10.x bug ignores the cooldown whenshared-workspace-lockfile=false. Check: run the package manager'sconfig getfor the setting on the version your CI actually runs.
Provenance tells you which workflow built the tarball. It does not tell you who pushed to the branch.
How do you run a Hold: cooldowns and exemption approval
A Hold works because of the cooldown. A cooldown is a minimum release age your package manager enforces before it will install a new version. The Hold verdict amounts to refusing the per-package exemption until named checks clear, so you need to know your tool's setting, its unit, and its exemption key.
| Tool | Setting and unit | Default | Per-package exemption |
|---|---|---|---|
| npm | min-release-age, days, CLI 11.10.0+ | Off | Disputed |
| pnpm | minimumReleaseAge, minutes, 10.16+ | 1440 from v11 | minimumReleaseAgeExclude |
| Yarn | npmMinimalAgeGate, minutes or duration, Berry 4.10.0+ | 1 day; version disputed | npmPreapprovedPackages |
| Bun | minimumReleaseAge, seconds | Unverified | minimumReleaseAgeExcludes |
Sources conflict on several cells. For npm, one source gives min-release-age-exclude and --min-release-age=0, and another says npm has no exclusion yet and points to CLI issue #8994. For Yarn, sources disagree on which version introduced the 1-day default and on the unit, and one reports that suffixed duration strings are ignored on some versions. Renovate 42's config:best-practices preset reportedly sets a 3-day default. I did not verify uv exclude-newer, pip uploaded-prior-to, or Dependabot cooldown. Before you rely on any of these, confirm the value on your installed version.
Where a 1-day cooldown sits against documented timelines
- PublishThe malicious version goes live on the registry
- First flag6 to 11 minutes in the axios and Tensorlake cases
- RemovalAbout 3 hours live for axios; about 4h50m to deletion for LiteLLM
- Cooldown ends1 day after publish, after removal in these cases
- Not coveredUpstream CI credential theft that began days earlier
The cooldown has limits. It covers the install-time window in these cases. It does not cover theft of upstream CI credentials, and in the LiteLLM case credential collection started five days before the malicious publish. A cooldown buys time for scanners and registries to act. It does not replace the verdict.
The exemption approval process I use:
- Grant an exemption only for a release that has scored Upgrade in a reviewed record.
- Record the approver's name in the record, next to the exemption key used.
- For a Hold, deny the exemption and list the checks and their owner.
- Remove the exemption once the release has aged past the cooldown, so it does not quietly cover later versions.
When a Hold turns on a handover question, ask a maintainer you already know to confirm it.
Hi <name>, I work on a team that depends on <package>, and my tooling flagged <flagged version>. It was published by <account or path>, while <previous version> came through <previous publisher or workflow>. Before I take it, can you confirm two things? 1. Did you, or a maintainer you added, publish this release? 2. Is the change in publish path or maintainers expected, and is it documented anywhere? I am holding the upgrade until I hear back. If this was not you, the release may come from a compromised account, and you may want to check your tokens and CI credentials. Thanks, <your name>, <team>
Send this through a channel that existed before the flagged release, never to a contact listed in the new release or its issue thread.
Who should own the verdict?
The verdict should belong to a named engineer working from the written rubric, with a second engineer reviewing. Few organisations have a dedicated supply-chain security person, so the owner will usually be a generalist.
| Segment | Country | Profiles |
|---|---|---|
| Supply-chain or OSS security engineer titles | US | 2 |
| Same titles | Germany | 0 |
| Open source maintainer or OSPO titles | US | 40 |
In Refolk's index of professional profiles, US maintainer and OSPO titles outnumber supply-chain security titles 20 to 1. The top employer among the 40 is Akuity, with 2, and the SF Bay Area accounts for 6 of 25 sampled profiles. These are exact-title matches, so they are a floor, not the size of the market. Plenty of people do this work under other titles. Still, the inference is that few people hold this as a titled job. Expect the verdict to land on whoever is on call, and design for that: the rubric, the record, and the second review exist so that a generalist and a specialist reach the same answer.
If you want a specialist to review your rubric, or you are hiring for the role, the titled pool is too small to wait for. Searching on the work itself, such as contributions to Sigstore, pnpm, or the npm CLI, finds people that title searches miss. Refolk runs that kind of search in plain English across GitHub and professional profiles.
How do you keep the verdict current?
Keep the verdict current by storing snapshots, re-checking the registry protections whose behaviour changes, and re-running the checklist on every flagged release. The registry metadata stays stable, but the protections around it change, and so do the tools' defaults.
What to re-check, and how:
- npm's account-change hold. npm places high-impact accounts in read-only mode for 72 hours after an email change or use of a 2FA recovery code. npm has not restated the "high-impact" threshold. One analysis infers it matches npm's 2FA policy: more than 1M weekly downloads or 500 dependents. Long-lived tokens never trigger the hold, and the axios versions were published with one. Check npm's current documentation for the threshold before you count on it.
- Staged publishing. It is opt-in. When a package uses it, read
_npmUseras a possible approver. - Tool defaults and exemption syntax. Re-run
config getafter every package manager upgrade, and watch npm CLI issue #8994 for the exclusion syntax. - Ownership snapshots. Save PyPI
ownership.rolesandownership.organization, and the npmmaintainers[]list, at every adoption and upgrade. Without them, a PyPI ownership transfer is invisible. - Precedents. When a legitimate handover is documented, link it in your records. When a new compromise pattern appears, add its signal to the failure-mode list.
Before you call the verdict done
- The registry diff for the previous and flagged versions is pasted into the record.
- Each of the five dimensions has a 0, 1, or 2 with one line of checkable evidence.
- Change content was reviewed even though provenance was present.
- Every new dependency was checked for an actual import in source.
- Any handover evidence cited was posted by an account that existed before the flagged release.
- For PyPI, ownership roles were compared against a stored snapshot, not memory.
- The cooldown setting was confirmed with config get on the package manager version CI runs.
- Any Hold lists named checks, an owner, and an exemption decision.
- A second engineer read the record and reached the same verdict.
Questions practitioners ask
How do I tell if an npm package was hijacked?
Compare the flagged version with the last trusted version in the registry document. Check whether _npmUser changed, whether trusted publishing gave way to a direct token publish, whether maintainer emails changed, and whether a new dependency with a postinstall script appeared. In the axios compromise all four happened together. Then confirm the release has a matching tag and that its commits went through pull requests. Worm-style hijacks publish through the real pipeline and keep valid provenance.
A new maintainer was added to a dependency. Is it safe to upgrade?
Not until you have scored the new identity and the change content. Look for a handover note posted by a maintainer account that existed before the change. Check the new account's age and its contributions to other projects, and look for the OpenSSF/OpenJS warning signs: persistent pressure on the maintainer, sudden requests for elevated rights, and sock-puppet endorsements. Tenure alone proves nothing, because the xz attacker spent 2.6 years building trust. Hold until the content review is clean.
What should I do when a package is published without provenance?
Hold it and find out why the publish path changed. Missing provenance is only a signal if earlier versions had it. Then look for a matching repository tag published before the release, a file list identical to the source, and an explanation from an existing maintainer. Playwright's move to a corporate release account dropped provenance legitimately, so a downgrade alone is not grounds to Block.
How should a dependency cooldown exception approval process work?
Grant an exemption only for a release that has scored Upgrade under the written rubric, and record who approved it. Use the package manager's per-package exemption key: minimumReleaseAgeExclude in pnpm, npmPreapprovedPackages in Yarn. npm's exclusion syntax is disputed, so confirm it on your installed version. If the release scored Hold, deny the exemption until the named checks clear. Remove the exemption once the release has aged past the cooldown.
Is a PyPI project ownership transfer a risk?
It can be, and you cannot see it unless you saved the earlier state. PyPI only exposes current ownership.roles and ownership.organization, keeps no history, and no API names the account that uploaded a file. Store a snapshot of the roles each time you adopt or upgrade a project, diff against it, and check whether the new release carries a Trusted Publisher attestation from the same repository and workflow as before.
Does valid provenance mean a release is safe?
No. Provenance proves which CI workflow built the tarball, not who pushed the code that workflow built. In the TanStack compromise, 84 malicious versions carried valid SLSA Build Level 3 provenance because the attacker controlled a source account. Valid provenance can earn the publish path a 2, but the change content review still has to happen.
Try it on the search you came here for
Stop building boolean strings. Just describe the person.
Type one sentence. I plan the search, read GitHub, public LinkedIn and Crunchbase records, and the open web as it is right now, and hand back a ranked list with the reason next to every name.
01Describe them
One plain sentence. Role, city, stack, stage, whatever matters to you.
02I read the web live
GitHub, public LinkedIn and Crunchbase records, the open web. Not a database that went stale last quarter.
03You read the shortlist
Ranked, with the reasoning under every name. Open a profile, ask a follow-up, narrow it down.
- Staff backend engineers in NYC who shipped Rust in production
- Series A fintechs in SF under 50 people, growing headcount this year
- Maintainers of fast-growing Rust web frameworks on GitHub
- No boolean, no filters, no seat to buy. One box.
- Read at search time, so a profile updated yesterday counts today.
- Every step visible as it runs, every name with its reason.
500 free credits on sign-up. No card, no demo call. See real searches.