Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion site/content/docs/install/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,19 @@ Frozen reinstalls — `nub ci`, `--frozen-lockfile`, a teammate's clone — inhe

### Trust downgrades

Nub also weighs trust *evidence* across a package's release history — OIDC provenance, a trusted publisher, a staged-publish approval. A resolved version that carries weaker evidence than an earlier-published version of the same package stops the install with `ERR_NUB_TRUST_DOWNGRADE`, because a maintainer's pipeline that suddenly publishes without the attestation it used to carry is the shape of a token-theft supply-chain attack.
Nub also weighs trust *evidence* across a package's release history — OIDC provenance, a trusted publisher, a staged-publish approval. A resolved version that carries weaker evidence than an earlier-published version of the same package is refused, because a maintainer's pipeline that suddenly publishes without the attestation it used to carry is the shape of a token-theft supply-chain attack.

Refusing a version is not the same as failing the install. Nub keeps looking through the versions the range admits and takes the best one that still carries its evidence, naming what it skipped and why:

```console
# captured: fast-glob@^3.3.2 → @nodelib/fs.walk → fastq, whose 1.20.2 was hand-published
$ nub install
WARN skipped fastq@1.20.2 (trustPolicy=no-downgrade): earlier published version 1.20.0 had trusted publisher but this version has no trust evidence; resolved to fastq@1.20.1 instead code=WARN_NUB_TRUST_DOWNGRADE_SKIPPED
```

The substitution stays inside the declared range and never crosses the refused version — down to an older release for an ordinary pick, and up to a newer one when `resolution-mode=time-based` resolves a direct dependency to its range floor. When nothing the range admits clears the check, the install stops with `ERR_NUB_TRUST_DOWNGRADE`.

A lockfile names one exact version rather than a range, so there is nothing to walk. Any install that reuses an undrifted lockfile — `nub ci`, `--frozen-lockfile`, or a plain `nub install` — still aborts on a pin the check refuses, because only a fresh resolve has a range to substitute within. The remedy is `nub install --no-frozen-lockfile`.

The comparison is by publish date, so a legitimate maintenance release on an older major — shipped after a newer major adopted provenance — can trip it. Nub exempts any version older than 14 days, so an aged, un-yanked backport resolves while a freshly published downgrade is still checked against the full history. Widen the window, clear a single package, or turn the check off in `.npmrc`:

Expand Down
7 changes: 7 additions & 0 deletions vendor/aube/crates/aube-codes/src/warnings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub const WARN_AUBE_UNSUPPORTED_PLATFORM_INSTALL: &str = "WARN_AUBE_UNSUPPORTED_
#[rustfmt::skip] pub const WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION: &str = "WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION";
pub const WARN_AUBE_EXOTIC_SUBDEP_SKIPPED: &str = "WARN_AUBE_EXOTIC_SUBDEP_SKIPPED";
pub const WARN_AUBE_PEER_DEDUPE_COLLISION: &str = "WARN_AUBE_PEER_DEDUPE_COLLISION";
#[rustfmt::skip] pub const WARN_AUBE_TRUST_DOWNGRADE_SKIPPED: &str = "WARN_AUBE_TRUST_DOWNGRADE_SKIPPED";

