Skip to content

feat(npm): publishing-trust ranking and no-downgrade trust policy#34927

Merged
bartlomieju merged 10 commits into
mainfrom
feat/npm-trust-policy
Jun 25, 2026
Merged

feat(npm): publishing-trust ranking and no-downgrade trust policy#34927
bartlomieju merged 10 commits into
mainfrom
feat/npm-trust-policy

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jun 5, 2026

Copy link
Copy Markdown
Member

This teaches Deno's npm resolver about how a package version was
published and adds an opt-in no-downgrade trust policy, following
pnpm's implementation (resolving/npm-resolver/src/trustChecks.ts)
closely.

npm's full packument exposes whether a version was published via trusted
publishing (OIDC, _npmUser.trustedPublisher), carries a provenance
attestation (dist.attestations.provenance), or went through staged
publishing (_npmUser.approver, a maintainer approving with a live 2FA
challenge). Each version's strongest signal is ranked into a single
trust level. The tiers are mutually exclusive and mirror pnpm: a staged
publish is strongest, then trusted publishing backed by a provenance
attestation, then a provenance attestation on its own. A
trustedPublisher flag without provenance is deliberately not counted,
since on its own it is just metadata a future staged-publish flow could
mint.

The no-downgrade policy, enabled with trust-policy=no-downgrade in
.npmrc, refuses to resolve a version whose trust evidence is weaker
than the strongest evidence on any earlier-published version of the same
package. The comparison is by publish date, not semver, exactly as
pnpm does it: if a package has been published through trusted publishing
or with provenance and a later version suddenly appears as a plain token
publish, that is a strong signal of a stolen maintainer token, and the
policy turns it into a hard error instead of a silent install. When a
downgrade is the only thing blocking resolution, the resolver returns a
dedicated error explaining the rejection and how to override it, rather
than silently falling back to an older version.

The motivation is supply-chain safety. This is the same defense that
would have caught the August 2025 s1ngularity incident, where a package
consistently published through CI was suddenly published from a laptop
with basic credentials.

The trust signals only exist in the full packument. Enabling the policy
fetches the full packument, but min-release-age already makes Deno do
that by default, so in the common case there is no extra fetch. To keep
the cached registry.json small, the never-read signal objects
(_npmUser.approver, _npmUser.trustedPublisher,
dist.attestations.provenance) are reduced to a one-byte presence
marker before the packument is cached.

A trust-policy-ignore-after setting (in minutes, mirroring pnpm's
trustPolicyIgnoreAfter) skips the check for versions published more
than that long ago, so genuinely pre-provenance releases still install.
It is turned into an absolute cutoff in the resolver factory so the
resolver stays free of a wall clock.

Coverage: tiered ranking, the publish-date downgrade scan (including
prerelease exclusion and the ignore-after cutoff), the targeted
downgrade error, and compact cache serialization are covered by resolver
unit tests. An end-to-end spec test installs a package whose newer
version downgrades from a staged publish to a plain publish and asserts
the install is rejected, plus a spec test that the cache keeps compact
markers. The test npm registry now preserves dist.attestations from
fixtures so provenance is exercisable end to end.

Unlike pnpm, the trust level is not persisted to the lockfile: the
policy recomputes the baseline from the packument's version history on
each resolution, so existing lockfiles are unaffected.

A trust-policy-exclude[] setting (mirroring pnpm's
trustPolicyExclude) exempts named packages from the policy: repeated
trust-policy-exclude[]=<package> entries in .npmrc list packages
that resolve as if the policy were off. Home and project excludes are
unioned.

The policy is opt-in and stays off by default. Provenance, trusted
publishing and staged publishing are still unevenly adopted across the
registry, so enabling no-downgrade by default would turn legitimate
trust-evidence gaps into hard install failures; it can be revisited
once adoption is high.

Prototype recognizing npm trusted-publishing, provenance and staged-publish
signals, ranking them, persisting the rank in the lockfile, and enforcing a
no-downgrade trust policy during resolution.
@bartlomieju

Copy link
Copy Markdown
Member Author

Prototype limitations / open questions

