Skip to content

Commit 702cafb

Browse files
authored
feat(npm): publishing-trust ranking and no-downgrade trust policy (#34927)
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.
1 parent 90d901d commit 702cafb

36 files changed

Lines changed: 1018 additions & 7 deletions

File tree

cli/factory.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -574,11 +574,20 @@ impl CliFactory {
574574
self.services.npm_installer_factory.get_or_try_init(|| {
575575
let cli_options = self.cli_options()?;
576576
let resolver_factory = self.resolver_factory()?;
577-
let needs_full_packument = resolver_factory
578-
.minimum_dependency_age_config()
577+
// the `no-downgrade` trust policy reads `_npmUser`/`attestations`, which
578+
// are only present in the full packument
579+
let needs_full_packument_for_trust = resolver_factory
580+
.workspace_factory()
581+
.npmrc()
579582
.ok()
580-
.and_then(|c| c.age.as_ref().and_then(|d| d.into_option()))
581-
.is_some();
583+
.map(|rc| rc.trust_policy != deno_npmrc::TrustPolicyConfig::Off)
584+
.unwrap_or(false);
585+
let needs_full_packument = needs_full_packument_for_trust
586+
|| resolver_factory
587+
.minimum_dependency_age_config()
588+
.ok()
589+
.and_then(|c| c.age.as_ref().and_then(|d| d.into_option()))
590+
.is_some();
582591
Ok(CliNpmInstallerFactory::new(
583592
resolver_factory.clone(),
584593
Arc::new(CliNpmCacheHttpClient::new(

cli/lsp/language_server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ impl Inner {
637637
link_packages: Default::default(),
638638
newest_dependency_date_options: Default::default(),
639639
overrides: Default::default(),
640+
trust_policy: Default::default(),
640641
}),
641642
);
642643
let config = Config::default();
@@ -864,6 +865,7 @@ impl Inner {
864865
link_packages: Default::default(),
865866
newest_dependency_date_options: Default::default(),
866867
overrides: Default::default(),
868+
trust_policy: Default::default(),
867869
}),
868870
);
869871
self.performance.measure(mark);

cli/lsp/resolver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,7 @@ impl<'a> ResolverFactory<'a> {
11171117
link_packages: link_packages.0.clone(),
11181118
newest_dependency_date_options: Default::default(),
11191119
overrides: Arc::new(overrides),
1120+
trust_policy: Default::default(),
11201121
});
11211122
let npm_resolution_installer = Arc::new(NpmResolutionInstaller::new(
11221123
Default::default(),

cli/rt/run.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,9 @@ pub async fn run_with_options(
13601360
scopes: Default::default(),
13611361
registry_configs: Default::default(),
13621362
min_release_age_days: None,
1363+
trust_policy: Default::default(),
1364+
trust_policy_ignore_after_minutes: None,
1365+
trust_policy_exclude: Vec::new(),
13631366
});
13641367
let npm_cache_dir = Arc::new(NpmCacheDir::new(
13651368
&sys,
@@ -1951,6 +1954,9 @@ fn create_default_npmrc() -> Arc<ResolvedNpmRc> {
19511954
scopes: Default::default(),
19521955
registry_configs: Default::default(),
19531956
min_release_age_days: None,
1957+
trust_policy: Default::default(),
1958+
trust_policy_ignore_after_minutes: None,
1959+
trust_policy_exclude: Vec::new(),
19541960
})
19551961
}
19561962

cli/tools/installer/global.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,6 +2451,9 @@ mod tests {
24512451
scopes,
24522452
registry_configs: Default::default(),
24532453
min_release_age_days: None,
2454+
trust_policy: Default::default(),
2455+
trust_policy_ignore_after_minutes: None,
2456+
trust_policy_exclude: Vec::new(),
24542457
})
24552458
}
24562459

libs/npm/benches/bench.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ async fn run_resolver_and_get_snapshot(
198198
link_packages: Default::default(),
199199
newest_dependency_date_options: Default::default(),
200200
overrides: Default::default(),
201+
trust_policy: Default::default(),
201202
};
202203
let result = snapshot
203204
.add_pkg_reqs(

libs/npm/examples/min_repro_solver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ async fn run_resolver_and_get_snapshot(
299299
link_packages: Default::default(),
300300
newest_dependency_date_options: Default::default(),
301301
overrides: Default::default(),
302+
trust_policy: Default::default(),
302303
};
303304
let result = snapshot
304305
.add_pkg_reqs(

libs/npm/registry.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,81 @@ pub struct NpmPackageVersionInfo {
246246
#[serde(default, skip_serializing_if = "Option::is_none")]
247247
#[serde(deserialize_with = "deserializers::string")]
248248
pub deprecated: Option<String>,
249+
/// The `_npmUser` field from the full packument. Identifies who published
250+
/// the version and carries the `trustedPublisher` (OIDC trusted publishing)
251+
/// and `approver` (staged publish) trust signals. Only present in the full
252+
/// packument.
253+
#[serde(
254+
default,
255+
rename = "_npmUser",
256+
skip_serializing_if = "Option::is_none"
257+
)]
258+
pub npm_user: Option<NpmUser>,
259+
}
260+
261+
/// A presence marker for a registry field whose mere existence is the signal
262+
/// we care about (`_npmUser.approver`, `_npmUser.trustedPublisher`,
263+
/// `dist.attestations.provenance`). The full packument carries large objects
264+
/// here, but [`NpmPackageVersionInfo::get_trust_evidence`] only checks whether
265+
/// they are present, so this discards the contents on deserialize and
266+
/// re-serializes compactly as `true`. That keeps the cached packument small
267+
/// even though `min-release-age` makes Deno fetch the full packument by
268+
/// default.
269+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270+
pub struct Present;
271+
272+
impl Serialize for Present {
273+
fn serialize<S: serde::Serializer>(
274+
&self,
275+
serializer: S,
276+
) -> Result<S::Ok, S::Error> {
277+
serializer.serialize_bool(true)
278+
}
279+
}
280+
281+
impl<'de> Deserialize<'de> for Present {
282+
fn deserialize<D: serde::Deserializer<'de>>(
283+
deserializer: D,
284+
) -> Result<Self, D::Error> {
285+
serde::de::IgnoredAny::deserialize(deserializer)?;
286+
Ok(Present)
287+
}
249288
}
250289

251290
impl NpmPackageVersionInfo {
291+
/// The strongest publishing-trust evidence this version exposes, derived
292+
/// from registry metadata signals. Used by the `no-downgrade` trust policy.
293+
///
294+
/// Mirrors pnpm's
295+
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/main/resolving/npm-resolver/src/trustChecks.ts).
296+
/// The tiers are mutually exclusive: a staged publish (`_npmUser.approver`)
297+
/// is strongest, then trusted publishing (`_npmUser.trustedPublisher`)
298+
/// *backed by* a provenance attestation, then a provenance attestation on
299+
/// its own. A `trustedPublisher` flag without a provenance attestation is
300+
/// not counted: on its own it is just metadata a future staged-publish flow
301+
/// could mint, so it only counts as the stronger signal when the version
302+
/// also shipped provenance.
303+
pub fn get_trust_evidence(&self) -> Option<TrustEvidence> {
304+
let npm_user = self.npm_user.as_ref();
305+
if npm_user.is_some_and(|u| u.approver.is_some()) {
306+
return Some(TrustEvidence::StagedPublish);
307+
}
308+
let has_provenance = self
309+
.dist
310+
.as_ref()
311+
.and_then(|d| d.attestations.as_ref())
312+
.is_some_and(|a| a.provenance.is_some());
313+
let has_trusted_publisher =
314+
npm_user.is_some_and(|u| u.trusted_publisher.is_some());
315+
if has_trusted_publisher && has_provenance {
316+
return Some(TrustEvidence::TrustedPublisher);
317+
}
318+
if has_provenance {
319+
return Some(TrustEvidence::Provenance);
320+
}
321+
None
322+
}
323+
252324
/// Helper for getting the bundle dependencies.
253325
///
254326
/// Unfortunately due to limitations in serde, it's not
@@ -445,6 +517,82 @@ pub struct NpmPackageVersionDistInfo {
445517
pub(crate) shasum: Option<String>,
446518
#[serde(default, skip_serializing_if = "Option::is_none")]
447519
pub(crate) integrity: Option<String>,
520+
/// Cryptographic attestations for this version (provenance, publish).
521+
/// Only present in the full packument.
522+
#[serde(default, skip_serializing_if = "Option::is_none")]
523+
pub attestations: Option<NpmAttestations>,
524+
}
525+
526+
/// The `_npmUser` object from the full packument. Only the presence of
527+
/// `trustedPublisher` and `approver` are trust signals; the `name` and other
528+
/// fields are dropped on deserialize to keep the cached packument small.
529+
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
530+
pub struct NpmUser {
531+
/// Present when the version was published via npm trusted publishing
532+
/// (OIDC). Its contents identify the CI provider and workflow.
533+
#[serde(
534+
default,
535+
rename = "trustedPublisher",
536+
skip_serializing_if = "Option::is_none"
537+
)]
538+
pub trusted_publisher: Option<Present>,
539+
/// Present when the version went through npm staged publishing: a maintainer
540+
/// approved it with a live 2FA challenge before it became installable. This
541+
/// is the strongest publishing-trust signal.
542+
/// See https://docs.npmjs.com/staged-publishing/
543+
#[serde(default, skip_serializing_if = "Option::is_none")]
544+
pub approver: Option<Present>,
545+
}
546+
547+
/// The `dist.attestations` object from the full packument.
548+
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
549+
pub struct NpmAttestations {
550+
/// SLSA provenance attestation linking the package to the source commit and
551+
/// build. Present when the version was published with `--provenance`.
552+
#[serde(default, skip_serializing_if = "Option::is_none")]
553+
pub provenance: Option<Present>,
554+
}
555+
556+
/// The strongest publishing-trust evidence a package version exposes, derived
557+
/// from registry metadata. Variants are declared weakest-first so the derived
558+
/// `Ord` matches the trust rank. The `no-downgrade` trust policy refuses to
559+
/// resolve a version whose evidence is weaker than the strongest evidence on
560+
/// any earlier-published version of the same package.
561+
///
562+
/// "No evidence" is represented as `Option::None` rather than a variant here,
563+
/// matching pnpm's `undefined`; compare ranks via
564+
/// `Option::map_or(0, TrustEvidence::rank)`.
565+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
566+
pub enum TrustEvidence {
567+
/// `dist.attestations.provenance` is set (published with `--provenance`).
568+
Provenance,
569+
/// `_npmUser.trustedPublisher` is set alongside a provenance attestation:
570+
/// published via OIDC-backed trusted publishing.
571+
TrustedPublisher,
572+
/// `_npmUser.approver` is set: a staged publish requiring a 2FA approval.
573+
/// The strongest signal.
574+
StagedPublish,
575+
}
576+
577+
impl TrustEvidence {
578+
/// The numeric trust rank, mirroring pnpm's `TRUST_RANK` weights. Higher is
579+
/// more trusted; "no evidence" is rank `0`.
580+
pub fn rank(self) -> u8 {
581+
match self {
582+
TrustEvidence::Provenance => 1,
583+
TrustEvidence::TrustedPublisher => 2,
584+
TrustEvidence::StagedPublish => 3,
585+
}
586+
}
587+
588+
/// Human-readable description for diagnostics.
589+
pub fn pretty(self) -> &'static str {
590+
match self {
591+
TrustEvidence::Provenance => "provenance attestation",
592+
TrustEvidence::TrustedPublisher => "trusted publisher",
593+
TrustEvidence::StagedPublish => "staged publish",
594+
}
595+
}
448596
}
449597