// ── lockfile ────────────────────────────────────────────────────────
pub const WARN_AUBE_LOCKFILE_MERGE_CONFLICT: &str = "WARN_AUBE_LOCKFILE_MERGE_CONFLICT";
Expand Down Expand Up @@ -542,6 +543,12 @@ pub const ALL: &[CodeMeta] = &[
description: "An optional or peer dep used an exotic specifier and was skipped under `blockExoticSubdeps=true`.",
exit_code: None,
},
CodeMeta {
name: WARN_AUBE_TRUST_DOWNGRADE_SKIPPED,
category: category::RESOLVER,
description: "`trustPolicy=no-downgrade` refused the version the range would otherwise resolve to, so an older satisfying version that keeps its trust evidence was installed instead.",
exit_code: None,
},
CodeMeta {
name: WARN_AUBE_PEER_DEDUPE_COLLISION,
category: category::RESOLVER,
Expand Down
95 changes: 88 additions & 7 deletions vendor/aube/crates/aube-resolver/src/resolve/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ pub(crate) struct ResolveDriver<'a> {
/// doesn't crash the wrong task. Checked after the fetch-wait loop
/// to decide skip (optional) vs propagate (required).
failed_fetches: FxHashMap<String, Error>,
/// `name@refused -> substitute` for every pick the trust gate refused and
/// the resolver then backtracked past, so the substitution notice is
/// printed once per outcome rather than once per parent that depends on
/// the package.
trust_repicks: FxHashSet<String>,
/// Catalog picks gathered as the BFS rewrites `catalog:` task
/// ranges. Outer key: catalog name. Inner: package name → spec.
catalog_picks: BTreeMap<String, BTreeMap<String, String>>,
Expand Down Expand Up @@ -386,6 +391,7 @@ impl<'a> ResolveDriver<'a> {
resolved_times: BTreeMap::new(),
skipped_optional_dependencies: BTreeMap::new(),
failed_fetches: FxHashMap::default(),
trust_repicks: FxHashSet::default(),
catalog_picks: BTreeMap::new(),
deferred_transitives: Vec::new(),
deferred_auto_peers: Vec::new(),
Expand Down Expand Up @@ -1113,7 +1119,7 @@ impl<'a> ResolveDriver<'a> {
let packument = self.resolver.cache.get(&registry_name).ok_or_else(|| {
Error::Registry(registry_name.clone(), "packument not in cache".to_string())
})?;
let picked_ref = prefer_non_vulnerable_pick(
let mut picked_ref = prefer_non_vulnerable_pick(
task.registry_name(),
packument,
&task.range,
Expand All @@ -1131,20 +1137,81 @@ impl<'a> ResolveDriver<'a> {
// compare. The check needs the live packument's `time`
// map and all version metadata, both of which are still
// in scope here from L1191.
if self.resolver.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade {
crate::trust::check_no_downgrade(
if self.resolver.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade
&& let Err(err) = crate::trust::check_no_downgrade(
packument,
&picked_ref.version,
picked_ref,
&self.resolver.dependency_policy.trust_policy_exclude,
self.resolver.dependency_policy.trust_policy_ignore_after,
)
.map_err(|e| match e {
crate::trust::TrustCheckError::Downgrade(d) => Error::TrustDowngrade(Box::new(d)),
{
let downgrade = match err {
crate::trust::TrustCheckError::Downgrade(d) => d,
// A packument with no `time` entry for the picked version is a
// metadata anomaly, not a refused candidate: there is nothing
// to backtrack THROUGH, since the same missing-time shape is
// what every other candidate would be judged on. Stays fatal.
crate::trust::TrustCheckError::MissingTime(d) => {
Error::TrustCheckMissingTime(Box::new(d))
return Err(Error::TrustCheckMissingTime(Box::new(d)));
}
})?;
};
// Backtrack the way the age gate already does. `pick_version`
// treats a too-new release as "keep looking down the range", so a
// package that publishes one version manually between two attested
// ones deadlocks the two gates against each other: the age gate
// walks down to the manual publish and the trust gate refuses it,
// while a fully-signed release one version lower satisfies the
// range and is never considered. Refusing is still the outcome
// when NO satisfying version clears both gates, so neither gate is
// weakened — only the order of "refuse" and "keep looking" changes.
match crate::trust::repick_past_downgrade(
packument,
task.registry_name(),
&task.range,
&downgrade.picked_version,
pick_lowest,
cutoff_for_pkg,
exempt_cutoff,
strict,
&self.resolver.dependency_policy.trust_policy_exclude,
self.resolver.dependency_policy.trust_policy_ignore_after,
&self.resolver.vulnerable_ranges,
is_age_exempt,
) {
Some(meta) => {
// Never silent: the user asked for a range and is getting
// something other than its head, for a supply-chain reason
// they may want to act on. Deduped per refusal AND
// substitute, because the same package resolves once per
// parent that depends on it and two disjunctive ranges can
// share a refused head while landing on different
// substitutes — keying on the refusal alone would suppress
// the second and leave the printed line naming a version
// one of the parents is not on.
if self.trust_repicks.insert(format!(
"{}@{} -> {}",
downgrade.name, downgrade.picked_version, meta.version
)) {
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_TRUST_DOWNGRADE_SKIPPED,
"skipped {}@{} (trustPolicy=no-downgrade): earlier published version \
{} had {} but this version has {}; resolved to {}@{} instead",
downgrade.name,
downgrade.picked_version,
downgrade.prior_version,
downgrade.prior_evidence.label(),
downgrade
.current_evidence
.map_or("no trust evidence", |e| e.label()),
downgrade.name,
meta.version,
);
}
picked_ref = meta;
}
None => return Err(Error::TrustDowngrade(Box::new(downgrade))),
}
}

// Clone the picked metadata into an owned value so we can
Expand Down Expand Up @@ -1179,6 +1246,20 @@ impl<'a> ResolveDriver<'a> {
&& task.range == "latest"
&& let Some(latest) = packument.dist_tags.get("latest")
&& latest != &picked_ref.version
// The trust gate can move a pick below `latest` too, and this
// notice names `minimumReleaseAge` by wording. Fire it only when
// `latest` genuinely fails the age cutoff, so a trust re-pick does
// not get reported as an age-gate fallback.
&& !crate::semver_util::version_clears_cutoff(
packument,
latest,
if is_age_exempt(latest, None) {
exempt_cutoff
} else {
cutoff_for_pkg
},
strict,
)
{
aube_util::record_age_gate_downgrade(task.registry_name(), &picked_ref.version, latest);
}
Expand Down
211 changes: 211 additions & 0 deletions vendor/aube/crates/aube-resolver/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2807,6 +2807,217 @@ async fn trust_policy_no_downgrade_blocks_downgraded_install() {
let _ = std::fs::remove_dir_all(base);
}

/// The gate deadlock this backtracking exists to break, end to end through the
/// driver. `foo` mirrors `fastq`: two attested releases, then a hand-published
/// 2.0.2 that npm tagged `latest`. `^2.0.0` resolves to that head, the trust
/// gate refuses it, and before the fix the whole install aborted with a signed
/// 2.0.1 sitting inside the range. Now the resolver keeps walking down.
///
/// The second half is the invariant: `>=2.0.2` admits nothing but the refused
/// version, so the refusal still stands. Backtracking changes which version is
/// installed, never whether an untrusted one can be.
#[tokio::test]
async fn trust_policy_no_downgrade_backtracks_to_a_still_trusted_version() {
use aube_registry::Attestations;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

let mut packument = make_packument("foo", &["1.0.0", "2.0.0", "2.0.1", "2.0.2"], "2.0.2");
for (ver, time) in [
("1.0.0", "2025-01-01T00:00:00.000Z"),
("2.0.0", "2025-02-01T00:00:00.000Z"),
("2.0.1", "2025-02-02T00:00:00.000Z"),
("2.0.2", "2025-03-01T00:00:00.000Z"),
] {
packument.time.insert(ver.to_string(), time.to_string());
}
for ver in ["2.0.0", "2.0.1"] {
packument
.versions
.get_mut(ver)
.unwrap()
.dist
.as_mut()
.unwrap()
.attestations = Some(Attestations {
provenance: Some(serde_json::json!({
"predicateType": "https://slsa.dev/provenance/v1"
})),
});
}
let body = serde_json::to_vec(&packument).unwrap();

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
});
}
});

let base = std::env::temp_dir().join(format!(
"aube-resolver-trust-backtrack-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(base.join("packuments")).unwrap();
std::fs::create_dir_all(base.join("packuments-full")).unwrap();

let resolve_range = |range: &str| {
let registry = registry.clone();
let base = base.clone();
let range = range.to_string();
async move {
let policy = crate::DependencyPolicy {
trust_policy: crate::TrustPolicy::NoDowngrade,
..crate::DependencyPolicy::default()
};
let mut resolver = Resolver::new(Arc::new(aube_registry::client::RegistryClient::new(
&registry,
)))
.with_packument_cache(base.join("packuments"))
.with_packument_full_cache(base.join("packuments-full"))
.with_dependency_policy(policy);
let mut manifest = PackageJson::default();
manifest.dependencies.insert("foo".to_string(), range);
resolver.resolve(&manifest, None).await
}
};

let graph = resolve_range("^2.0.0")
.await
.expect("the range still admits a signed 2.0.1");
assert!(
graph_has_package(&graph, "foo", "2.0.1"),
"must fall back to the newest release that kept its provenance"
);
assert!(!graph_has_package(&graph, "foo", "2.0.2"));

match resolve_range(">=2.0.2")
.await
.expect_err("nothing in this range keeps its evidence")
{
Error::TrustDowngrade(d) => assert_eq!(d.picked_version, "2.0.2"),
other => panic!("expected TrustDowngrade, got {other:?}"),
}

server.abort();
let _ = std::fs::remove_dir_all(base);
}

/// The `latest`-was-steered notice names `minimumReleaseAge` by wording, so a
/// pick the TRUST gate moved must not be reported under it. `bar@1.1.0` is
/// mature and tagged `latest`; only its missing attestation pushes the resolve
/// down to 1.0.0, and dlx — the only consumer of this notice — would otherwise
/// tell the user their tool is older because it was published too recently.
#[tokio::test]
async fn a_trust_repick_is_not_reported_as_an_age_gate_fallback() {
use aube_registry::Attestations;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

const NAME: &str = "trust-repick-notice";
let mut packument = make_packument(NAME, &["1.0.0", "1.1.0"], "1.1.0");
packument
.time
.insert("1.0.0".to_string(), "2025-01-01T00:00:00.000Z".to_string());
packument
.time
.insert("1.1.0".to_string(), "2025-02-01T00:00:00.000Z".to_string());
packument
.versions
.get_mut("1.0.0")
.unwrap()
.dist
.as_mut()
.unwrap()
.attestations = Some(Attestations {
provenance: Some(serde_json::json!({
"predicateType": "https://slsa.dev/provenance/v1"
})),
});
let body = serde_json::to_vec(&packument).unwrap();

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let registry = format!("http://{}/", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
break;
};
let body = body.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 2048];
let _ = socket.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let _ = socket.write_all(response.as_bytes()).await;
let _ = socket.write_all(&body).await;
});
}
});