Posting these up front so reviewers know where the rough edges are.

  • No live approver (staged-publish) data yet. npm staged publishing is
    brand new (May 2026), so the top trust rank is only exercised by unit tests
    with synthetic packuments. Parsing/ranking is in place for when registries
    serve it. The exact location/shape of approver in the packument is my best
    guess and should be confirmed against real data.
  • Baseline timing. The factory reads the no-downgrade baseline from the
    lockfile via the already-initialized OnceCell (maybe_lockfile_sync). If
    the version resolver were ever constructed before lockfile discovery, the
    baseline would be empty and the policy a silent no-op (never wrong, just
    inactive). This ordering assumption should be made explicit before this is
    production-ready.
  • Enforcement coverage. The trust filter gates the main semver matching
    path and the latest dist-tag fast-path. Explicit non-latest dist-tags
    are not gated yet.
  • Ranking is a design choice. Only two invariants are load-bearing: a
    staged publish ranks highest, and any signal beats a plain token publish.
    The relative order of provenance vs. trusted-publishing (I weighted trusted
    publishing higher) is arbitrary and open to discussion.
  • No spec test. Enforcement is covered by resolver-level unit tests rather
    than a spec test, since a spec test needs the local npm registry harness to
    serve full packuments carrying these fields. Worth adding.
  • Full-packument requirement. _npmUser/attestations only exist in the
    full packument, so enabling trust-policy forces full-packument fetches
    (same as min-release-age). That is heavier than the abbreviated default.
  • Lockfile churn. Trust ranks are only populated when a package resolves
    fresh from the registry; an existing lock entry is reused as-is. So trust
    fields appear incrementally as packages are re-resolved, not all at once on
    the first run after upgrading.

Verified working: against the live registry, installing npm:sigstore@5
populated trust ranks on 24/49 packages (mostly rank 3 = provenance + trusted
publishing, two at rank 1 = provenance only), and chalk correctly got no
field (it ships neither) — i.e. no false positives.

@bartlomieju

Copy link
Copy Markdown
Member Author

Synced this branch with main and fixed the fallout so it builds again.