450598
impl NpmPackageVersionDistInfo {
@@ -1108,12 +1256,101 @@ mod test {
11081256
tarball: "value".to_string(),
11091257
shasum: None,
11101258
integrity: None,
1259+
attestations: None,
11111260
}),
11121261
..Default::default()
11131262
}
11141263
);
11151264
}
11161265

1266+
#[test]
1267+
fn trust_evidence_ranking() {
1268+
fn trust_of(json: &str) -> Option<TrustEvidence> {
1269+
let info: NpmPackageVersionInfo = serde_json::from_str(json).unwrap();
1270+
info.get_trust_evidence()
1271+
}
1272+
1273+
// plain token publish: no signals
1274+
assert_eq!(trust_of(r#"{ "version": "1.0.0" }"#), None);
1275+
1276+
// provenance attestation only
1277+
assert_eq!(
1278+
trust_of(
1279+
r#"{ "version": "1.0.0", "dist": { "tarball": "t", "attestations": { "provenance": { "predicateType": "x" } } } }"#,
1280+
),
1281+
Some(TrustEvidence::Provenance)
1282+
);
1283+
1284+
// trusted publishing WITHOUT a provenance attestation is not counted: on
1285+
// its own the flag is just metadata, mirroring pnpm's getTrustEvidence.
1286+
assert_eq!(
1287+
trust_of(
1288+
r#"{ "version": "1.0.0", "_npmUser": { "name": "ci", "trustedPublisher": { "id": "github" } } }"#,
1289+
),
1290+
None
1291+
);
1292+
1293+
// trusted publishing backed by a provenance attestation
1294+
assert_eq!(
1295+
trust_of(
1296+
r#"{ "version": "1.0.0", "_npmUser": { "trustedPublisher": { "id": "github" } }, "dist": { "tarball": "t", "attestations": { "provenance": {} } } }"#,
1297+
),
1298+
Some(TrustEvidence::TrustedPublisher)
1299+
);
1300+
1301+
// staged publish (human-approved via 2FA) is strongest
1302+
assert_eq!(
1303+
trust_of(
1304+
r#"{ "version": "1.0.0", "_npmUser": { "approver": { "name": "maintainer" } } }"#,
1305+
),
1306+
Some(TrustEvidence::StagedPublish)
1307+
);
1308+
1309+
// ranks are ordered: provenance < trusted publisher < staged publish
1310+
assert!(TrustEvidence::Provenance < TrustEvidence::TrustedPublisher);
1311+
assert!(TrustEvidence::TrustedPublisher < TrustEvidence::StagedPublish);
1312+
assert_eq!(TrustEvidence::Provenance.rank(), 1);
1313+
assert_eq!(TrustEvidence::TrustedPublisher.rank(), 2);
1314+
assert_eq!(TrustEvidence::StagedPublish.rank(), 3);
1315+
}
1316+
1317+
#[test]
1318+
fn trust_signals_serialize_compactly() {
1319+
// The full packument carries large objects in `_npmUser.approver`,
1320+
// `_npmUser.trustedPublisher` and `dist.attestations.provenance`, but we
1321+
// only care that they exist. Re-serializing (as the registry cache does)
1322+
// must collapse them to compact markers and still preserve the trust
1323+
// evidence, so caching the full packument by default stays cheap.
1324+
let info: NpmPackageVersionInfo = serde_json::from_str(
1325+
r#"{
1326+
"version": "1.0.0",
1327+
"_npmUser": { "name": "ci", "trustedPublisher": { "id": "github", "oidcConfigId": "abc" } },
1328+
"dist": { "tarball": "t", "attestations": { "url": "https://example/att", "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } } }
1329+
}"#,
1330+
)
1331+
.unwrap();
1332+
1333+
let serialized = serde_json::to_string(&info).unwrap();
1334+
assert!(
1335+
serialized.contains(r#""trustedPublisher":true"#),
1336+
"{serialized}"
1337+
);
1338+
assert!(serialized.contains(r#""provenance":true"#), "{serialized}");
1339+
// none of the dropped sub-fields survive
1340+
assert!(!serialized.contains("oidcConfigId"), "{serialized}");
1341+
assert!(!serialized.contains("predicateType"), "{serialized}");
1342+
assert!(!serialized.contains("\"name\""), "{serialized}");
1343+
1344+
// and the trust evidence round-trips through the slimmed form
1345+
let reparsed: NpmPackageVersionInfo =
1346+
serde_json::from_str(&serialized).unwrap();
1347+
assert_eq!(reparsed.get_trust_evidence(), info.get_trust_evidence());
1348+
assert_eq!(
1349+
reparsed.get_trust_evidence(),
1350+
Some(TrustEvidence::TrustedPublisher)
1351+
);
1352+
}
1353+
11171354
#[test]
11181355
fn deserializes_serializes_time() {
11191356
let text = r#"{ "name": "package", "versions": {}, "time": { "created": "2015-11-07T19:15:58.747Z", "1.0.0": "2015-11-07T19:15:58.747Z" } }"#;
@@ -1148,6 +1385,7 @@ mod test {
11481385
tarball: "value".to_string(),
11491386
shasum: Some("test".to_string()),
11501387
integrity: None,
1388+
attestations: None,
11511389
}),
11521390
dependencies: HashMap::new(),
11531391
deprecated: Some("aa".to_string()),
@@ -1185,6 +1423,7 @@ mod test {
11851423
tarball: "value".to_string(),
11861424
shasum: Some("test".to_string()),
11871425
integrity: None,
1426+
attestations: None,
11881427
}),
11891428
dependencies: HashMap::new(),
11901429
deprecated: None,
@@ -1564,6 +1803,7 @@ mod test {
15641803
scripts: Default::default(),
15651804
has_install_script: Default::default(),
15661805
deprecated: Default::default(),
1806+
npm_user: Default::default(),
15671807
};
15681808
let text = serde_json::to_string(&data).unwrap();
15691809
assert_eq!(text, r#"{"version":"1.0.0"}"#);
@@ -1575,6 +1815,7 @@ mod test {
15751815
tarball: "test".to_string(),
15761816
shasum: None,
15771817
integrity: None,
1818+
attestations: None,
15781819
};
15791820
let text = serde_json::to_string(&data).unwrap();
15801821
assert_eq!(text, r#"{"tarball":"test"}"#);

0 commit comments

Comments
 (0)