let base = std::env::temp_dir().join(format!(
"aube-resolver-trust-notice-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(base.join("packuments")).unwrap();
std::fs::create_dir_all(base.join("packuments-full")).unwrap();

let policy = crate::DependencyPolicy {
trust_policy: crate::TrustPolicy::NoDowngrade,
..crate::DependencyPolicy::default()
};
let mut resolver = Resolver::new(Arc::new(aube_registry::client::RegistryClient::new(
&registry,
)))
.with_packument_cache(base.join("packuments"))
.with_packument_full_cache(base.join("packuments-full"))
.with_dependency_policy(policy)
.with_minimum_release_age(Some(MinimumReleaseAge {
minutes: 60,
strict: true,
..Default::default()
}));
let mut manifest = PackageJson::default();
manifest
.dependencies
.insert(NAME.to_string(), "latest".to_string());

aube_util::arm_age_gate_downgrade_collection();
let resolved = resolver.resolve(&manifest, None).await;
let downgrades = aube_util::take_age_gate_downgrades();
server.abort();
let _ = std::fs::remove_dir_all(base);

let graph = resolved.expect("the signed 1.0.0 is still installable");
assert!(graph_has_package(&graph, NAME, "1.0.0"));
assert!(
!downgrades.iter().any(|d| d.name == NAME),
"the age gate admitted `latest`; only trust moved the pick: {downgrades:?}"
);
}

#[test]
fn test_format_iso8601_known_epoch() {
// 2024-01-01T00:00:00Z = 1704067200
Expand Down
Loading
Loading