The branch had drifted 332 commits behind main, so I merged the latest
main in. There was a single real content conflict, in
libs/npm/resolution/common.rs: both sides added new methods at the same
spot (this branch's matches_trust_policy / matches_trust_policy_for_version
and main's link_version_req_satisfies). They are independent, so I kept
both. (The conflict markers that show up in libs/lockfile/lib.rs are string
literals inside the resolves_git_merge_conflict test, not a real conflict.)

The merge resolved cleanly textually, but main had since added new
construction sites for the structs this feature extends, so they were missing
the new fields. A cargo check --workspace --all-targets surfaced them all:

  • ResolvedNpmRc gained trust_policy -> added Default::default() in
    cli/rt/run.rs (x2) and cli/tools/installer/global.rs.
  • SerializedNpmResolutionSnapshotPackage gained trust -> added
    Default::default() in cli/rt/binary.rs and libs/eszip/v2.rs.
  • deno_lockfile::NpmPackageInfo gained trust -> added trust: 0 in
    cli/tools/installer/global.rs.

Verified locally that the full workspace and all test targets compile, and
that tools/format.js and tools/lint.js (clippy + JS) pass. No behavior
changes; this is purely the merge plus the mechanical field additions.

@bartlomieju bartlomieju changed the title feat(npm): publishing-trust ranking + no-downgrade trust policy feat(npm): publishing-trust ranking and no-downgrade trust policy Jun 23, 2026
Self-review follow-ups on the publishing-trust work now that min-release-age
makes Deno fetch the full packument by default.

- The full packument carries large objects in `approver`,
  `_npmUser.trustedPublisher` and `dist.attestations.provenance`, but only
  their presence is a trust signal. Store them as a compact presence marker
  (serializes as `true`, drops the heavy sub-fields) so caching the full
  packument by default stays cheap. Drops the unused `_npmUser.name` too.

- When the no-downgrade policy is the only reason resolution fails, return a
  dedicated `TrustPolicyDowngrade` error explaining the rejection and how to
  override, instead of a generic "version not matched".

- Document the lockfile-baseline ordering assumption: it fails open (the
  policy becomes a no-op, never a wrong rejection) if the lockfile is absent.

- Add an end-to-end spec test (cache keeps a compact marker, lockfile records
  the trust rank) plus unit coverage for compact serialization and the
  downgrade error.
@bartlomieju

Copy link
Copy Markdown
Member Author

Self-review pass (on top of the main merge):

Cache cost of the full packument. Since min-release-age already pulls the
full packument by default, the trust signals are on the wire for everyone, so
the "enabling the policy forces a heavier fetch" limitation is mostly moot. The
flip side was that the newly-retained signal objects would bloat the cached
registry.json for all users. We only ever check whether approver,
_npmUser.trustedPublisher and dist.attestations.provenance exist, so they
are now collapsed to a one-byte presence marker (serializes as true, heavy
sub-fields dropped) before caching. Unused _npmUser.name is dropped too.

Clearer failure. When no-downgrade is the sole reason a resolution fails,
the resolver returns a dedicated TrustPolicyDowngrade error explaining the
rejection and how to override, instead of the generic "version not matched"
(which previously even mis-attributed it to the min-release-age date).

Baseline timing. Documented in the factory that the lockfile baseline read
fails open: a missing/late lockfile makes the policy a no-op (it can never
wrongly reject, only fail to protect), so the ordering assumption is now
explicit rather than a silent footgun.

Spec test. Added tests/specs/npm/trust_signals: installs a fixture
carrying _npmUser.trustedPublisher, asserts the cached packument keeps only a
compact marker, and that the lockfile records the derived trust rank.

Still open from the original list: live approver data, gating explicit
non-latest dist-tags, and the provenance-vs-trusted-publishing ordering.

Rework the publishing-trust policy to mirror pnpm's trustChecks.ts
instead of the earlier lockfile-baseline prototype.

- Tiered, mutually-exclusive trust evidence (TrustEvidence): staged
  publish > trusted publisher (only when backed by a provenance
  attestation) > provenance. A trustedPublisher flag without provenance
  no longer counts on its own, matching pnpm's getTrustEvidence.
- The staged-publish signal lives under _npmUser.approver (the real
  packument shape), not as a top-level field.
- no-downgrade now compares a resolved version against the strongest
  evidence among earlier-published versions of the same package, by
  publish date (not semver), and errors on a downgrade rather than
  silently falling back to an older version. The per-entry lockfile
  trust field and the locked-trust baseline are dropped entirely; pnpm
  does not persist a trust rank.
- Add the trust-policy-ignore-after parity setting (minutes), turned
  into an absolute cutoff in the factory so the resolver stays
  clock-free.
- Add an end-to-end spec test for the no-downgrade rejection. The test
  npm registry now preserves dist.attestations from fixtures so
  provenance is exercisable.

trust-policy-exclude is left as a follow-up (needs .npmrc array
support).
Implements the deferred `trust-policy-exclude[]` follow-up. Repeated
`trust-policy-exclude[]=<package>` entries in .npmrc exempt the named
packages from the `no-downgrade` trust policy; an excluded package
resolves as if the policy were off. Mirrors pnpm's `trustPolicyExclude`.

The npmrc ini parser already produced `Key::Array` entries, but
NpmRc::parse only consumed `Key::Plain`, so the array values were
dropped. This wires up the `Key::Array` branch for that one key. Home
and project excludes are unioned (project entries first).

Covered by an npmrc parse test, a resolver unit test (excluded package
installs, unrelated exclude does not exempt), and an end-to-end spec
test that an otherwise-rejected downgrade installs when excluded.
# Conflicts:
#	libs/npmrc/lib.rs
#	libs/resolver/npmrc.rs
clippy::disallowed_types flagged the std::sync::Arc::new for the exclude
set; allow it with a reason, matching the sibling overrides field.
@bartlomieju
bartlomieju merged commit 702cafb into main Jun 25, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the feat/npm-trust-policy branch June 25, 2026 08:47
@earthlingdavey

Copy link
Copy Markdown

Strong feature 😎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants