feat(npm): publishing-trust ranking and no-downgrade trust policy#34927
Conversation
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.
Prototype limitations / open questionsPosting these up front so reviewers know where the rough edges are.
Verified working: against the live registry, installing |
|
Synced this branch with The branch had drifted 332 commits behind The merge resolved cleanly textually, but
Verified locally that the full workspace and all test targets compile, and |
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.
|
Self-review pass (on top of the Cache cost of the full packument. Since Clearer failure. When Baseline timing. Documented in the factory that the lockfile baseline read Spec test. Added Still open from the original list: live |
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.
|
Strong feature 😎 |
This teaches Deno's npm resolver about how a package version was
published and adds an opt-in
no-downgradetrust policy, followingpnpm'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 provenanceattestation (
dist.attestations.provenance), or went through stagedpublishing (
_npmUser.approver, a maintainer approving with a live 2FAchallenge). 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
trustedPublisherflag without provenance is deliberately not counted,since on its own it is just metadata a future staged-publish flow could
mint.
The
no-downgradepolicy, enabled withtrust-policy=no-downgradein.npmrc, refuses to resolve a version whose trust evidence is weakerthan 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-agealready makes Deno dothat by default, so in the common case there is no extra fetch. To keep
the cached
registry.jsonsmall, the never-read signal objects(
_npmUser.approver,_npmUser.trustedPublisher,dist.attestations.provenance) are reduced to a one-byte presencemarker before the packument is cached.
A
trust-policy-ignore-aftersetting (in minutes, mirroring pnpm'strustPolicyIgnoreAfter) skips the check for versions published morethan 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.attestationsfromfixtures 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'strustPolicyExclude) exempts named packages from the policy: repeatedtrust-policy-exclude[]=<package>entries in.npmrclist packagesthat 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-downgradeby default would turn legitimatetrust-evidence gaps into hard install failures; it can be revisited
once adoption is high.