diff --git a/crates/fourda-depengine/src/api.rs b/crates/fourda-depengine/src/api.rs index f89b80c8c..22d3a49f4 100644 --- a/crates/fourda-depengine/src/api.rs +++ b/crates/fourda-depengine/src/api.rs @@ -186,7 +186,13 @@ fn answer(request: Request) -> Result { let walk = outcome .target .map(|w| json!({ "version": w.version, "clears_all": w.clears_all })); - json!({ "line_fixes": line_fixes, "walk": walk, "withheld": outcome.withheld }) + let mut out = + json!({ "line_fixes": line_fixes, "walk": walk, "withheld": outcome.withheld }); + // Only when set, so every earlier answer keeps its exact shape. + if !outcome.introduces.is_empty() { + out["introduces"] = json!(outcome.introduces); + } + out } Request::MatchInventory { inventory, diff --git a/crates/fourda-depengine/src/fix_target.rs b/crates/fourda-depengine/src/fix_target.rs index 6c94f8a9e..457bba246 100644 --- a/crates/fourda-depengine/src/fix_target.rs +++ b/crates/fourda-depengine/src/fix_target.rs @@ -127,6 +127,11 @@ pub struct WalkOutcome { /// then withdrawn, so the honest answer is "no installable fix", not "no /// fix". Empty when the walk could not evaluate an advisory. pub withheld: Vec, + /// Parity decision 2 (a target never brings in an advisory): indices + /// into the walk's `ranges` of the advisories with no way out that affect + /// where the walk lands but NOT the installed version. Non-empty means + /// every fixing release brings them in, and `target` is `None`. + pub introduces: Vec, } /// A line fix as a release the registry still offers ([`installable_fix`]). @@ -411,7 +416,16 @@ pub fn walk_target_in( ) -> WalkOutcome { let mut withheld = Vec::new(); match walk(ecosystem, installed, ranges, registry, &mut withheld) { - Ok(target) => WalkOutcome { target, withheld }, + Ok(Landing::At(target)) => WalkOutcome { + target, + withheld, + introduces: Vec::new(), + }, + Ok(Landing::Introduces(introduces)) => WalkOutcome { + target: None, + withheld, + introduces, + }, Err(Unreadable) => WalkOutcome::default(), } } @@ -419,13 +433,21 @@ pub fn walk_target_in( /// The walk met an advisory it cannot evaluate on its path. struct Unreadable; +/// Where the walk ends. +enum Landing { + At(Option), + /// It lands inside advisories with no way out that the installed version + /// does not have (these indices): every fixing release brings them in. + Introduces(Vec), +} + fn walk( ecosystem: &str, installed: &str, ranges: &[&Option], registry: &RegistryReleases, withheld: &mut Vec, -) -> Result, Unreadable> { +) -> Result { let parsed: Vec>> = ranges .iter() .map(|r| r.as_deref().and_then(|j| serde_json::from_str(j).ok())) @@ -436,8 +458,8 @@ fn walk( for _ in 0..MAX_HOPS { let here = OrderedVersion::parse_in(¤t, ecosystem).ok_or(Unreadable)?; let mut next: Option = None; - let mut stuck = false; - for (json, advisory) in ranges.iter().zip(&parsed) { + let mut stuck: Vec = Vec::new(); + for (index, (json, advisory)) in ranges.iter().zip(&parsed).enumerate() { match check_version_affected(ecosystem, Some(¤t), json) { (false, _) => continue, (true, false) => return Err(Unreadable), @@ -445,7 +467,7 @@ fn walk( } let advisory = advisory.as_deref().ok_or(Unreadable)?; match exit(ecosystem, &here, advisory, &known, withheld) { - Exit::Stuck => stuck = true, + Exit::Stuck => stuck.push(index), Exit::To(step) => { let replace = match next.as_ref() { None => true, @@ -459,11 +481,23 @@ fn walk( } match next { Some((_, raw)) => current = raw, + None if current == start => return Ok(Landing::At(None)), None => { - return Ok((current != start).then_some(WalkTarget { + // Never bring an advisory in (parity decision 2): one with no + // way out that the installed version does not have blocks the + // target; the MCP's `smallestCleanVersion(installed)` agrees. + let introduced: Vec = stuck + .iter() + .copied() + .filter(|&i| !check_version_affected(ecosystem, Some(start), ranges[i]).0) + .collect(); + if !introduced.is_empty() { + return Ok(Landing::Introduces(introduced)); + } + return Ok(Landing::At(Some(WalkTarget { version: current, - clears_all: !stuck, - })) + clears_all: stuck.is_empty(), + }))); } } } diff --git a/crates/fourda-depengine/src/fix_target_tests.rs b/crates/fourda-depengine/src/fix_target_tests.rs index 0da2c1bc3..65a8189a2 100644 --- a/crates/fourda-depengine/src/fix_target_tests.rs +++ b/crates/fourda-depengine/src/fix_target_tests.rs @@ -126,3 +126,28 @@ fn installable_fix_skips_a_release_the_same_advisory_still_affects() { LineFix::default() ); } + +/// Parity decision 2 (review of #994, B2): A is fixed at 1.2.0, B opens at +/// 1.1.0 with no fix. From 1.0.0 every fixing release brings B in, so no +/// target is named (the MCP's `smallestCleanVersion(installed)` agrees); +/// from 1.1.5, which already has B, 1.2.0 clears A and B remains. +#[test] +fn a_target_never_brings_in_an_advisory_the_installed_version_lacks() { + let ab = [ + ranges(r#"[{"introduced":"0"},{"fixed":"1.2.0"}]"#), + ranges(r#"[{"introduced":"1.1.0"}]"#), + ]; + let out = walk_in("1.0.0", &ab, &RegistryReleases::default()); + assert_eq!(out.target, None); + assert_eq!(out.introduces, [1]); + + let has_b = walk_in("1.1.5", &ab, &RegistryReleases::default()); + assert_eq!( + has_b.target, + Some(WalkTarget { + version: "1.2.0".into(), + clears_all: false + }) + ); + assert!(has_b.introduces.is_empty()); +} diff --git a/crates/fourda-depengine/src/lines.rs b/crates/fourda-depengine/src/lines.rs index 5685da7d8..3836f03de 100644 --- a/crates/fourda-depengine/src/lines.rs +++ b/crates/fourda-depengine/src/lines.rs @@ -36,6 +36,10 @@ pub struct LineTarget { pub clears_all_known: bool, pub upgrade_type: Option, pub sites: Vec, + /// Parity decision 2: every fixing release brings in these advisories, + /// which the installed version does not have, so `target_version` is + /// `None` (no line fix is offered in its place either). Sorted, deduped. + pub introduces: Vec, } impl LineTarget { @@ -57,6 +61,7 @@ where walked: Option, partial: Option<(Version, String)>, sites: BTreeMap, + introduces: Vec, } let mut by_version: BTreeMap = BTreeMap::new(); let instances = copies @@ -74,6 +79,7 @@ where walked: None, partial: None, sites: BTreeMap::new(), + introduces: Vec::new(), }); if acc.clean.is_none() { acc.clean.clone_from(&d.clean_version); @@ -81,6 +87,7 @@ where if acc.walked.is_none() { acc.walked.clone_from(&d.target_version); } + acc.introduces.extend(d.introduces.iter().cloned()); if let Some(fix) = d .fixed_version .as_deref() @@ -110,7 +117,15 @@ where // Clean; else where the walk landed (past every window that has // a way out); else, when the walk could not run, the highest // own-line fix. - let target_version = acc.clean.or(acc.walked).or(acc.partial.map(|(_, f)| f)); + // A target never brings an advisory in (parity decision 2). + let mut introduces = acc.introduces; + introduces.sort(); + introduces.dedup(); + let target_version = if introduces.is_empty() { + acc.clean.or(acc.walked).or(acc.partial.map(|(_, f)| f)) + } else { + None + }; let upgrade_type = target_version .as_deref() .and_then(|t| upgrade_type(&installed, t)); @@ -120,6 +135,7 @@ where clears_all_known, upgrade_type, sites: acc.sites.into_values().collect(), + introduces, } }) .collect(); @@ -188,6 +204,7 @@ mod tests { clean_version: clean.map(str::to_string), target_version: walked.map(str::to_string), withheld: Vec::new(), + introduces: Vec::new(), } } @@ -270,4 +287,18 @@ mod tests { assert!(lines[0].is_major()); assert_eq!(describe_lines(&lines), "1.0.0 -> 2.0.1 (major)"); } + + /// Parity decision 2: a copy whose every fixing release brings in an + /// advisory gets no target, not even its line fix. + #[test] + fn a_line_whose_fixes_bring_in_an_advisory_has_no_target() { + let copies = [MatchedDependency { + introduces: vec!["B".to_string()], + ..copy("/a", "1.0.0", Some("1.2.0"), None, None, true, false) + }]; + let lines = line_targets(&copies, &["/a".to_string()]); + assert_eq!(lines[0].target_version, None); + assert_eq!(lines[0].introduces, ["B"]); + assert!(distinct_targets(&lines).is_empty()); + } } diff --git a/crates/fourda-depengine/src/matching.rs b/crates/fourda-depengine/src/matching.rs index ce4f62e67..7c35427c4 100644 --- a/crates/fourda-depengine/src/matching.rs +++ b/crates/fourda-depengine/src/matching.rs @@ -91,6 +91,12 @@ pub struct MatchedDependency { /// but was withdrawn ("no installable fix", not "no fix published"). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub withheld: Vec, + /// Parity decision 2: the advisories every fixing release of this copy + /// brings in (no way out, and the installed version does not have them). + /// Sorted and deduped. Non-empty means no target is named: neither `target_version` nor the + /// line fix (`fourda_depengine::fix_target::WalkOutcome::introduces`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub introduces: Vec, } /// One advisory row that affects at least one installed copy. @@ -195,7 +201,15 @@ where // Every known advisory's ranges per package: a copy's clean version must // clear ALL of them, not only the ones it matched today. let mut package_ranges: HashMap<(String, String), Vec<&Option>> = HashMap::new(); + let mut package_ids: HashMap<(String, String), Vec<&str>> = HashMap::new(); for record in records { + package_ids + .entry(( + package_key(&record.package_name, &record.ecosystem), + normalize_ecosystem(&record.ecosystem).to_string(), + )) + .or_default() + .push(&record.advisory_id); package_ranges .entry(( package_key(&record.package_name, &record.ecosystem), @@ -250,7 +264,8 @@ where .unwrap_or_default() }) .clone(); - CopyTargets::new(record, v, walk, facts) + let ids = package_ids.get(&key).map_or(&[][..], Vec::as_slice); + CopyTargets::new(record, v, walk, facts, ids) } _ => CopyTargets::default(), }; @@ -264,6 +279,7 @@ where clean_version: targets.clean, target_version: targets.target, withheld: targets.withheld, + introduces: targets.introduces, }); } } @@ -348,14 +364,17 @@ struct CopyTargets { clean: Option, target: Option, withheld: Vec, + introduces: Vec, } impl CopyTargets { + /// `ids`: the package's advisory ids, parallel to the walk's ranges. fn new( record: &AdvisoryRecord, installed: &str, walk: WalkOutcome, facts: &RegistryReleases, + ids: &[&str], ) -> Self { let line = installable_line_fix(&record.ecosystem, installed, &record.affected_ranges, facts); @@ -374,10 +393,24 @@ impl CopyTargets { .map(|w| w.version.clone()), target: walk.target.map(|w| w.version), withheld, + introduces: introduced_ids(&walk.introduces, ids), } } } +/// The ids at `indices` of `ids`, sorted and deduped (the order both +/// engines name them in). +fn introduced_ids(indices: &[usize], ids: &[&str]) -> Vec { + let mut out: Vec = indices + .iter() + .filter_map(|&i| ids.get(i)) + .map(|id| (*id).to_string()) + .collect(); + out.sort(); + out.dedup(); + out +} + /// The versions checked for one resolved row: every installed copy of that /// (project, package), deduped by version (first wins, with its own /// direct/dev labels), then the resolved row's own version unless a copy diff --git a/scripts/conformance-corpus.mjs b/scripts/conformance-corpus.mjs index 01eaa37d6..298c2d252 100644 --- a/scripts/conformance-corpus.mjs +++ b/scripts/conformance-corpus.mjs @@ -566,8 +566,14 @@ async function stepPin(cases, adjud) { const ids = [...new Set(idLists.flat())].sort(); console.log(`pin: ${list.length} package names -> ${ids.length} advisories`); const osvDir = join(CORPUS, "osv"); + // SYNTHETIC records (SYNT-*.json) pin rule vectors no live case shows + // (fix_target ft-069); a re-pin keeps them, it never fetches them. + const synthetic = existsSync(osvDir) + ? readdirSync(osvDir).filter((f) => f.startsWith("SYNT-")).map((f) => [f, readFileSync(join(osvDir, f))]) + : []; rmSync(osvDir, { recursive: true, force: true }); mkdirSync(osvDir, { recursive: true }); + for (const [f, body] of synthetic) writeFileSync(join(osvDir, f), body); await pool(ids, 16, async (id) => { const rec = await fetchJson(`https://api.osv.dev/v1/vulns/${encodeURIComponent(id)}`, null, join(CACHE, "vulns", `${id}.json`)); if (rec) writeFileSync(join(osvDir, `${id}.json`), `${JSON.stringify(rec)}\n`); diff --git a/src-tauri/src/brief_facts.rs b/src-tauri/src/brief_facts.rs index 10b4f358d..a71014f32 100644 --- a/src-tauri/src/brief_facts.rs +++ b/src-tauri/src/brief_facts.rs @@ -121,6 +121,9 @@ pub(crate) enum FixPath { /// No advisory on this copy has a way out on its line: no `fixed` bound, /// and no release past a `last_affected` one (`osv::fix_target`). NoFix, + /// Every fixing release brings in these advisories, which the installed + /// copy does not have (parity decision 2): no release is named. + Introduces { advisories: Vec }, /// A fix exists, scope unknown. Update { to: String }, } @@ -716,7 +719,7 @@ pub(crate) fn package_facts(facts: &BriefFacts) -> Vec {} + FixPath::NoFix | FixPath::Introduces { .. } => {} } } } diff --git a/src-tauri/src/brief_facts_build.rs b/src-tauri/src/brief_facts_build.rs index 71fa20662..1ed9db384 100644 --- a/src-tauri/src/brief_facts_build.rs +++ b/src-tauri/src/brief_facts_build.rs @@ -16,6 +16,7 @@ use super::*; // Per-copy targets and parent routes (file-size gate). #[path = "brief_facts_copies.rs"] mod copies; +use super::clause::with_direct_fix; pub(crate) use copies::{copy_fix, CopyFix}; use copies::{project_copies, with_parent_route}; @@ -355,6 +356,12 @@ fn security_fact( CopyFix::PerLine(lines) => FixPath::PerLine { lines }, } }; + let line = lines.iter().find(|l| { + installed.as_deref() == Some(l.installed_version.as_str()) + && l.sites.iter().any(|s| norm_path(&s.project_path) == pnorm) + }); + let introduced = line.map_or(&[][..], |l| l.introduces.as_slice()); + let fix_path = super::clause::with_introduces(fix_path, introduced); sites.push(SecuritySite { label: labels.label(project), installed, @@ -405,35 +412,6 @@ fn security_fact( Some(fact) } -/// A copy the project declares: name how it moves where it is declared, read -/// from the project's own manifest (`osv::direct_upgrade`, audit E8). The -/// same rule the Upgrade Plan's direct rows use; nothing when it is not certain -/// (including when the declaration does not admit the installed copy). -fn with_direct_fix( - path: FixPath, - project: &str, - ecosystem: &str, - package: &str, - installed: Option<&str>, -) -> FixPath { - let (FixPath::Bump { to, fix: None }, Some(installed)) = (&path, installed) else { - return path; - }; - let to = to.clone(); - let fix = crate::osv::exposure::canonical(ecosystem) - .and_then(|eco| { - crate::osv::direct_upgrade::direct_fix( - std::path::Path::new(project), - eco, - package, - installed, - &to, - ) - }) - .map(|f| f.brief_clause()); - FixPath::Bump { to, fix } -} - /// Name the project tool's refresh command on a refresh path (`osv::fix_path`). fn with_refresh_command( path: FixPath, diff --git a/src-tauri/src/brief_facts_build_tests.rs b/src-tauri/src/brief_facts_build_tests.rs index 573368060..f8ab64905 100644 --- a/src-tauri/src/brief_facts_build_tests.rs +++ b/src-tauri/src/brief_facts_build_tests.rs @@ -45,9 +45,10 @@ fn a_bump_names_the_command_for_its_declaration() { "absent", Some("1.0.0"), ); + // (1.0.0 -> 2.0.0 crosses a major, which is said: parity decision 3.) assert_eq!( crate::brief_facts::fix_clause("absent", &plain), - "bump absent to >= 2.0.0" + "bump absent to >= 2.0.0: a major upgrade" ); // A copy the declaration does not admit is another copy: nothing named. let other = with_direct_fix( @@ -62,7 +63,7 @@ fn a_bump_names_the_command_for_its_declaration() { ); assert_eq!( crate::brief_facts::fix_clause("minimist", &other), - "bump minimist to >= 1.2.6" + "bump minimist to >= 1.2.6: a major upgrade" ); let refresh = FixPath::Refresh { to: "1.2.6".into(), diff --git a/src-tauri/src/brief_facts_clause.rs b/src-tauri/src/brief_facts_clause.rs index ef48fad65..c1afe42e6 100644 --- a/src-tauri/src/brief_facts_clause.rs +++ b/src-tauri/src/brief_facts_clause.rs @@ -89,6 +89,10 @@ pub(crate) fn fix_clause(package: &str, path: &FixPath) -> String { .join("; ") ), FixPath::NoFix => "no fix published: pin, patch locally, replace, or accept the risk".to_string(), + FixPath::Introduces { advisories } => format!( + "no {package} release is named: {}; pin, patch locally, replace, or accept the risk", + crate::osv::fix_target::introduces_text(advisories) + ), FixPath::Update { to } => format!("update {package} to >= {to}"), } } @@ -107,3 +111,57 @@ fn proven_clause(package: &str, proven: Option<&ProvenParent>) -> String { ) }) } + +/// A copy the project declares: name how it moves where it is declared, read +/// from the project's own manifest (`osv::direct_upgrade`, audit E8). The +/// same rule the Upgrade Plan's direct rows use; nothing when it is not certain +/// (including when the declaration does not admit the installed copy). +pub(super) fn with_direct_fix( + path: FixPath, + project: &str, + ecosystem: &str, + package: &str, + installed: Option<&str>, +) -> FixPath { + let (FixPath::Bump { to, fix: None }, Some(installed)) = (&path, installed) else { + return path; + }; + let to = to.clone(); + let fix = crate::osv::exposure::canonical(ecosystem) + .and_then(|eco| { + crate::osv::direct_upgrade::direct_fix( + std::path::Path::new(project), + eco, + package, + installed, + &to, + ) + }) + .map(|f| f.brief_clause()); + let fix = with_major(installed, &to, fix); + FixPath::Bump { to, fix } +} + +/// A raise across a major (parity decision 3) is never silent: the bump +/// clause says so, before any command. +fn with_major(installed: &str, to: &str, fix: Option) -> Option { + if !crate::osv::parent_release::is_major(installed, to) { + return fix; + } + Some(fix.map_or_else( + || "a major upgrade".to_string(), + |f| format!("a major upgrade; {f}"), + )) +} + +/// A copy whose line has no target because every fixing release brings in +/// an advisory it does not have (parity decision 2) says so, with the same +/// ids the Upgrade Plan and Preemption name, instead of "no fix published". +pub(super) fn with_introduces(path: FixPath, introduces: &[String]) -> FixPath { + if path != FixPath::NoFix || introduces.is_empty() { + return path; + } + FixPath::Introduces { + advisories: introduces.to_vec(), + } +} diff --git a/src-tauri/src/brief_facts_tests.rs b/src-tauri/src/brief_facts_tests.rs index 771ff86d5..ddd913bcf 100644 --- a/src-tauri/src/brief_facts_tests.rs +++ b/src-tauri/src/brief_facts_tests.rs @@ -661,7 +661,7 @@ fn fix_target(path: &FixPath) -> Option<&str> { | FixPath::ParentUnknown { to } | FixPath::Reinstall { to } | FixPath::Update { to } => Some(to), - FixPath::PerLine { .. } | FixPath::NoFix => None, + FixPath::PerLine { .. } | FixPath::NoFix | FixPath::Introduces { .. } => None, } } @@ -989,6 +989,7 @@ fn line_in(project: &str, installed: &str, target: Option<&str>) -> LineTarget { installed_version: installed.into(), target_version: target.map(str::to_string), clears_all_known: target.is_some(), + introduces: Vec::new(), upgrade_type: None, sites: vec![InstallSite { project_path: project.into(), diff --git a/src-tauri/src/evidence/stack_change.rs b/src-tauri/src/evidence/stack_change.rs index 2eca3799f..125add42a 100644 --- a/src-tauri/src/evidence/stack_change.rs +++ b/src-tauri/src/evidence/stack_change.rs @@ -202,7 +202,7 @@ fn fix_targets(path: &FixPath) -> Vec<&str> { | FixPath::Reinstall { to } | FixPath::Update { to } => vec![to.as_str()], FixPath::PerLine { lines } => lines.iter().map(|l| l.to.as_str()).collect(), - FixPath::NoFix => Vec::new(), + FixPath::NoFix | FixPath::Introduces { .. } => Vec::new(), } } diff --git a/src-tauri/src/evidence/upgrade_parent.rs b/src-tauri/src/evidence/upgrade_parent.rs index d6ab0e019..a30aab43b 100644 --- a/src-tauri/src/evidence/upgrade_parent.rs +++ b/src-tauri/src/evidence/upgrade_parent.rs @@ -291,6 +291,11 @@ pub(crate) fn parent_move( "{package} is flagged malicious ({advisory}): remove or replace {package}; no \ {parent} release is named as the fix" ), + ParentStanding::Introduces { advisories } => format!( + "no {parent} release is named: every fixing release brings in {}, which the \ + installed version does not have{force}", + advisories.join(", ") + ), ParentStanding::Undecided { why } => { format!("no {parent} release is named because {why}, so no command is named{force}") } @@ -397,6 +402,10 @@ fn row_move( ParentStanding::Deprecated => { format!("{via}{lever} is deprecated: replace {lever}{force}") } + ParentStanding::Introduces { advisories } => format!( + "{via}no {lever} release is named: {}{force}", + crate::osv::fix_target::introduces_text(advisories) + ), // A lever's registry read runs only under a parent release // (`admits_move`); reaching here it says nothing more than Unknown. ParentStanding::Unknown diff --git a/src-tauri/src/evidence/upgrade_parent_release.rs b/src-tauri/src/evidence/upgrade_parent_release.rs index cbb66306c..dfc119d0f 100644 --- a/src-tauri/src/evidence/upgrade_parent_release.rs +++ b/src-tauri/src/evidence/upgrade_parent_release.rs @@ -215,6 +215,11 @@ fn lever_move(parent: &str, to: &str, l: &Through, force: &str) -> String { "{package} is flagged malicious ({advisory}): remove {package} or replace {lever}; \ no {lever} upgrade is named" ), + ParentStanding::Introduces { advisories } => format!( + "no {lever} release is named: every fixing release brings in {}, which the installed \ + version does not have{force}", + advisories.join(", ") + ), ParentStanding::Undecided { why } => { format!("no {lever} release is named ({why}), so no command is named") } diff --git a/src-tauri/src/evidence/upgrade_plan.rs b/src-tauri/src/evidence/upgrade_plan.rs index ccd2b0d95..ed7ef5c9a 100644 --- a/src-tauri/src/evidence/upgrade_plan.rs +++ b/src-tauri/src/evidence/upgrade_plan.rs @@ -201,6 +201,8 @@ struct PackageGroup<'a> { /// (yanked, deprecated; FX-H5). Non-empty means "no installable fix", /// never "no fix published". withheld: Vec, + /// With no fix: every fixing release brings in these (parity decision 2). + introduces: Vec, /// Every advisory is a maintenance notice (unmaintained / deprecated: no /// fix, no CVSS). Folded into ONE Watch item, never an upgrade step. informational: bool, @@ -279,8 +281,10 @@ fn package_group<'a>( } } } + let introduces = fix_target::introduced_by(&lines); let informational = !has_fix && withheld.is_empty() + && introduces.is_empty() && advisories .iter() .all(|a| crate::osv::severity::is_informational(a)); @@ -329,6 +333,7 @@ fn package_group<'a>( targets, has_fix, withheld, + introduces, informational, cohort_key, routes: crate::osv::parent_hint::TransitiveRoutes::default(), @@ -428,7 +433,9 @@ impl PackageGroup<'_> { } else { format!( "{none} for {pkg} — {n} {adv} across {m} {proj}", - none = if self.withheld.is_empty() { + none = if !self.introduces.is_empty() { + "No clean fix" + } else if self.withheld.is_empty() { "No fix published" } else { "No installable fix" @@ -441,7 +448,10 @@ impl PackageGroup<'_> { ) }); - let scope_note = if !self.has_fix && !self.withheld.is_empty() { + let scope_note = if !self.has_fix && !self.introduces.is_empty() { + let why = fix_target::introduces_text(&self.introduces); + format!("No release is named: {why} — pin, patch, or replace") + } else if !self.has_fix && !self.withheld.is_empty() { format!( "The release that fixes it ({}) was withdrawn by its registry (yanked or \ deprecated), and no later release is known to clear it — pin, patch, or replace", @@ -646,7 +656,12 @@ fn line_citations(lines: &[LineTarget]) -> Vec { lines .iter() .map(|l| { - let target = l.target_version.as_deref().unwrap_or("no fix published"); + let none = if l.introduces.is_empty() { + "no fix published" + } else { + "no clean fix" + }; + let target = l.target_version.as_deref().unwrap_or(none); let kind = match l.upgrade_type { Some(fix_target::UpgradeType::Patch) => "patch", Some(fix_target::UpgradeType::Minor) => "minor", diff --git a/src-tauri/src/evidence/upgrade_plan_tests.rs b/src-tauri/src/evidence/upgrade_plan_tests.rs index 148e38817..ef175754a 100644 --- a/src-tauri/src/evidence/upgrade_plan_tests.rs +++ b/src-tauri/src/evidence/upgrade_plan_tests.rs @@ -256,6 +256,53 @@ fn a_fixed_and_an_unfixed_advisory_on_one_package_still_upgrade() { ); } +/// Review of #994, B2 (parity decision 2): A is fixed at 1.2.0, B opens at +/// 1.1.0 with no fix and the installed 1.0.0 does not have it. Every fixing +/// release brings B in, so the row names no target (not even A's line fix) +/// and says why; Preemption's sentence agrees. +#[test] +fn a_fix_that_brings_in_an_advisory_is_not_named() { + let db = test_db(); + db.store_dependency("/proj/a", "foo", Some("1.0.0"), "npm", false, None) + .unwrap(); + advisory(&db, "GHSA-foo-a", "foo", "npm", "1.2.0", 7.5); + db.upsert_osv_advisory( + "GHSA-foo-b", + "Unfixed issue in foo", + None, + "foo", + "npm", + Some(r#"[{"type":"SEMVER","events":[{"introduced":"1.1.0"}]}]"#), + None, + Some("CVSS_V3"), + Some(5.0), + None, + None, + None, + None, + ) + .unwrap(); + let plan = build_upgrade_plan(&db); + assert_eq!(plan.len(), 1); + let step = &plan[0]; + assert!( + step.title.starts_with("No clean fix for foo"), + "{}", + step.title + ); + let why = + "every fixing release brings in GHSA-foo-b, which the installed version does not have"; + assert!(step.explanation.contains(why), "{}", step.explanation); + assert!(!step.title.contains("1.2.0"), "{}", step.title); + + let matches = crate::osv::matching::get_matched_advisories(&db).unwrap(); + let group: Vec<_> = matches.iter().collect(); + let lines = crate::osv::fix_target::line_targets(&group, &["/proj/a".to_string()]); + let (fix, sentence) = crate::preemption::alert_fix_target(&group, &lines); + assert_eq!(fix, None); + assert!(sentence.contains(why), "{sentence}"); +} + #[test] fn every_emitted_item_is_schema_valid_across_a_mixed_plan() { let db = test_db(); @@ -496,6 +543,7 @@ fn matched(cvss: f64, installs: &[(bool, bool, bool)]) -> crate::osv::types::Mat clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), }, ) .collect(), diff --git a/src-tauri/src/morning_audit.rs b/src-tauri/src/morning_audit.rs index 5e075a215..173fab668 100644 --- a/src-tauri/src/morning_audit.rs +++ b/src-tauri/src/morning_audit.rs @@ -107,7 +107,7 @@ pub(crate) fn fact_versions(facts: &BriefFacts) -> Vec<(String, String, &'static push(&f.package, &l.to, "line fix"); } } - FixPath::NoFix => {} + FixPath::NoFix | FixPath::Introduces { .. } => {} } } } diff --git a/src-tauri/src/osv/direct_upgrade_tests.rs b/src-tauri/src/osv/direct_upgrade_tests.rs index 14580ba86..3014140eb 100644 --- a/src-tauri/src/osv/direct_upgrade_tests.rs +++ b/src-tauri/src/osv/direct_upgrade_tests.rs @@ -468,6 +468,7 @@ fn the_note_words_a_refresh_as_one() { installed_version: "1.2.5".into(), target_version: Some("1.2.8".into()), clears_all_known: true, + introduces: Vec::new(), upgrade_type: None, sites: projects .iter() diff --git a/src-tauri/src/osv/fix_target.rs b/src-tauri/src/osv/fix_target.rs index 45a525d27..63f21320d 100644 --- a/src-tauri/src/osv/fix_target.rs +++ b/src-tauri/src/osv/fix_target.rs @@ -20,6 +20,26 @@ pub use fourda_depengine::fix_target::{upgrade_type, UpgradeType}; pub use fourda_depengine::lines::InstallSite; pub use fourda_depengine::lines::{describe_lines, distinct_targets, LineTarget}; +/// Parity decision 2, the MCP's words: why no target is named for a copy. +pub fn introduces_text(advisories: &[String]) -> String { + format!( + "every fixing release brings in {}, which the installed version does not have", + advisories.join(", ") + ) +} + +/// The advisories every fixing release of these lines brings in, sorted and +/// deduped; empty when any line has a target. +pub fn introduced_by(lines: &[LineTarget]) -> Vec { + if lines.iter().any(|l| l.target_version.is_some()) { + return Vec::new(); + } + let mut out: Vec = lines.iter().flat_map(|l| l.introduces.clone()).collect(); + out.sort(); + out.dedup(); + out +} + /// Per-line targets for the version-confirmed copies of a package in /// `projects`, sorted by installed version. A copy in a project the caller /// does not speak for can neither set nor raise a target here (AD-044). diff --git a/src-tauri/src/osv/fix_target_tests.rs b/src-tauri/src/osv/fix_target_tests.rs index 101aad322..b9c6ae9c8 100644 --- a/src-tauri/src/osv/fix_target_tests.rs +++ b/src-tauri/src/osv/fix_target_tests.rs @@ -133,6 +133,7 @@ fn copy(project: &str, version: &str, fix: Option<&str>, clean: Option<&str>) -> clean_version: clean.map(str::to_string), target_version: clean.map(str::to_string), withheld: Vec::new(), + introduces: Vec::new(), } } @@ -298,7 +299,9 @@ fn last_affected_with_nothing_known_above_it_declines_the_clean_claim() { /// With an advisory no release leaves, the walk still clears every one that /// HAS a way out. The old fallback (highest own-line fix, 2.0.0) sat inside -/// the second window. +/// the second window. The open window is one the installed 1.6.0 already +/// has; from 1.0.0, which does not, every fixing release brings it in, so +/// nothing is named (parity decision 2, review of #994 B2). #[test] fn a_target_that_cannot_clear_everything_still_clears_every_fixable_window() { let all = ranges(&[ @@ -306,7 +309,8 @@ fn a_target_that_cannot_clear_everything_still_clears_every_fixable_window() { r#"[{"type":"SEMVER","events":[{"introduced":"2.0.0"},{"fixed":"2.5.0"}]}]"#, r#"[{"type":"SEMVER","events":[{"introduced":"1.5.0"}]}]"#, ]); - assert_eq!(walk("npm", "1.0.0", &all), landed("2.5.0", false)); + assert_eq!(walk("npm", "1.6.0", &all), landed("2.5.0", false)); + assert_eq!(walk("npm", "1.0.0", &all), None); // Nothing with a way out affects the copy: no target to name. let open_only = ranges(&[r#"[{"type":"SEMVER","events":[{"introduced":"1.5.0"}]}]"#]); assert_eq!(walk("npm", "1.6.0", &open_only), None); diff --git a/src-tauri/src/osv/identity.rs b/src-tauri/src/osv/identity.rs index ca32839ae..571843747 100644 --- a/src-tauri/src/osv/identity.rs +++ b/src-tauri/src/osv/identity.rs @@ -706,6 +706,7 @@ mod tests { clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), }; let mut sandbox = adv("GHSA-s", "sandbox", None, "s", &[], Some(9.8), None); sandbox.dependency_instances = vec![ diff --git a/src-tauri/src/osv/parent_hint.rs b/src-tauri/src/osv/parent_hint.rs index 9660e2dd9..35416f92f 100644 --- a/src-tauri/src/osv/parent_hint.rs +++ b/src-tauri/src/osv/parent_hint.rs @@ -143,6 +143,9 @@ pub enum ParentStanding { /// A malicious-package record affects the installed `package` (this /// package, or the vulnerable copy it pins): remove or replace it. Malicious { package: String, advisory: String }, + /// A release admits a clean copy, but every one brings in an advisory + /// the installed release does not have (parity decision 2). + Introduces { advisories: Vec }, } impl ParentStanding { @@ -312,10 +315,32 @@ pub fn parent_standing( ); let releases = book.releases(ecosystem_norm, name).unwrap_or_default(); let newest = advisories.newest(&releases); - let usable = - |v: &str| !registry.excludes(ecosystem_norm, v) && advisories.usable(v, newest.as_deref()); + let usable = |v: &str| { + !registry.excludes(ecosystem_norm, v) + && advisories.usable(v, newest.as_deref(), Some(installed)) + }; let listed = releases.versions.clone(); - standing_from_with(&rows, name, installed, &listed, &usable) + let standing = standing_from_with(&rows, name, installed, &listed, &usable); + // Review of #994, B3: "already the newest release" is false when newer + // releases exist and only advisories the installed version lacks rule + // them out; then that is what is said (parity decision 2). + if standing != (ParentStanding::Newest { deprecated: false }) { + return standing; + } + let newer = super::parent_release::ascending(&releases.versions, ecosystem_norm) + .into_iter() + .filter(|v| !registry.excludes(ecosystem_norm, v)) + .filter(|v| { + super::parent_release::compare(v, installed, ecosystem_norm) + == Some(std::cmp::Ordering::Greater) + }) + .map(String::as_str); + let ids = advisories.introduced_union(newer, newest.as_deref(), installed); + if ids.is_empty() { + standing + } else { + ParentStanding::Introduces { advisories: ids } + } } /// The pure core of [`parent_standing`]: `rows` are `(id, title)` registry diff --git a/src-tauri/src/osv/parent_hint_tests.rs b/src-tauri/src/osv/parent_hint_tests.rs index 602b95f1a..50e0c63fc 100644 --- a/src-tauri/src/osv/parent_hint_tests.rs +++ b/src-tauri/src/osv/parent_hint_tests.rs @@ -14,6 +14,7 @@ fn line(installed: &str, target: &str, project: &str, direct: bool) -> LineTarge installed_version: installed.to_string(), target_version: Some(target.to_string()), clears_all_known: true, + introduces: Vec::new(), upgrade_type: Some(UpgradeType::Major), sites: vec![InstallSite { project_path: project.to_string(), diff --git a/src-tauri/src/osv/parent_release.rs b/src-tauri/src/osv/parent_release.rs index 576a26ced..5c6f0c362 100644 --- a/src-tauri/src/osv/parent_release.rs +++ b/src-tauri/src/osv/parent_release.rs @@ -108,18 +108,24 @@ impl Advisory { } } +#[cfg(test)] +use super::parent_release_hop::{through_release, Hop, HopParent}; + /// One package's known advisories. pub struct Advisories<'a> { pub ecosystem: &'a str, pub list: Vec, } -/// THE predicate every reader shares (review of #981, B2): whether a release -/// can be named. A malicious-package record that affects it rules it out -/// outright; an advisory with a published fix (it does not also affect the -/// package's newest non-malicious release) rules it out; an advisory no -/// release fixes does not (it cannot block a move that fixes the others), -/// and the release named says it remains affected by it. +/// THE predicate every reader shares (review of #981, B2; parity decision +/// 2): whether a release can be named. A malicious-package record that +/// affects it rules it out outright; so does ANY advisory the installed +/// version does not have (a move never introduces one, fixed or not); an +/// advisory the installed version already has rules it out when it has a +/// published fix (it does not also affect the package's newest +/// non-malicious release); one no release fixes does not (it cannot block a +/// move that fixes the others), and the release named says it remains +/// affected by it. impl<'a> Advisories<'a> { /// From the advisories 4DA stores for one package. pub fn stored(ecosystem: &'a str, stored: Vec) -> Self { @@ -183,15 +189,68 @@ impl<'a> Advisories<'a> { } /// Whether `version` can be named (see the predicate above). - pub fn usable(&self, version: &str, newest: Option<&str>) -> bool { + pub fn usable(&self, version: &str, newest: Option<&str>, installed: Option<&str>) -> bool { self.list.iter().all(|a| { if !self.hits(a, version) { return true; } + // Never introduce an advisory (parity decision 2): one the + // installed version does not have rules the release out, fixed + // or not. + if installed.is_some_and(|i| !self.hits(a, i)) { + return false; + } !a.malware && newest.is_some_and(|n| self.hits(a, n)) }) } + /// The non-malicious advisories that affect `version` but not + /// `installed`: what a move to it would bring in (the MCP's + /// `introducedAt`). + pub fn introduced_at(&self, version: &str, installed: &str) -> Vec { + self.list + .iter() + .filter(|a| !a.malware && self.hits(a, version) && !self.hits(a, installed)) + .map(|a| a.id.clone()) + .collect() + } + + /// [`Self::usable`], except that advisories `installed` does not have are + /// ignored, fixed or not (the MCP's `cleanButIntroduced`): whether only + /// those block `version`. A malicious record affecting it still does. + pub fn clean_but_introduced( + &self, + version: &str, + newest: Option<&str>, + installed: &str, + ) -> bool { + self.malware_at(version).is_none() + && self.list.iter().all(|a| { + !self.hits(a, version) + || !self.hits(a, installed) + || newest.is_some_and(|n| self.hits(a, n)) + }) + } + + /// Of `candidates`, the ones only advisories `installed` lacks block: + /// the union of what they bring in, sorted and deduped (review of #994, + /// S4). Empty when none. + pub fn introduced_union<'v>( + &self, + candidates: impl Iterator, + newest: Option<&str>, + installed: &str, + ) -> Vec { + let mut out: Vec = candidates + .filter(|v| !self.usable(v, newest, Some(installed))) + .filter(|v| self.clean_but_introduced(v, newest, installed)) + .flat_map(|v| self.introduced_at(v, installed)) + .collect(); + out.sort(); + out.dedup(); + out + } + /// The advisories no release fixes that still affect `version`. pub fn unfixed_at(&self, version: &str, newest: Option<&str>) -> Vec { self.list @@ -258,6 +317,9 @@ pub enum Standing { /// The installed parent is a release a malicious-package record affects /// (its id): it is removed or replaced, never moved. Malicious(String), + /// A release admits a clean copy, but every such release brings in an + /// advisory the installed parent does not have (these ids): none is named. + Introduces(Vec), } fn parse(v: &str, eco: &str) -> Option { @@ -274,7 +336,7 @@ fn stable(v: &str, eco: &str) -> bool { } /// Sorted ascending, stable releases only. -fn ascending<'a>(versions: &'a [String], eco: &str) -> Vec<&'a String> { +pub(super) fn ascending<'a>(versions: &'a [String], eco: &str) -> Vec<&'a String> { let mut out: Vec<&String> = versions.iter().filter(|v| stable(v, eco)).collect(); out.sort_by(|a, b| compare(a, b, eco).unwrap_or(Ordering::Equal)); out @@ -291,7 +353,7 @@ pub fn highest(releases: &Releases, eco: &str) -> Option { /// The release "already its latest" is judged against: the `latest` tag, /// else the highest stable installable release. -fn latest_of(releases: &Releases, eco: &str) -> Option { +pub(super) fn latest_of(releases: &Releases, eco: &str) -> Option { releases.latest.clone().or_else(|| highest(releases, eco)) } @@ -300,7 +362,7 @@ pub fn is_major(from: &str, to: &str) -> bool { super::fix_target::upgrade_type(from, to) == Some(super::fix_target::UpgradeType::Major) } -fn admits(req: &str, version: &str, dialect: Dialect) -> Option { +pub(super) fn admits(req: &str, version: &str, dialect: Dialect) -> Option { match dialect { Dialect::Npm => requirement_admits(req, version), Dialect::Cargo => Requirement::cargo(req).admits(version), @@ -414,9 +476,9 @@ pub fn standing( } /// Rule (b), the parent's own row: its target raised to the highest release -/// the transitive rows need from it, unless that crosses a major its own -/// target does not or one of its advisories affects it. `None` when the -/// parent has no row of its own. +/// the transitive rows need from it (across a major too: parity decision 3), +/// unless one of its advisories affects that release. `None` when the parent +/// has no row of its own. pub fn row_target( own_target: Option<&str>, needs: &[&str], @@ -430,15 +492,16 @@ pub fn row_target( .chain(std::iter::once(own)) .max_by(|a, b| compare(a, b, eco).unwrap_or(Ordering::Equal)) .unwrap_or(own); - let raise = compare(highest, own, eco) == Some(Ordering::Greater) - && !is_major(own, highest) - && !parent_affected(highest); + // Across a major too (parity decision 3): the plan flags it breaking and + // says why; refusing left the parent's row on a release that does not + // fix the copy it pins. + let raise = compare(highest, own, eco) == Some(Ordering::Greater) && !parent_affected(highest); Some(if raise { highest } else { own }.to_string()) } /// Rule (b), a transitive row: a smallest release below the parent's own -/// row target moves up to it when that target also admits a clean copy and -/// the step does not cross a major. +/// row target moves up to it when that target also admits a clean copy (a +/// major step included: the parent's row moves there anyway). pub fn lift( found: &Found, row_target: Option<&str>, @@ -449,7 +512,7 @@ pub fn lift( ) -> Option { let target = row_target?; let below = compare(&found.version, target, child.ecosystem) == Some(Ordering::Less); - if !below || is_major(&found.version, target) { + if !below { return None; } admits_at(child, history, own_clean, target, dialect) @@ -506,14 +569,25 @@ pub fn decide( ) -> Decision { let eco = parent_advisories.ecosystem; let latest = parent_advisories.newest(parent); - let own_clean = |v: &str| parent_advisories.usable(v, latest.as_deref()); + let own_clean = |v: &str| parent_advisories.usable(v, latest.as_deref(), Some(from)); let blocked = parent_blocked(parent_advisories, from); let standings: Vec = rows .iter() .map(|r| match (&blocked, &r.unknown) { (Some(b), _) => b.clone(), (None, Some(why)) => Standing::Undecided(why.clone()), - (None, None) => standing(&r.child, parent, from, r.history, &own_clean, dialect), + (None, None) => { + let s = standing(&r.child, parent, from, r.history, &own_clean, dialect); + introduces( + s, + r, + parent, + from, + parent_advisories, + latest.as_deref(), + dialect, + ) + } }) .collect(); let needs: Vec<&str> = standings @@ -545,6 +619,38 @@ pub fn decide( } } +/// Parity decision 2: when no release qualifies, but one would if it could +/// bring in an advisory the installed parent lacks, say which ones. +fn introduces( + found: Standing, + row: &RowIn<'_>, + parent: &Releases, + from: &str, + advisories: &Advisories<'_>, + latest: Option<&str>, + dialect: Dialect, +) -> Standing { + let (Standing::NoneAdmits { .. }, Some(history)) = (&found, row.history) else { + return found; + }; + // Every release above the installed one that admits a clean copy once + // advisories the installed parent lacks are set aside, fixed or not + // (review of #994, S2): what they bring in, unioned (S4). + let relaxed = |v: &str| advisories.clean_but_introduced(v, latest, from); + let eco = advisories.ecosystem; + let admitting = ascending(&parent.versions, eco) + .into_iter() + .filter(|v| compare(v, from, eco) == Some(Ordering::Greater)) + .filter(|v| admits_at(&row.child, history, &relaxed, v, dialect).is_some()) + .map(String::as_str); + let ids = advisories.introduced_union(admitting, latest, from); + if ids.is_empty() { + found + } else { + Standing::Introduces(ids) + } +} + /// Why no release of the parent can be named for any row: the installed /// parent is malicious, or one of its advisories cannot be read. fn parent_blocked(advisories: &Advisories<'_>, from: &str) -> Option { @@ -558,101 +664,6 @@ fn parent_blocked(advisories: &Advisories<'_>, from: &str) -> Option { }) } -/// A lever that is not the parent: the declared dependency the parent -/// arrives through. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Hop { - /// `version` of the lever brings in parent `brings`, a release that - /// admits a clean copy; `keeps_parent` when the lever's requirement still - /// admits the installed parent (an install keeps it; a refresh of the - /// parent follows). - Found { - version: String, - brings: String, - keeps_parent: bool, - }, - Deprecated, - Latest, - /// Every release was read and none brings in a parent that admits it. - NoneBrings { - latest: Option, - }, -} - -/// The parent side of a hop. -pub struct HopParent<'a> { - pub installed: &'a str, - pub releases: &'a Releases, - pub history: &'a RequirementHistory, - pub own_clean: &'a dyn Fn(&str) -> bool, -} - -/// The smallest release of the lever, clear of its own advisories, whose one -/// requirement on the parent resolves to a parent release above the -/// installed one, clear of the parent's advisories, that admits a clean -/// copy. `lever_history` is the lever's requirement on the parent. -pub fn through_release( - child: &Child<'_>, - parent: &HopParent<'_>, - lever: &Releases, - lever_from: &str, - lever_history: &RequirementHistory, - lever_clean: &dyn Fn(&str) -> bool, - dialect: Dialect, -) -> Hop { - let eco = child.ecosystem; - if lever.deprecated { - return Hop::Deprecated; - } - let latest = latest_of(lever, eco); - if latest - .as_deref() - .is_some_and(|l| compare(lever_from, l, eco).is_some_and(|o| o != Ordering::Less)) - { - return Hop::Latest; - } - ascending(&lever.versions, eco) - .into_iter() - .filter(|v| compare(v, lever_from, eco) == Some(Ordering::Greater)) - .find_map(|v| hop_at(child, parent, lever_history, lever_clean, v, dialect)) - .unwrap_or(Hop::NoneBrings { latest }) -} - -/// Lever release `version`, clear of the lever's own advisories, brings in -/// (its one requirement on the parent resolves to) a parent release above the -/// installed one, clear of the parent's advisories, that admits a clean copy. -pub fn hop_at( - child: &Child<'_>, - parent: &HopParent<'_>, - lever_history: &RequirementHistory, - lever_clean: &dyn Fn(&str) -> bool, - version: &str, - dialect: Dialect, -) -> Option { - let eco = child.ecosystem; - let entry = lever_history.get(version)?; - let [req] = entry.reqs.as_slice() else { - return None; - }; - if !lever_clean(version) { - return None; - } - let brings = ascending(&parent.releases.versions, eco) - .into_iter() - .rev() - .find(|p| admits(req, p, dialect) == Some(true))?; - let above = compare(brings, parent.installed, eco) == Some(Ordering::Greater); - if !above || !(parent.own_clean)(brings) { - return None; - } - admits_clean(child, &parent.history.get(brings.as_str())?.reqs, dialect)?; - Some(Hop::Found { - version: version.to_string(), - brings: brings.clone(), - keeps_parent: admits(req, parent.installed, dialect) == Some(true), - }) -} - #[cfg(test)] #[path = "parent_release_tests.rs"] mod tests; diff --git a/src-tauri/src/osv/parent_release_graph_vectors.rs b/src-tauri/src/osv/parent_release_graph_vectors.rs index 9702e0560..7f4064847 100644 --- a/src-tauri/src/osv/parent_release_graph_vectors.rs +++ b/src-tauri/src/osv/parent_release_graph_vectors.rs @@ -134,6 +134,7 @@ fn link_json(link: &ParentLink) -> Value { } ParentStanding::Malicious { .. } => json!("malicious"), ParentStanding::Undecided { .. } => json!("undecided"), + ParentStanding::Introduces { .. } => json!("introduces"), ParentStanding::NoneAdmits { .. } => json!("none_admits"), ParentStanding::Deprecated => json!("deprecated"), ParentStanding::Newest { .. } => json!("latest"), diff --git a/src-tauri/src/osv/parent_release_hop.rs b/src-tauri/src/osv/parent_release_hop.rs index 421ce8505..4c290b555 100644 --- a/src-tauri/src/osv/parent_release_hop.rs +++ b/src-tauri/src/osv/parent_release_hop.rs @@ -15,7 +15,7 @@ use crate::db::Database; use super::fix_path::{requirement_admits, Dialect}; use super::parent_hint::{normalize_path, ParentRelease, ParentStanding, Through}; use super::parent_release::{ - compare, hop_at, is_major, through_release, Advisories, Hop, HopParent, Releases, + admits, admits_clean, ascending, compare, is_major, latest_of, Advisories, Child, Releases, RequirementHistory, }; use super::parent_release_load::{ @@ -78,8 +78,13 @@ fn lever_hop( .unwrap_or_default(); let lever_own = advisories(hop.db, &through.name); let lever_newest = lever_own.newest(&lever); - let lever_clean = |v: &str| lever_own.usable(v, lever_newest.as_deref()); - let parent_clean = |v: &str| parent.own.usable(v, parent.newest.as_deref()); + let lever_clean = + |v: &str| lever_own.usable(v, lever_newest.as_deref(), Some(&through.version)); + let parent_clean = |v: &str| { + parent + .own + .usable(v, parent.newest.as_deref(), Some(&hop.parent.1)) + }; let clean = hop.child.clean(); let child = hop.child.child(clean.as_ref()); let hp = HopParent { @@ -111,8 +116,9 @@ fn lever_hop( ) else { return (found, None); }; - let lift = - compare(version, &row, ECOSYSTEM) == Some(Ordering::Less) && !is_major(version, &row); + // A major step included: the lever's own row moves there anyway + // (parity decision 3), and the release says it is a major upgrade. + let lift = compare(version, &row, ECOSYSTEM) == Some(Ordering::Less); let smallest = version.clone(); match hop_at(&child, &hp, lever_history, &lever_clean, &row, Dialect::Npm) { Some(lifted) if lift => (lifted, Some(smallest)), @@ -120,6 +126,44 @@ fn lever_hop( } } +/// The advisories the lever releases that would bring a fixed parent in +/// bring in, when only advisories the installed lever lacks (fixed or not) +/// block them: sorted, deduped, unioned over every such release (review of +/// #994, S3/S4). Empty when no release is blocked only so. +fn lever_introduces( + hop: &HopCtx<'_>, + through: &Through, + lever_history: &RequirementHistory, + parent: &ParentFacts<'_>, +) -> Vec { + let lever = hop + .book + .releases(ECOSYSTEM, &through.name) + .unwrap_or_default(); + let lever_own = advisories(hop.db, &through.name); + let newest = lever_own.newest(&lever); + let relaxed = |v: &str| lever_own.clean_but_introduced(v, newest.as_deref(), &through.version); + let parent_clean = |v: &str| { + parent + .own + .usable(v, parent.newest.as_deref(), Some(&hop.parent.1)) + }; + let clean = hop.child.clean(); + let child = hop.child.child(clean.as_ref()); + let hp = HopParent { + installed: &hop.parent.1, + releases: &parent.releases, + history: parent.history, + own_clean: &parent_clean, + }; + let candidates = ascending(&lever.versions, ECOSYSTEM) + .into_iter() + .filter(|v| compare(v, &through.version, ECOSYSTEM) == Some(Ordering::Greater)) + .filter(|v| hop_at(&child, &hp, lever_history, &relaxed, v, Dialect::Npm).is_some()) + .map(String::as_str); + lever_own.introduced_union(candidates, newest.as_deref(), &through.version) +} + impl HopCtx<'_> { /// Fill `through.standing` with its release, when it requires the parent /// directly (adjacent) and every registry fact needed was read. @@ -163,7 +207,17 @@ impl HopCtx<'_> { ))), Hop::Deprecated => ParentStanding::Deprecated, Hop::Latest => ParentStanding::Newest { deprecated: false }, - Hop::NoneBrings { latest } => ParentStanding::NoneAdmits { latest }, + Hop::NoneBrings { latest } => { + // Parity decision 2 for levers (the MCP's stuck lever, + // "introduces"): a release would bring a fixed parent in, but + // only advisories the installed lever lacks block it. + let ids = lever_introduces(self, through, lever_history, &facts); + if ids.is_empty() { + ParentStanding::NoneAdmits { latest } + } else { + ParentStanding::Introduces { advisories: ids } + } + } }; } @@ -255,7 +309,7 @@ pub(super) fn lever_needs( }; let lever_own = advisories(db, lever.name); let lever_newest = lever_own.newest(&lever_releases); - let lever_clean = |v: &str| lever_own.usable(v, lever_newest.as_deref()); + let lever_clean = |v: &str| lever_own.usable(v, lever_newest.as_deref(), Some(lever.version)); for (parent, version) in pinned_parents(db, book, lever) { let side = ParentSide { project: lever.project, @@ -270,7 +324,8 @@ pub(super) fn lever_needs( let Some(facts) = parent_facts(db, book, &parent, &row.package) else { continue; }; - let parent_clean = |v: &str| facts.own.usable(v, facts.newest.as_deref()); + let parent_clean = + |v: &str| facts.own.usable(v, facts.newest.as_deref(), Some(&version)); let clean = row.clean(); let hp = HopParent { installed: &version, @@ -307,3 +362,98 @@ pub(super) fn lever_children(book: &ReleaseBook, lever: &ParentSide<'_>) -> Vec< out.dedup(); out } + +/// A lever that is not the parent: the declared dependency the parent +/// arrives through. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Hop { + /// `version` of the lever brings in parent `brings`, a release that + /// admits a clean copy; `keeps_parent` when the lever's requirement still + /// admits the installed parent (an install keeps it; a refresh of the + /// parent follows). + Found { + version: String, + brings: String, + keeps_parent: bool, + }, + Deprecated, + Latest, + /// Every release was read and none brings in a parent that admits it. + NoneBrings { + latest: Option, + }, +} + +/// The parent side of a hop. +pub struct HopParent<'a> { + pub installed: &'a str, + pub releases: &'a Releases, + pub history: &'a RequirementHistory, + pub own_clean: &'a dyn Fn(&str) -> bool, +} + +/// The smallest release of the lever, clear of its own advisories, whose one +/// requirement on the parent resolves to a parent release above the +/// installed one, clear of the parent's advisories, that admits a clean +/// copy. `lever_history` is the lever's requirement on the parent. +pub fn through_release( + child: &Child<'_>, + parent: &HopParent<'_>, + lever: &Releases, + lever_from: &str, + lever_history: &RequirementHistory, + lever_clean: &dyn Fn(&str) -> bool, + dialect: Dialect, +) -> Hop { + let eco = child.ecosystem; + if lever.deprecated { + return Hop::Deprecated; + } + let latest = latest_of(lever, eco); + if latest + .as_deref() + .is_some_and(|l| compare(lever_from, l, eco).is_some_and(|o| o != Ordering::Less)) + { + return Hop::Latest; + } + ascending(&lever.versions, eco) + .into_iter() + .filter(|v| compare(v, lever_from, eco) == Some(Ordering::Greater)) + .find_map(|v| hop_at(child, parent, lever_history, lever_clean, v, dialect)) + .unwrap_or(Hop::NoneBrings { latest }) +} + +/// Lever release `version`, clear of the lever's own advisories, brings in +/// (its one requirement on the parent resolves to) a parent release above the +/// installed one, clear of the parent's advisories, that admits a clean copy. +pub fn hop_at( + child: &Child<'_>, + parent: &HopParent<'_>, + lever_history: &RequirementHistory, + lever_clean: &dyn Fn(&str) -> bool, + version: &str, + dialect: Dialect, +) -> Option { + let eco = child.ecosystem; + let entry = lever_history.get(version)?; + let [req] = entry.reqs.as_slice() else { + return None; + }; + if !lever_clean(version) { + return None; + } + let brings = ascending(&parent.releases.versions, eco) + .into_iter() + .rev() + .find(|p| admits(req, p, dialect) == Some(true))?; + let above = compare(brings, parent.installed, eco) == Some(Ordering::Greater); + if !above || !(parent.own_clean)(brings) { + return None; + } + admits_clean(child, &parent.history.get(brings.as_str())?.reqs, dialect)?; + Some(Hop::Found { + version: version.to_string(), + brings: brings.clone(), + keeps_parent: admits(req, parent.installed, dialect) == Some(true), + }) +} diff --git a/src-tauri/src/osv/parent_release_levers_tests.rs b/src-tauri/src/osv/parent_release_levers_tests.rs index db5904f76..8dc5c026a 100644 --- a/src-tauri/src/osv/parent_release_levers_tests.rs +++ b/src-tauri/src/osv/parent_release_levers_tests.rs @@ -14,6 +14,7 @@ fn line(installed: &str, target: &str, project: &str) -> Vec { installed_version: installed.into(), target_version: Some(target.into()), clears_all_known: true, + introduces: Vec::new(), upgrade_type: Some(UpgradeType::Minor), sites: vec![InstallSite { project_path: project.to_string(), @@ -216,6 +217,36 @@ fn the_newest_release_named_is_never_an_affected_one() { ); } +/// Review of #994, B3: newer releases exist, and only an advisory the +/// installed parent does not have rules them out. That is said, never +/// "already the newest release". +#[test] +fn newer_releases_that_bring_in_an_advisory_are_not_the_newest() { + let db = graph( + "d:/work/introduces", + &[("par", "1.0.0", true)], + &[], + &[("GHSA-synt-b3", "par", "1.1.0", "9.0.0")], + ); + crate::osv::release_facts::store_for_tests( + &db, + vec![( + "npm", + "par", + vec!["1.0.0", "1.1.0"], + Some("1.1.0"), + false, + vec![], + )], + ); + assert_eq!( + crate::osv::parent_hint::parent_standing(&db, "npm", "par", "1.0.0"), + ParentStanding::Introduces { + advisories: vec!["GHSA-synt-b3".into()] + } + ); +} + /// Review of #981, B2c and B1: the release named says what unfixed advisory /// it still carries, and a lifted lever names its own smallest release /// apart from the parent release it brings in. @@ -274,3 +305,28 @@ fn the_named_release_states_what_it_still_carries_and_where_it_was_lifted_from() "{text}" ); } + +/// Parity decision 2: the MCP's exact words when every fixing release +/// brings in an advisory the installed version does not have. +#[test] +fn every_fixing_release_bringing_in_an_advisory_names_none() { + let text = crate::evidence::parent_move( + "leaf", + "1.1.0", + "par", + "1.0.0", + &ParentStanding::Introduces { + advisories: vec!["GHSA-synt-0000-0002".into()], + }, + None, + None, + ); + assert!( + text.contains( + "every fixing release brings in GHSA-synt-0000-0002, which the installed version does \ + not have" + ), + "{text}" + ); + assert!(!text.contains("upgrade par to"), "{text}"); +} diff --git a/src-tauri/src/osv/parent_release_load.rs b/src-tauri/src/osv/parent_release_load.rs index 19d73cc5c..1d1d89d07 100644 --- a/src-tauri/src/osv/parent_release_load.rs +++ b/src-tauri/src/osv/parent_release_load.rs @@ -115,7 +115,10 @@ impl RowData { /// The shared predicate (`Advisories::usable`): a release that can be named. pub(super) fn clean(&self) -> Box bool + '_> { - Box::new(move |v: &str| self.advisories.usable(v, self.newest.as_deref())) + Box::new(move |v: &str| { + self.advisories + .usable(v, self.newest.as_deref(), Some(&self.installed)) + }) } /// Why nothing can be decided for this copy, when that is so. @@ -269,8 +272,8 @@ fn decision_for( /// The plan row of a DECLARED vulnerable package, raised so one bump also /// clears the transitive copies it pins (rule b): the highest release any of -/// them needs from it, when that stays in its major and no advisory of it -/// affects that release. `Some((to, packages))` only when it rises above +/// them needs from it, across a major too (flagged; parity decision 3), when +/// no advisory of it affects that release. `Some((to, packages))` only when it rises above /// `current`; `packages` are the ones that needed more than `current`. pub(crate) fn raised_row_target( db: &Database, @@ -469,6 +472,9 @@ fn standing_of( latest: latest.clone(), }, (Standing::Undecided(why), _) => ParentStanding::Undecided { why: why.clone() }, + (Standing::Introduces(ids), _) => ParentStanding::Introduces { + advisories: ids.clone(), + }, (Standing::Malicious(id), _) => ParentStanding::Malicious { package: link.parent.clone(), advisory: id.clone(), diff --git a/src-tauri/src/osv/parent_release_load_tests.rs b/src-tauri/src/osv/parent_release_load_tests.rs index f51edad3e..c86127bdb 100644 --- a/src-tauri/src/osv/parent_release_load_tests.rs +++ b/src-tauri/src/osv/parent_release_load_tests.rs @@ -12,6 +12,7 @@ fn ptr_line(project: &str) -> Vec { installed_version: "0.1.7".into(), target_version: Some("0.1.13".into()), clears_all_known: true, + introduces: Vec::new(), upgrade_type: Some(UpgradeType::Patch), sites: vec![InstallSite { project_path: project.to_string(), diff --git a/src-tauri/src/osv/parent_release_tests.rs b/src-tauri/src/osv/parent_release_tests.rs index 142748161..4bf3bad25 100644 --- a/src-tauri/src/osv/parent_release_tests.rs +++ b/src-tauri/src/osv/parent_release_tests.rs @@ -44,7 +44,7 @@ fn ptr_clean(v: &str) -> bool { adv("GHSA-27v5", range("8.0.0", "8.4.0")), ], }; - a.usable(v, Some("8.4.2")) + a.usable(v, Some("8.4.2"), None) } fn express() -> Releases { @@ -235,10 +235,11 @@ fn the_own_row_rises_within_its_major_and_only_to_a_clean_release() { row_target(Some("4.20.0"), &["4.19.0"], &none, "npm").as_deref(), Some("4.20.0") ); - // A major the own target does not cross stays a row of its own. + // Parity decision 3: the only fix across a major raises the row too + // (flagged breaking where it is worded, `osv::raised_lines`). assert_eq!( row_target(Some("2.1.20"), &["3.1.2"], &none, "npm").as_deref(), - Some("2.1.20") + Some("3.1.2") ); // An advisory of the parent affects the higher release. assert_eq!( @@ -283,17 +284,46 @@ fn a_lower_release_lifts_to_the_own_row_target_when_that_still_admits_it() { ), None ); - assert_eq!( - lift( - &found, - Some("5.0.0"), - &child, - &express_ptr(), - &|_| true, - Dialect::Npm - ), - None + // Parity decision 3: across a major too (express 5.0.0 no longer depends + // on path-to-regexp at all). + let major = lift( + &found, + Some("5.0.0"), + &child, + &express_ptr(), + &|_| true, + Dialect::Npm, + ) + .expect("lifts across the major"); + assert_eq!((major.version.as_str(), major.requirement), ("5.0.0", None)); +} + +/// Parity decision 2: a release affected by an advisory the installed +/// version does not have is never named, fixed or not; one the installed +/// version already has and no release fixes does not block it. +#[test] +fn a_release_never_introduces_an_advisory() { + let own = Advisories { + ecosystem: "npm", + list: vec![ + adv("GHSA-new", range("1.1.0", "1.2.0")), + adv( + "GHSA-old-unfixed", + Some(r#"[{"type":"SEMVER","events":[{"introduced":"0"}]}]"#.into()), + ), + ], + }; + let newest = Some("1.2.0"); + assert!( + !own.usable("1.1.0", newest, Some("1.0.0")), + "brings in GHSA-new" + ); + assert!( + own.usable("1.2.0", newest, Some("1.0.0")), + "keeps only what 1.0.0 has" ); + assert_eq!(own.introduced_at("1.1.0", "1.0.0"), ["GHSA-new"]); + assert_eq!(own.unfixed_at("1.2.0", newest), ["GHSA-old-unfixed"]); } #[test] @@ -516,7 +546,7 @@ fn a_malicious_release_is_never_named_nor_the_newest() { ..Releases::default() }; assert_eq!(own.newest(&par).as_deref(), Some("1.1.0")); - assert!(!own.usable("2.0.0", Some("1.1.0"))); + assert!(!own.usable("2.0.0", Some("1.1.0"), None)); // An alias counts too. let aliased = Advisory::new( "GHSA-xxxx", diff --git a/src-tauri/src/osv/parent_release_vectors.rs b/src-tauri/src/osv/parent_release_vectors.rs index dd87b0779..f1c5e85d3 100644 --- a/src-tauri/src/osv/parent_release_vectors.rs +++ b/src-tauri/src/osv/parent_release_vectors.rs @@ -108,6 +108,7 @@ fn outcome(standing: &Standing) -> &'static str { Standing::Unread => "unread", Standing::Undecided(_) => "undecided", Standing::Malicious(_) => "malicious", + Standing::Introduces(_) => "introduces", } } @@ -123,22 +124,36 @@ fn command( let Some(declared) = declared else { return Ok(None); }; - if tool != "npm" { - return Err(format!("no project shape for tool {tool}")); - } let dir = tempfile::tempdir().map_err(|e| e.to_string())?; - let pkg_json = json!({ "dependencies": { name: declared } }); - std::fs::write(dir.path().join("package.json"), pkg_json.to_string()) - .map_err(|e| e.to_string())?; - std::fs::write( - dir.path().join("package-lock.json"), - "{\"lockfileVersion\": 3}\n", - ) - .map_err(|e| e.to_string())?; + let write = |file: &str, body: String| { + std::fs::write(dir.path().join(file), body).map_err(|e| e.to_string()) + }; + let eco = match tool { + "npm" => { + write( + "package.json", + json!({ "dependencies": { name: declared } }).to_string(), + )?; + write( + "package-lock.json", + "{\"lockfileVersion\": 3}\n".to_string(), + )?; + "npm" + } + "cargo" => { + write( + "Cargo.toml", + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\n{name} = \"{declared}\"\n"), + )?; + write("Cargo.lock", "version = 3\n".to_string())?; + "crates.io" + } + other => return Err(format!("no project shape for tool {other}")), + }; // The command the lever names: the in-range refresh when the declaration // already admits the release (pr-001), else the install that keeps the // operator; exhaustive so a new kind of fix cannot drop out silently. - Ok(match direct_fix(dir.path(), "npm", name, installed, to) { + Ok(match direct_fix(dir.path(), eco, name, installed, to) { Some(DirectFix::Command(c) | DirectFix::Refresh(c)) => Some(c), Some(DirectFix::Instruction(_)) | None => None, }) @@ -199,8 +214,10 @@ pub(super) fn check_parent_release(v: &Value) -> Result<(), String> { let cleans: Vec bool + '_>> = ads .iter() .zip(&newest) - .map(|(a, n)| { - Box::new(move |x: &str| a.usable(x, n.as_deref())) as Box bool> + .zip(&rows) + .map(|((a, n), r)| { + Box::new(move |x: &str| a.usable(x, n.as_deref(), Some(&r.installed))) + as Box bool> }) .collect(); let inputs: Vec> = rows @@ -226,7 +243,11 @@ pub(super) fn check_parent_release(v: &Value) -> Result<(), String> { &parent_advisories, &inputs, &[], - Dialect::Npm, + if eco == "crates.io" { + Dialect::Cargo + } else { + Dialect::Npm + }, ); let expect = v.get("expect").ok_or("no expect")?; @@ -269,7 +290,7 @@ pub(super) fn check_parent_release(v: &Value) -> Result<(), String> { }, "refresh": to .filter(|f| f.keeps_installed) - .and_then(|_| Manager::Npm.refresh_command(&row.pkg, &row.installed, &row.target)), + .and_then(|_| if eco == "crates.io" { Manager::Cargo } else { Manager::Npm }.refresh_command(&row.pkg, &row.installed, &row.target)), }); if &got != want { return Err(format!("{}: got {got}, want {want}", row.pkg)); diff --git a/src-tauri/src/osv/raised_lines.rs b/src-tauri/src/osv/raised_lines.rs index e2c033eed..e82e81ebe 100644 --- a/src-tauri/src/osv/raised_lines.rs +++ b/src-tauri/src/osv/raised_lines.rs @@ -98,8 +98,41 @@ fn note(package: &str, old: &str, to: &str, packages: &[String]) -> String { } else { format!("{package} {to} is the highest of the {package} releases they need") }; + // Across a major (parity decision 3): flagged, never silent. + let breaking = if crate::osv::parent_release::is_major(old, to) { + format!( + " BREAKING: the only release of {package} that fixes {which} is {to}, a major \ + upgrade." + ) + } else { + String::new() + }; format!( " Upgrade to {package} {to}, not {old}: {package} {old} still requires a vulnerable \ - {which}; {why}, and no advisory of {package} affects it." + {which}; {why}, and no advisory of {package} affects it.{breaking}" ) } + +#[cfg(test)] +mod tests { + /// Parity decision 3 (pr-009's case, xsv, 2026-10-18): the raise across a + /// major is flagged with its reason. + #[test] + fn a_raise_across_a_major_says_why() { + let text = super::note( + "crossbeam-channel", + "0.4.4", + "0.5.0", + &["crossbeam-utils".to_string()], + ); + assert!( + text.contains( + "BREAKING: the only release of crossbeam-channel that fixes crossbeam-utils is \ + 0.5.0, a major upgrade." + ), + "{text}" + ); + let minor = super::note("express", "4.20.0", "4.22.3", &["qs".to_string()]); + assert!(!minor.contains("BREAKING"), "{minor}"); + } +} diff --git a/src-tauri/src/osv/reachability_tests.rs b/src-tauri/src/osv/reachability_tests.rs index fa37a9493..5aa750587 100644 --- a/src-tauri/src/osv/reachability_tests.rs +++ b/src-tauri/src/osv/reachability_tests.rs @@ -193,6 +193,7 @@ fn instance(project: &str, version: &str, confirmed: bool) -> MatchedDependency clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), } } diff --git a/src-tauri/src/osv/vectors_tests.rs b/src-tauri/src/osv/vectors_tests.rs index 430b2a6e9..4e707cb50 100644 --- a/src-tauri/src/osv/vectors_tests.rs +++ b/src-tauri/src/osv/vectors_tests.rs @@ -195,17 +195,27 @@ fn same_version(eco: &str, a: Option<&str>, b: Option<&str>) -> bool { /// lands (`clears_all_known` when it clears everything), else, when the walk /// cannot run, the highest own-line fix among the advisories that affect the /// copy. Neither is ever a release `registry` withdrew; the third value names -/// the withdrawn ones the walk stepped over. +/// the withdrawn ones the walk stepped over; the fourth, the indices of the +/// advisories every fixing release brings in (then there is no target, not +/// even a line fix: parity decision 2). fn engine_target( eco: &str, installed: &str, ranges: &[Option], registry: &RegistryReleases, -) -> (Option, bool, Vec) { +) -> (Option, bool, Vec, Vec) { let refs: Vec<&Option> = ranges.iter().collect(); let outcome = walk_target_in(eco, installed, &refs, registry); if let Some(walk) = outcome.target { - return (Some(walk.version), walk.clears_all, outcome.withheld); + return ( + Some(walk.version), + walk.clears_all, + outcome.withheld, + Vec::new(), + ); + } + if !outcome.introduces.is_empty() { + return (None, false, outcome.withheld, outcome.introduces); } let partial = ranges .iter() @@ -214,7 +224,7 @@ fn engine_target( .filter_map(|f| parse_version(&f).map(|p| (p, f))) .max_by(|a, b| a.0.cmp(&b.0)) .map(|(_, f)| f); - (partial, false, outcome.withheld) + (partial, false, outcome.withheld, Vec::new()) } /// A string-array field of a vector line (absent reads as empty). @@ -230,6 +240,7 @@ fn strings(v: &Value, key: &str) -> Vec { fn check_fix_target(v: &Value, records: &mut Records) -> Result<(), String> { let (eco, name, installed) = (s(v, "eco"), s(v, "name"), s(v, "installed")); let mut ranges = Vec::new(); + let ids = strings(v, "record_ids"); for id in v .get("record_ids") .and_then(Value::as_array) @@ -242,7 +253,21 @@ fn check_fix_target(v: &Value, records: &mut Records) -> Result<(), String> { published: strings(v, "published"), excluded: strings(v, "excluded"), }; - let (target, clears, withheld) = engine_target(eco, installed, &ranges, ®istry); + let (target, clears, withheld, introduced) = engine_target(eco, installed, &ranges, ®istry); + // `introduces` (record ids) is checked only on lines that state it. + // Sorted and deduped, as both engines name them. + let mut introduced: Vec = introduced + .iter() + .filter_map(|&i| ids.get(i).cloned()) + .collect(); + introduced.sort(); + introduced.dedup(); + if v.get("introduces").is_some() && introduced != strings(v, "introduces") { + return Err(format!( + "introduces: got {introduced:?}, want {:?}", + strings(v, "introduces") + )); + } let want_target = opt_s(v, "target"); let want_clears = v.get("clears").and_then(Value::as_bool).unwrap_or(false); if !same_version(eco, target.as_deref(), want_target) { diff --git a/src-tauri/src/preemption.rs b/src-tauri/src/preemption.rs index d4864451b..9d7c02dc0 100644 --- a/src-tauri/src/preemption.rs +++ b/src-tauri/src/preemption.rs @@ -588,8 +588,14 @@ pub(crate) fn alert_fix_target( } else { "" }; + let introduced = crate::osv::fix_target::introduced_by(lines); let sentence = match (best_fix.as_deref(), targets.len()) { (Some(f), _) => format!(" Update to >= {f}.{major}"), + // Parity decision 2: say why nothing is named. + (None, 0) if !introduced.is_empty() => format!( + " No release is named: {}.", + crate::osv::fix_target::introduces_text(&introduced) + ), (None, 0) => String::new(), (None, _) => format!( " Update each copy on its own line: {}.{major}", @@ -3235,6 +3241,7 @@ mod tests { clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), }, crate::osv::types::MatchedDependency { project_path: "/direct".into(), @@ -3246,6 +3253,7 @@ mod tests { clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), }, ], }; @@ -3288,6 +3296,7 @@ mod tests { clean_version: Some(fix.into()), target_version: Some(fix.into()), withheld: Vec::new(), + introduces: Vec::new(), }; let advisory = crate::osv::types::MatchedAdvisory { advisory_id: "GHSA-q2hr-2g5m-vwhr".into(), @@ -3378,6 +3387,7 @@ mod tests { clean_version: None, target_version: None, withheld: Vec::new(), + introduces: Vec::new(), }], }; let projects = vec!["/paddle-webhook".to_string()]; diff --git a/src-tauri/tests/conformance/osv/SYNT-0000-0001.json b/src-tauri/tests/conformance/osv/SYNT-0000-0001.json new file mode 100644 index 000000000..f25ed6c18 --- /dev/null +++ b/src-tauri/tests/conformance/osv/SYNT-0000-0001.json @@ -0,0 +1 @@ +{"schema_version":"1.6.0","id":"SYNT-0000-0001","modified":"2026-10-19T00:00:00Z","published":"2026-10-19T00:00:00Z","summary":"SYNTHETIC: fixed at 1.2.0","details":"SYNTHETIC record for conformance vector ft-069 (parity decision 2). Not a real advisory; the package name does not exist.","affected":[{"package":{"ecosystem":"npm","name":"4da-synthetic-ft069"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.2.0"}]}]}]} diff --git a/src-tauri/tests/conformance/osv/SYNT-0000-0002.json b/src-tauri/tests/conformance/osv/SYNT-0000-0002.json new file mode 100644 index 000000000..8d547b4ff --- /dev/null +++ b/src-tauri/tests/conformance/osv/SYNT-0000-0002.json @@ -0,0 +1 @@ +{"schema_version":"1.6.0","id":"SYNT-0000-0002","modified":"2026-10-19T00:00:00Z","published":"2026-10-19T00:00:00Z","summary":"SYNTHETIC: opens at 1.1.0, no fix","details":"SYNTHETIC record for conformance vector ft-069 (parity decision 2). Not a real advisory; the package name does not exist.","affected":[{"package":{"ecosystem":"npm","name":"4da-synthetic-ft069"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.1.0"}]}]}]} diff --git a/src-tauri/tests/conformance/vectors/README.md b/src-tauri/tests/conformance/vectors/README.md index d00318c6c..6418c85a0 100644 --- a/src-tauri/tests/conformance/vectors/README.md +++ b/src-tauri/tests/conformance/vectors/README.md @@ -30,11 +30,11 @@ specific enough to recheck). |---|---|---| | `version_order.jsonl` | `{eco, a, b, cmp}`, `cmp` one of `<` `=` `>` | PEP 440, SemVer 2.0.0 and the Go Modules Reference spec examples. Every line is cross-checked when written: PyPI lines with python `packaging` 25.0, the others with node `semver` 7 (Go with the `v` stripped). | | `osv_range.jsonl` | `{record_id, eco, name, version, affected}` | OSV's own answer: `/v1/querybatch` at the exact version, recorded in `../expected/.json` (records pinned 2026-10-10 in `../osv/`). `affected: true` is used only when the finding's alias group holds exactly one pinned record for that package, so OSV returned exactly that record. | -| `fix_target.jsonl` | `{eco, name, installed, record_ids[], target, clears, evidence}`, optionally `published[]`, `excluded[]`, `withheld[]` | ft-001..ft-022: the 22 cases of the 2026-10-10 fix-path oracle (the action was applied, re-resolved and re-scanned, and the target checked minimal against OSV per published version), re-checked 2026-10-12 against the pinned ids. ft-023..: OSV `/v1/querybatch` per published stable release (2026-10-12), restricted to the pinned `record_ids`. `evidence` holds the OSV ids at `installed` and at `target`. | +| `fix_target.jsonl` | `{eco, name, installed, record_ids[], target, clears, evidence}`, optionally `published[]`, `excluded[]`, `withheld[]`, `introduces[]` | ft-001..ft-022: the 22 cases of the 2026-10-10 fix-path oracle (the action was applied, re-resolved and re-scanned, and the target checked minimal against OSV per published version), re-checked 2026-10-12 against the pinned ids. ft-023..: OSV `/v1/querybatch` per published stable release (2026-10-12), restricted to the pinned `record_ids`. `evidence` holds the OSV ids at `installed` and at `target`. | | `refresh_verdict.jsonl` | `{eco, name, installed, target, requirements[], verdict, via, inferred}` | The 16 transitive oracle cases (the refresh was applied: it reached the target, or it did not move the copy), each with its parents' requirements read from the case lockfile (`package-lock.json` `requires`, `yarn.lock` `dependencies`) or the parent's published manifest (npm registry for pnpm, crates.io for Cargo). Each case also appears with `requirements: []` to pin the semver fallback. The rest apply AD-055 ¶1 by hand. | | `fix_command.jsonl` | `{tool, eco, pkg, installed, target, workspace_root, command}`; `kind: "direct"` lines add `declared` (npm family), `manifest` (Cargo), `go_mod` (Go) or `requirements` (PyPI), optionally `member: true` (the manifest sits one workspace level below the lockfile), and `instruction` | The command text of AD-055 ¶4 for a transitive copy's refresh. Where the oracle applied that tool's refresh, the `source` names the case. `kind: "direct"` lines pin the fix for a DECLARED dependency, read from the project's own manifest, that keeps how it is declared (audit E8): `command` when one command does it, `instruction` for a manifest edit, both null when nothing can be named with certainty. Each line's `source` says whether it was run against its tool (and which version) or is a rule with no tool run. | | `range_check.jsonl` | `{eco, name, constraint, from, releases[], record_ids[], expected}` | `@4da/mcp-server` 6.2.0 recorded fixtures (`src/__tests__/fixtures/dependency-check/recorded/`, origin/main `bae421b`): OSV's querybatch answer per release of ws, Jinja2 and golang.org/x/net. Constraint membership: node `semver` (npm, Go without `v`; `v0` is the v0 line), python `packaging` `SpecifierSet` (PyPI). | -| `parent_release.jsonl` | `{eco, tool, parent{...}, rows[], expect{parent_target, rows[]}}` (below) | pr-001..pr-008: registry.npmjs.org abbreviated documents and OSV.dev `/v1/query`, read 2026-10-18 (generated once, then the rule applied by hand); OWASP/NodeGoat's `package-lock.json`; npm 10.9.2 for the install-keeps-the-copy case. pr-001..pr-004 and pr-006 agree with `@4da/mcp-server` fix/planner-advice-625 `1840dab` run on the same projects. | +| `parent_release.jsonl` | `{eco, tool, parent{...}, rows[], expect{parent_target, rows[]}}` (below) | pr-001..pr-008: registry.npmjs.org abbreviated documents and OSV.dev `/v1/query`, read 2026-10-18 (generated once, then the rule applied by hand); pr-009: index.crates.io sparse records and OSV, 2026-10-18 (the MCP terminal's line, verbatim; `tool: cargo`, Cargo requirement dialect); pr-010..pr-012 and ft-069: SYNTHETIC (marked in their source); OWASP/NodeGoat's `package-lock.json`; npm 10.9.2 for the install-keeps-the-copy case. pr-001..pr-004 and pr-006 agree with `@4da/mcp-server` fix/planner-advice-625 `1840dab` run on the same projects. | | `holding.jsonl` | `{eco, name, advisory_id, version, holding}` | npm's security holding placeholders (`0.0.-security`; a publisher's `1.0.0-security` prerelease is not one: ho-006): the registry's own description ("security holding package") and the advisories that flag the name. `holding: true` means no advisory applies to that release (audit L4 decision, 2026-10-16). | | `severity.jsonl` | `{record \| records[], fixed_version?, aliases_available, expect: {severity, severity_from, urgency}}`; a `record` is `{id, summary, severity?, database_specific?: {severity?, cwe_ids?}, aliases[]}` as OSV gives it | api.osv.dev `/v1/vulns/` and the app's local mirror (`osv_advisories`, read-only), both read on the date in `source`; by-hand lines say so. `aliases_available` maps each GHSA id the mirror holds to its grade (`null`: held, no grade); an id absent from it cannot be read. | @@ -51,7 +51,16 @@ Field conventions: - A `fix_target` `target` is compared as a version (build metadata ignored). `clears: false` means the target clears only the advisories that have a fix. `target: null` means no advisory on the copy has one, or (with - `excluded`) every release that has one was withdrawn. + `excluded`) every release that has one was withdrawn, or (with + `introduces`) every fixing release brings in an advisory the installed + version does not have. +- Optional `fix_target` `introduces[]` (parity decision 2, 2026-10-19; + expected, checked only when present): the record ids of the advisories + with no way out that affect where the walk lands but not `installed`, + sorted and deduped. Non-empty means no target AND no line fix in its + place. Records whose file names start `SYNT-` under `osv/` are SYNTHETIC + (marked in their `details`); `scripts/conformance-corpus.mjs` keeps them + on a re-pin and never fetches them. - Optional `fix_target` registry fields (FX-H5, 2026-10-16). Lines without them run on the advisory data alone, as before: - `published[]`: the registry's releases above `installed`, as the @@ -86,7 +95,7 @@ Field conventions: one window per range (OSV's events, split). - `expect.parent_target`: the parent's own row after the raise (`null` without an own row). `expect.rows[]`, in row order: `outcome` - (`release`, `deprecated`, `latest`, `none_admits`, `unread`), `smallest` + (`release`, `deprecated`, `latest`, `none_admits`, `unread`, `undecided`, `malicious`, `introduces`), `smallest` (the smallest admitting release), `to` (the release named, lifted to `parent_target` when that still admits a clean copy), `breaking`, `requirement`, `peer`, `resolves_to` (what a fresh resolve of @@ -196,6 +205,7 @@ form. - A `last_affected` window that later releases leave (the walk steps past the bound and keeps checking every advisory): ft-043 (pyjwt), ft-052 (mistune) - A release the registry withdrew is never the target (FX-H5): npm deprecated, ft-007 (lodash 4.18.0, "Bad release", so 4.18.1) and ft-063 (brace-expansion 3.0.3, so 3.0.4); crates.io yanked, ft-062 (h2 0.3.17, so 0.3.18); every clearing release withdrawn, so no target and `withheld` names it, ft-064 (by hand) - A whole-major npm deprecation notice is not a withdrawal, so the target stays on the installed line (review of #976): ft-067 (glob 10.4.5 -> 10.5.0, not 12.0.0), ft-068 (tar 6.1.11 -> 6.2.1, while tar 7.0.0..7.5.7 are excluded because 7.5.8 is clean) +- A target never brings in an advisory (parity decision 2, review of #994 B2): an advisory with no way out that the installed version does not have blocks every fixing release, so no target and no line fix; SYNTHETIC records, no live case across the 11 audit projects: ft-069 ### Refresh verdict — AD-055 ¶1, ¶2, ¶3 @@ -302,6 +312,31 @@ the parent upgrades that name it as their lever agree on its release."* - A whole old major deprecated with one notice is not withdrawn (the decided FX-H5 rule; the engines must agree): glob 11.0.3's target stays 11.1.0, and @nestjs/cli 11.0.11 is the release that admits it: pr-008 +- The only fix across a major raises the parent's own row, flagged breaking + (parity decision 3, 2026-10-18; Cargo, from live crates.io sparse records + and OSV): xsv's crossbeam-channel 0.2.4, own row 0.4.4, raised to 0.5.0 for + crossbeam-utils; the reason "the only release of crossbeam-channel that + fixes crossbeam-utils is 0.5.0, a major upgrade" is pinned in + `osv::raised_lines` tests (the line format has no field for it): pr-009 + (written by the MCP terminal, copied verbatim) +- A move never introduces an advisory (parity decision 2): no release is + named when every fixing one is affected by an advisory the installed + version does not have, outcome `introduces` (text "every fixing release + brings in , which the installed version does not have", pinned in the + app's tests); a SYNTHETIC rule line, no clean live case exists: pr-010 +- The same for a declared lever (review of #994, S3): a lever release that + would bring a fixed parent in, blocked only by an advisory the installed + lever does not have, makes the lever `release: "introduces"` in a graph + line; SYNTHETIC: pr-011 +- A release blocked ONLY by advisories the installed version lacks + introduces them whether they are fixed or not (review of #994, S2; the + MCP's `cleanButIntroduced`), and the ids are the union over every such + release, sorted (S4); SYNTHETIC: pr-012 +- Scope of the cross-major raise (review of #994, S1): the app raises a row + across a major in production for **npm only** (`parent_release_load` + reads npm registry facts); Cargo is pinned at the rule level only + (pr-009 runs `decide()`), and wiring it needs per-version crates.io + dependency records (follow-up). ### Severity — one grade per advisory (2026-10-18) diff --git a/src-tauri/tests/conformance/vectors/fix_target.jsonl b/src-tauri/tests/conformance/vectors/fix_target.jsonl index 71c29f244..d207b5ff4 100644 --- a/src-tauri/tests/conformance/vectors/fix_target.jsonl +++ b/src-tauri/tests/conformance/vectors/fix_target.jsonl @@ -66,3 +66,4 @@ {"id":"ft-066","rule":"AD-055 ¶5 (readiness audit H2): RubyGems same-line fix (the TS engine sent rack 2.0.6 to 3.2.6)","source":"OSV /v1/querybatch per published stable release (2026-10-17), restricted to the pinned record ids: smallest release above `installed` that no advisory a later release fixes affects (registry release lists: repo.packagist.org p2, rubygems.org versions API)","eco":"RubyGems","name":"rack","installed":"2.0.6","record_ids":["GHSA-22f2-v57c-j9cx","GHSA-3h57-hmj3-gj3p","GHSA-3pxh-h8hw-mj8w","GHSA-47m2-26rw-j2jw","GHSA-54rr-7fvw-6x8f","GHSA-5f9h-9pjv-v6j7","GHSA-5r2p-j47h-mhpg","GHSA-625h-95r8-8xpm","GHSA-65f5-mfpf-vfhj","GHSA-6xw4-3v39-52mm","GHSA-7g2v-jj9q-g3rg","GHSA-7mqq-6cf9-v2qp","GHSA-7wqh-767x-r66v","GHSA-85r7-w5mv-c849","GHSA-8cgq-6mh2-7j6v","GHSA-8vqr-qjwx-82mw","GHSA-93pm-5p5f-3ghx","GHSA-c6qg-cjj8-47qp","GHSA-cj83-2ww7-mvq7","GHSA-g2pf-xv49-m2h5","GHSA-gjh7-p2fx-99vx","GHSA-h2jq-g4cq-5ppq","GHSA-h77x-m5q8-c29h","GHSA-hg78-4f6x-99wq","GHSA-hrqr-hxpp-chr3","GHSA-hxqx-xwvh-44m2","GHSA-j6w9-fv6q-3q52","GHSA-mxw3-3hh2-x2mh","GHSA-p543-xpfm-54cp","GHSA-q2ww-5357-x388","GHSA-q4qf-9j86-f5mh","GHSA-qfgr-crr9-7r49","GHSA-qv7j-4883-hwh7","GHSA-r657-rxjc-j557","GHSA-rgr4-9jh5-j4j6","GHSA-rqv2-275x-2jq5","GHSA-rx22-g9mx-qrhv","GHSA-v569-hp3g-36wr","GHSA-v6j3-7jrw-hq2p","GHSA-v6x5-cg8r-vv6x","GHSA-v882-ccj6-jc48","GHSA-vgpv-f759-9wx3","GHSA-vpfw-47h7-xj4g","GHSA-w9pc-fmgc-vxvw","GHSA-whrj-4476-wvmp","GHSA-wpv5-97wm-hp9c","GHSA-wq4h-7r42-5hrr","GHSA-x8cg-fq8g-mxfx","GHSA-xc85-32mf-xpv8","GHSA-xj5v-6v4g-jfw6"],"target":"2.2.23","clears":true,"evidence":{"osv_ids_at_installed":["GHSA-22f2-v57c-j9cx","GHSA-3h57-hmj3-gj3p","GHSA-54rr-7fvw-6x8f","GHSA-5f9h-9pjv-v6j7","GHSA-625h-95r8-8xpm","GHSA-65f5-mfpf-vfhj","GHSA-6xw4-3v39-52mm","GHSA-7g2v-jj9q-g3rg","GHSA-7mqq-6cf9-v2qp","GHSA-7wqh-767x-r66v","GHSA-8cgq-6mh2-7j6v","GHSA-8vqr-qjwx-82mw","GHSA-93pm-5p5f-3ghx","GHSA-c6qg-cjj8-47qp","GHSA-gjh7-p2fx-99vx","GHSA-h2jq-g4cq-5ppq","GHSA-hrqr-hxpp-chr3","GHSA-hxqx-xwvh-44m2","GHSA-j6w9-fv6q-3q52","GHSA-mxw3-3hh2-x2mh","GHSA-p543-xpfm-54cp","GHSA-q2ww-5357-x388","GHSA-q4qf-9j86-f5mh","GHSA-qv7j-4883-hwh7","GHSA-r657-rxjc-j557","GHSA-rqv2-275x-2jq5","GHSA-v569-hp3g-36wr","GHSA-vgpv-f759-9wx3","GHSA-vpfw-47h7-xj4g","GHSA-w9pc-fmgc-vxvw","GHSA-whrj-4476-wvmp","GHSA-wpv5-97wm-hp9c","GHSA-wq4h-7r42-5hrr","GHSA-x8cg-fq8g-mxfx","GHSA-xj5v-6v4g-jfw6"],"osv_ids_at_target":[],"stable_releases_checked":101}} {"id":"ft-067","rule":"AD-055 ¶5 + FX-H5: an npm `deprecated` notice excludes a release only when a later, non-deprecated stable release exists in its caret range (same major; same minor for 0.x). A whole-major \"please upgrade\" notice is not a withdrawn release, so the target stays on the installed line","source":"npm abbreviated packument for glob (2026-10-17): 10.5.0 is deprecated (\"Old versions of glob are not supported, ... Please update.\", a whole-package notice) but no later 10.x release is free of a deprecation notice, so it is not excluded; the excluded list is that rule applied to every release above 10.4.5. OSV /v1/query (2026-10-17) at 10.4.5: [\"GHSA-5j98-mcp5-4vw2\"], at 10.5.0: []. Restricted to the pinned record ids","eco":"npm","name":"glob","installed":"10.4.5","record_ids":["GHSA-5j98-mcp5-4vw2"],"published":["10.5.0","11.0.0","11.0.1","11.0.2","11.0.3","11.1.0","12.0.0","13.0.0","13.0.1","13.0.2","13.0.3","13.0.4","13.0.5","13.0.6"],"excluded":[],"target":"10.5.0","clears":true,"withheld":[],"evidence":{"osv_ids_at_installed":["GHSA-5j98-mcp5-4vw2"],"osv_ids_at_target":[]}} {"id":"ft-068","rule":"AD-055 ¶5 + FX-H5: an npm `deprecated` notice excludes a release only when a later, non-deprecated stable release exists in its caret range (same major; same minor for 0.x). A whole-major \"please upgrade\" notice is not a withdrawn release, so the target stays on the installed line","source":"npm abbreviated packument for tar (2026-10-17): 6.2.1 is deprecated (\"Old versions of tar are not supported, ... Please update.\", a whole-package notice) but no later 6.x release is free of a deprecation notice, so it is not excluded; the excluded list is that rule applied to every release above 6.1.11. OSV /v1/query (2026-10-17) at 6.1.11: [\"GHSA-f5x3-32g6-xq36\"], at 6.2.1: []. Restricted to the pinned record ids","eco":"npm","name":"tar","installed":"6.1.11","record_ids":["GHSA-f5x3-32g6-xq36"],"published":["6.1.12","6.1.13","6.1.14","6.1.15","6.2.0","6.2.1","7.0.0","7.0.1","7.1.0","7.2.0","7.3.0","7.4.0","7.4.1","7.4.2","7.4.3","7.4.4","7.5.0","7.5.1","7.5.2","7.5.3","7.5.4","7.5.5","7.5.6","7.5.7","7.5.8","7.5.9","7.5.10","7.5.11","7.5.12","7.5.13","7.5.14","7.5.15","7.5.16","7.5.17","7.5.18","7.5.19","7.5.20","7.5.21","7.5.22"],"excluded":["7.0.0","7.0.1","7.1.0","7.2.0","7.3.0","7.4.0","7.4.1","7.4.2","7.4.3","7.4.4","7.5.0","7.5.1","7.5.2","7.5.3","7.5.4","7.5.5","7.5.6","7.5.7"],"target":"6.2.1","clears":true,"withheld":[],"evidence":{"osv_ids_at_installed":["GHSA-f5x3-32g6-xq36"],"osv_ids_at_target":[]}} +{"id":"ft-069","rule":"Parity decision 2 (review of #994, B2): a target never brings in an advisory. SYNT-0000-0001 is fixed at 1.2.0; SYNT-0000-0002 opens at 1.1.0 with no fix and does not affect the installed 1.0.0, so every fixing release brings it in: no target, and no line fix in its place (introduces: sorted, deduped record ids). An installed version that already has SYNT-0000-0002 (1.1.5) gets 1.2.0 with clears false (unit test a_target_never_brings_in_an_advisory_the_installed_version_lacks)","source":"SYNTHETIC (no live case across the 11 audit projects; agreed with the MCP terminal 2026-10-19): records osv/SYNT-0000-0001.json and SYNT-0000-0002.json are invented, for the invented npm package 4da-synthetic-ft069","eco":"npm","name":"4da-synthetic-ft069","installed":"1.0.0","record_ids":["SYNT-0000-0001","SYNT-0000-0002"],"published":["1.0.0","1.1.0","1.2.0"],"excluded":[],"target":null,"clears":false,"introduces":["SYNT-0000-0002"]} diff --git a/src-tauri/tests/conformance/vectors/parent_release.jsonl b/src-tauri/tests/conformance/vectors/parent_release.jsonl index f22ed8269..0da8189af 100644 --- a/src-tauri/tests/conformance/vectors/parent_release.jsonl +++ b/src-tauri/tests/conformance/vectors/parent_release.jsonl @@ -6,3 +6,7 @@ {"id":"pr-006","rule":"A parent whose latest release is deprecated names no release: no upgrade of it will widen its requirement, so the copy is forced or the parent replaced (audit A4 via H1)","source":"registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json) and OSV.dev /v1/query, read 2026-10-18; request 2.88.0 (NodeGoat's cypress/node_modules/request), whose every release is deprecated (\"request has been deprecated, see https://github.com/request/request/issues/3142\")","eco":"npm","tool":"npm","parent":{"name":"request","installed":"2.88.0","declared":"2.88.0","lever":"direct","latest":"2.88.2","deprecated":true,"releases":[],"advisories":[{"id":"GHSA-7xfp-9c55-5vqj","ranges":[{"introduced":"2.49.0","fixed":"2.68.0"},{"introduced":"2.2.6","fixed":"2.68.0"}]},{"id":"GHSA-p8p7-x288-28g6","ranges":[{"introduced":"0","last_affected":"2.88.2"}]}],"own_target":null},"rows":[{"pkg":"qs","installed":"6.5.2","target":"6.16.0","releases":["6.0.0","6.0.1","6.0.2","6.0.3","6.0.4","6.0.6","6.1.0","6.1.1","6.1.2","6.1.4","6.2.0","6.2.1","6.2.2","6.2.3","6.2.4","6.2.6","6.3.0","6.3.1","6.3.2","6.3.3","6.3.5","6.4.0","6.4.1","6.4.3","6.5.0","6.5.1","6.5.2","6.5.3","6.5.5","6.6.0","6.6.1","6.6.3","6.7.0","6.7.1","6.7.2","6.7.3","6.7.5","6.8.0","6.8.1","6.8.2","6.8.3","6.8.5","6.9.0","6.9.1","6.9.2","6.9.3","6.9.4","6.9.5","6.9.6","6.9.7","6.9.9","6.10.0","6.10.1","6.10.2","6.10.3","6.10.4","6.10.7","6.11.0","6.11.1","6.11.2","6.11.4","6.12.0","6.12.1","6.12.2","6.12.3","6.12.5","6.13.0","6.13.1","6.13.3","6.14.0","6.14.1","6.14.2","6.15.0","6.15.1","6.15.2","6.15.3","6.16.0","6.16.1"],"advisories":[{"id":"GHSA-4mjr-xmp4-gh2g","ranges":[{"introduced":"2.2.5","fixed":"6.16.0"}]},{"id":"GHSA-6rw7-vpxm-498p","ranges":[{"introduced":"0","fixed":"6.14.1"}]},{"id":"GHSA-f9cm-p3w6-xvr3","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-gqgv-6jq5-jjj9","ranges":[{"introduced":"0","fixed":"6.0.4"},{"introduced":"6.1.0","fixed":"6.1.2"},{"introduced":"6.2.0","fixed":"6.2.3"},{"introduced":"6.3.0","fixed":"6.3.2"}]},{"id":"GHSA-hrpp-h998-j3pp","ranges":[{"introduced":"6.10.0","fixed":"6.10.3"},{"introduced":"6.9.0","fixed":"6.9.7"},{"introduced":"6.8.0","fixed":"6.8.3"},{"introduced":"6.7.0","fixed":"6.7.3"},{"introduced":"6.6.0","fixed":"6.6.1"},{"introduced":"6.5.0","fixed":"6.5.3"},{"introduced":"6.4.0","fixed":"6.4.1"},{"introduced":"6.3.0","fixed":"6.3.3"},{"introduced":"0","fixed":"6.2.4"}]},{"id":"GHSA-jjv7-qpx3-h62q","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-q8mj-m7cp-5q26","ranges":[{"introduced":"6.11.1","fixed":"6.15.2"}]},{"id":"GHSA-w7fw-mjwx-w883","ranges":[{"introduced":"6.7.0","fixed":"6.14.2"}]},{"id":"GHSA-x5fp-wj9c-mxmx","ranges":[{"introduced":"6.14.2","fixed":"6.16.0"}]}],"requires":{}}],"expect":{"parent_target":null,"rows":[{"pkg":"qs","outcome":"deprecated","smallest":null,"to":null,"breaking":false,"requirement":null,"peer":false,"resolves_to":null,"command":null,"refresh":null}]}} {"id":"pr-007","rule":"Graph mode, the whole pipeline: one release per declared dependency on every row (audit H1 rules a-c, e, f; review of #981 B1, S4, S5). express 4.16.4's own row (4.20.0) rises to 4.22.3, the release qs needs; path-to-regexp (smallest 4.22.0), serve-static and send (smallest 4.20.0) lift to it; send is also pinned by serve-static 1.13.2, which arrives through express: serve-static 1.16.1 admits a clean send, and express's release for it is the smallest that brings in such a serve-static (4.21.0, which brings serve-static 1.16.2) lifted to express's own row, 4.22.3, which brings serve-static 1.16.3: the lever's smallest (4.21.0) and the parent's floor (1.16.1) are separate fields","source":"OWASP/NodeGoat package-lock.json (express 4.16.4 declared; path-to-regexp 0.1.7, qs 6.5.2, serve-static 1.13.2, send 0.16.2; serve-static 1.13.2 requires send 0.16.2); registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json), RAW: every release in the cut with its own deprecation notice, nothing pre-filtered; OSV.dev /v1/query; both read 2026-10-18; the same releases as @4da/mcp-server fix/planner-advice-625 1840dab upgrade_planner on NodeGoat (express 4.22.3 on every row; serve-static 1.16.1; express 4.21.0 brings serve-static 1.16.2, lifted to 4.22.3)","eco":"npm","mode":"graph","nodes":[["express","4.16.4",true],["path-to-regexp","0.1.7",false],["qs","6.5.2",false],["serve-static","1.13.2",false],["send","0.16.2",false]],"edges":[["express","4.16.4","path-to-regexp","0.1.7"],["express","4.16.4","qs","6.5.2"],["express","4.16.4","serve-static","1.13.2"],["express","4.16.4","send","0.16.2"],["serve-static","1.13.2","send","0.16.2"]],"advisories":[{"id":"GHSA-cm5g-3pgc-8rg4","package":"express","ranges":[{"introduced":"0","fixed":"4.0.0-rc1"}]},{"id":"GHSA-gpvr-g6gh-9mc2","package":"express","ranges":[{"introduced":"0","fixed":"3.11.0"},{"introduced":"4.0.0","fixed":"4.5.0"}]},{"id":"GHSA-jj78-5fmv-mv28","package":"express","ranges":[{"introduced":"3.4.5","fixed":"4.0.0-rc1"}]},{"id":"GHSA-qw6h-vgh9-j6wx","package":"express","ranges":[{"introduced":"0","fixed":"4.20.0"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0"}]},{"id":"GHSA-rv95-896h-c2vc","package":"express","ranges":[{"introduced":"0","fixed":"4.19.2"},{"introduced":"5.0.0-alpha.1","fixed":"5.0.0-beta.3"}]},{"id":"GHSA-27v5-c462-wpq7","package":"path-to-regexp","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-37ch-88jc-xwx2","package":"path-to-regexp","ranges":[{"introduced":"0","fixed":"0.1.13"}]},{"id":"GHSA-9wv6-86v2-598j","package":"path-to-regexp","ranges":[{"introduced":"0.2.0","fixed":"1.9.0"},{"introduced":"0","fixed":"0.1.10"},{"introduced":"7.0.0","fixed":"8.0.0"},{"introduced":"2.0.0","fixed":"3.3.0"},{"introduced":"4.0.0","fixed":"6.3.0"}]},{"id":"GHSA-j3q9-mxjg-w52f","package":"path-to-regexp","ranges":[{"introduced":"8.0.0","fixed":"8.4.0"}]},{"id":"GHSA-rhx6-c78j-4q9w","package":"path-to-regexp","ranges":[{"introduced":"0","fixed":"0.1.12"}]},{"id":"GHSA-4mjr-xmp4-gh2g","package":"qs","ranges":[{"introduced":"2.2.5","fixed":"6.16.0"}]},{"id":"GHSA-6rw7-vpxm-498p","package":"qs","ranges":[{"introduced":"0","fixed":"6.14.1"}]},{"id":"GHSA-f9cm-p3w6-xvr3","package":"qs","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-gqgv-6jq5-jjj9","package":"qs","ranges":[{"introduced":"0","fixed":"6.0.4"},{"introduced":"6.1.0","fixed":"6.1.2"},{"introduced":"6.2.0","fixed":"6.2.3"},{"introduced":"6.3.0","fixed":"6.3.2"}]},{"id":"GHSA-hrpp-h998-j3pp","package":"qs","ranges":[{"introduced":"6.10.0","fixed":"6.10.3"},{"introduced":"6.9.0","fixed":"6.9.7"},{"introduced":"6.8.0","fixed":"6.8.3"},{"introduced":"6.7.0","fixed":"6.7.3"},{"introduced":"6.6.0","fixed":"6.6.1"},{"introduced":"6.5.0","fixed":"6.5.3"},{"introduced":"6.4.0","fixed":"6.4.1"},{"introduced":"6.3.0","fixed":"6.3.3"},{"introduced":"0","fixed":"6.2.4"}]},{"id":"GHSA-jjv7-qpx3-h62q","package":"qs","ranges":[{"introduced":"0","fixed":"1.0.0"}]},{"id":"GHSA-q8mj-m7cp-5q26","package":"qs","ranges":[{"introduced":"6.11.1","fixed":"6.15.2"}]},{"id":"GHSA-w7fw-mjwx-w883","package":"qs","ranges":[{"introduced":"6.7.0","fixed":"6.14.2"}]},{"id":"GHSA-x5fp-wj9c-mxmx","package":"qs","ranges":[{"introduced":"6.14.2","fixed":"6.16.0"}]},{"id":"GHSA-c3x7-gjmx-r2ff","package":"serve-static","ranges":[{"introduced":"0","fixed":"1.7.2"},{"introduced":"1.7.0","fixed":"1.7.2"}]},{"id":"GHSA-cm22-4g7w-348p","package":"serve-static","ranges":[{"introduced":"0","fixed":"1.16.0"},{"introduced":"2.0.0","fixed":"2.1.0"}]},{"id":"GHSA-jgqf-hwc5-hh37","package":"send","ranges":[{"introduced":"0","fixed":"0.11.1"}]},{"id":"GHSA-m6fv-jmcg-4jfg","package":"send","ranges":[{"introduced":"0","fixed":"0.19.0"}]},{"id":"GHSA-xwg4-93c6-3h42","package":"send","ranges":[{"introduced":"0","fixed":"0.8.4"}]}],"registry":{"express":{"dist-tags":{"latest":"5.3.0"},"versions":{"4.16.0":{"dependencies":{"qs":"6.5.1","send":"0.16.0","serve-static":"1.13.0","path-to-regexp":"0.1.7"}},"4.16.1":{"dependencies":{"qs":"6.5.1","send":"0.16.1","serve-static":"1.13.1","path-to-regexp":"0.1.7"}},"4.16.2":{"dependencies":{"qs":"6.5.1","send":"0.16.1","serve-static":"1.13.1","path-to-regexp":"0.1.7"}},"4.16.3":{"dependencies":{"qs":"6.5.1","send":"0.16.2","serve-static":"1.13.2","path-to-regexp":"0.1.7"}},"4.16.4":{"dependencies":{"qs":"6.5.2","send":"0.16.2","serve-static":"1.13.2","path-to-regexp":"0.1.7"}},"4.17.0":{"dependencies":{"qs":"6.7.0","send":"0.17.1","serve-static":"1.14.1","path-to-regexp":"0.1.7"}},"4.17.1":{"dependencies":{"qs":"6.7.0","send":"0.17.1","serve-static":"1.14.1","path-to-regexp":"0.1.7"}},"4.17.2":{"dependencies":{"qs":"6.9.6","send":"0.17.2","serve-static":"1.14.2","path-to-regexp":"0.1.7"}},"4.17.3":{"dependencies":{"qs":"6.9.7","send":"0.17.2","serve-static":"1.14.2","path-to-regexp":"0.1.7"}},"4.18.0":{"dependencies":{"qs":"6.10.3","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.18.1":{"dependencies":{"qs":"6.10.3","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.18.2":{"dependencies":{"qs":"6.11.0","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.18.3":{"dependencies":{"qs":"6.11.0","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.19.0":{"dependencies":{"qs":"6.11.0","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.19.1":{"dependencies":{"qs":"6.11.0","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.19.2":{"dependencies":{"qs":"6.11.0","send":"0.18.0","serve-static":"1.15.0","path-to-regexp":"0.1.7"}},"4.20.0":{"dependencies":{"qs":"6.11.0","send":"0.19.0","serve-static":"1.16.0","path-to-regexp":"0.1.10"}},"5.0.0":{"dependencies":{"qs":"6.13.0","send":"^1.1.0","serve-static":"^2.1.0"}},"4.21.0":{"dependencies":{"qs":"6.13.0","send":"0.19.0","serve-static":"1.16.2","path-to-regexp":"0.1.10"}},"4.21.1":{"dependencies":{"qs":"6.13.0","send":"0.19.0","serve-static":"1.16.2","path-to-regexp":"0.1.10"}},"5.0.1":{"dependencies":{"qs":"6.13.0","send":"^1.1.0","serve-static":"^2.1.0"}},"4.21.2":{"dependencies":{"qs":"6.13.0","send":"0.19.0","serve-static":"1.16.2","path-to-regexp":"0.1.12"}},"5.1.0":{"dependencies":{"qs":"^6.14.0","send":"^1.1.0","serve-static":"^2.2.0"}},"4.22.0":{"dependencies":{"qs":"~6.14.0","send":"~0.19.0","serve-static":"~1.16.2","path-to-regexp":"~0.1.12"}},"5.2.0":{"dependencies":{"qs":"^6.14.0","send":"^1.1.0","serve-static":"^2.2.0"}},"5.2.1":{"dependencies":{"qs":"^6.14.0","send":"^1.1.0","serve-static":"^2.2.0"}},"4.22.1":{"dependencies":{"qs":"~6.14.0","send":"~0.19.0","serve-static":"~1.16.2","path-to-regexp":"~0.1.12"}},"4.22.2":{"dependencies":{"qs":"~6.15.1","send":"~0.19.0","serve-static":"~1.16.2","path-to-regexp":"~0.1.12"}},"4.22.3":{"dependencies":{"qs":"~6.16.0","send":"~0.19.0","serve-static":"~1.16.2","path-to-regexp":"~0.1.13"}},"5.3.0":{"dependencies":{"qs":"^6.16.0","send":"^1.1.0","serve-static":"^2.2.0"}}}},"path-to-regexp":{"dist-tags":{"latest":"8.4.2"},"versions":{"0.1.0":{},"0.1.1":{},"0.1.2":{},"0.1.3":{},"0.1.4":{},"0.1.5":{},"0.1.6":{},"0.1.7":{},"0.1.8":{},"0.1.9":{},"0.1.10":{},"8.0.0":{},"8.1.0":{},"0.1.11":{},"8.2.0":{},"0.1.12":{},"8.3.0":{},"0.1.13":{},"8.4.0":{},"8.4.1":{},"8.4.2":{}}},"qs":{"dist-tags":{"latest":"6.16.1"},"versions":{"6.0.0":{},"6.0.1":{},"6.0.2":{},"6.1.0":{},"6.2.0":{},"6.2.1":{},"6.3.0":{},"6.3.1":{},"6.1.1":{},"6.0.3":{},"6.2.2":{},"6.4.0":{},"6.3.2":{},"6.2.3":{},"6.1.2":{},"6.0.4":{},"6.5.0":{},"6.5.1":{},"6.5.2":{},"6.6.0":{},"6.7.0":{},"6.8.0":{},"6.9.0":{},"6.9.1":{},"6.9.2":{},"6.8.1":{},"6.7.1":{},"6.7.2":{},"6.8.2":{},"6.9.3":{},"6.9.4":{},"6.9.5":{},"6.9.6":{},"6.10.0":{},"6.10.1":{},"6.10.2":{},"6.2.4":{},"6.3.3":{},"6.4.1":{},"6.5.3":{},"6.6.1":{},"6.7.3":{},"6.8.3":{},"6.9.7":{},"6.10.3":{},"6.10.4":{},"6.10.5":{"deprecated":"when using stringify with arrayFormat comma, `[]` is appended on single-item arrays. Upgrade to v6.11.0 or downgrade to v6.10.4 to fix."},"6.11.0":{},"6.11.1":{},"6.11.2":{},"6.12.0":{},"6.12.1":{},"6.12.2":{},"6.12.3":{},"6.13.0":{},"6.13.1":{},"6.14.0":{},"6.14.1":{},"6.14.2":{},"6.13.2":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.13.3."},"6.12.4":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.12.5."},"6.11.3":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.11.4."},"6.10.6":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.10.7."},"6.9.8":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.9.9."},"6.8.4":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.8.5."},"6.7.4":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.7.5."},"6.6.2":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.6.3."},"6.5.4":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.5.5."},"6.4.2":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.4.3."},"6.3.4":{"deprecated":"This version has regressions from a robustness refactor: parse incorrectly handles keys exceeding the depth option, and stringify incorrectly percent-encodes low-byte characters. Upgrade to 6.3.5."},"6.2.5":{"deprecated":"This version has a regression from a robustness refactor: parse incorrectly handles keys exceeding the depth option. Upgrade to 6.2.6."},"6.1.3":{"deprecated":"This version has a regression from a robustness refactor: parse incorrectly handles keys exceeding the depth option. Upgrade to 6.1.4."},"6.0.5":{"deprecated":"This version has a regression from a robustness refactor: parse incorrectly handles keys exceeding the depth option. Upgrade to 6.0.6."},"6.0.6":{},"6.1.4":{},"6.2.6":{},"6.3.5":{},"6.4.3":{},"6.5.5":{},"6.6.3":{},"6.7.5":{},"6.8.5":{},"6.9.9":{},"6.10.7":{},"6.11.4":{},"6.12.5":{},"6.13.3":{},"6.15.0":{},"6.15.1":{},"6.15.2":{},"6.15.3":{},"6.16.0":{},"6.16.1":{}}},"serve-static":{"dist-tags":{"latest":"2.2.1"},"versions":{"1.13.0":{"dependencies":{"send":"0.16.0"}},"1.13.1":{"dependencies":{"send":"0.16.1"}},"1.13.2":{"dependencies":{"send":"0.16.2"}},"1.14.0":{"dependencies":{"send":"0.17.0"}},"1.14.1":{"dependencies":{"send":"0.17.1"}},"1.14.2":{"dependencies":{"send":"0.17.2"}},"1.15.0":{"dependencies":{"send":"0.18.0"}},"1.16.0":{"dependencies":{"send":"0.18.0"}},"1.16.1":{"dependencies":{"send":"0.19.0"}},"1.16.2":{"dependencies":{"send":"0.19.0"}},"1.16.3":{"dependencies":{"send":"~0.19.1"}}}},"send":{"dist-tags":{"latest":"1.2.1"},"versions":{"0.16.0":{},"0.16.1":{},"0.16.2":{},"0.17.0":{},"0.17.1":{},"0.17.2":{},"0.18.0":{},"0.19.0":{},"0.19.1":{},"0.19.2":{}}}},"expect":{"rows":[{"pkg":"express","installed":"4.16.4","own_target":"4.20.0","to":"4.22.3"}],"copies":[{"pkg":"path-to-regexp","installed":"0.1.7","target":"0.1.13","links":[{"parent":"express","release":{"to":"4.22.3","smallest":"4.22.0","floor":null,"brings":null},"levers":[],"lever_unknown":false}]},{"pkg":"qs","installed":"6.5.2","target":"6.16.0","links":[{"parent":"express","release":{"to":"4.22.3","smallest":null,"floor":null,"brings":null},"levers":[],"lever_unknown":false}]},{"pkg":"serve-static","installed":"1.13.2","target":"1.16.0","links":[{"parent":"express","release":{"to":"4.22.3","smallest":"4.20.0","floor":null,"brings":null},"levers":[],"lever_unknown":false}]},{"pkg":"send","installed":"0.16.2","target":"0.19.0","links":[{"parent":"express","release":{"to":"4.22.3","smallest":"4.20.0","floor":null,"brings":null},"levers":[],"lever_unknown":false},{"parent":"serve-static","release":{"to":"1.16.1","smallest":null,"floor":null,"brings":null},"levers":[{"name":"express","release":{"to":"4.22.3","smallest":"4.21.0","floor":"1.16.1","brings":"1.16.3"}}],"lever_unknown":false}]}]}} {"id":"pr-008","rule":"A whole old major deprecated with one notice is not withdrawn (FX-H5, the decided rule; review of #981 S3): glob deprecates every 10.x and 11.x release, so 11.1.0, GHSA-5j98-mcp5-4vw2's fix on the 11 line, stays the target of glob 11.0.3 (dropping every deprecated release would send it to 12.0.0). @nestjs/cli 11.0.10 pins glob 11.0.3; 11.0.11, which pins 12.0.0, is the smallest release that admits a clean glob","source":"@nestjs/cli 11.0.10 declared, glob 11.0.3 as its pinned dependency; registry.npmjs.org abbreviated documents (Accept: application/vnd.npm.install-v1+json), RAW: every release in the cut with its own deprecation notice, nothing pre-filtered; OSV.dev /v1/query; both read 2026-10-18","eco":"npm","mode":"graph","nodes":[["@nestjs/cli","11.0.10",true],["glob","11.0.3",false]],"edges":[["@nestjs/cli","11.0.10","glob","11.0.3"]],"advisories":[{"id":"GHSA-5j98-mcp5-4vw2","package":"glob","ranges":[{"introduced":"11.0.0","fixed":"11.1.0"},{"introduced":"10.2.0","fixed":"10.5.0"}]}],"registry":{"@nestjs/cli":{"dist-tags":{"latest":"12.0.8"},"versions":{"11.0.0":{"dependencies":{"glob":"11.0.1"}},"11.0.1":{"dependencies":{"glob":"11.0.1"}},"11.0.2":{"dependencies":{"glob":"11.0.1"}},"11.0.3":{"dependencies":{"glob":"11.0.1"}},"11.0.4":{"dependencies":{"glob":"11.0.1"}},"11.0.5":{"dependencies":{"glob":"11.0.1"}},"11.0.6":{"dependencies":{"glob":"11.0.1"}},"11.0.7":{"dependencies":{"glob":"11.0.1"}},"11.0.8":{"dependencies":{"glob":"11.0.3"}},"11.0.9":{"dependencies":{"glob":"11.0.3"}},"11.0.10":{"dependencies":{"glob":"11.0.3"}},"11.0.11":{"dependencies":{"glob":"12.0.0"}},"11.0.12":{"dependencies":{"glob":"12.0.0"}},"11.0.13":{"dependencies":{"glob":"13.0.0"}},"11.0.14":{"dependencies":{"glob":"13.0.0"}},"11.0.15":{"dependencies":{"glob":"13.0.0"}},"11.0.16":{"dependencies":{"glob":"13.0.0"}},"11.0.17":{"dependencies":{"glob":"13.0.6"}},"11.0.18":{"dependencies":{"glob":"13.0.6"}},"11.0.19":{"dependencies":{"glob":"13.0.6"}},"11.0.20":{"dependencies":{"glob":"13.0.6"}},"11.0.21":{"dependencies":{"glob":"13.0.6"}},"11.0.22":{"dependencies":{"glob":"13.0.6"}},"11.0.23":{"dependencies":{"glob":"13.0.6"}},"11.0.24":{"dependencies":{"glob":"13.0.6"}}}},"glob":{"dist-tags":{"latest":"13.0.6"},"versions":{"10.0.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.1.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.1":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.2":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.3":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.4":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.5":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.6":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.2.7":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.1":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.2":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.3":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.4":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.5":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.6":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.7":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.8":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.9":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.10":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.11":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.12":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.13":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.14":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.15":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.3.16":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.1":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.2":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.3":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.4":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"11.0.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"10.4.5":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"11.0.1":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"11.0.2":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"11.0.3":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"11.1.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"12.0.0":{},"10.5.0":{"deprecated":"Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me"},"13.0.0":{},"13.0.1":{},"13.0.2":{},"13.0.3":{},"13.0.4":{},"13.0.5":{},"13.0.6":{}}}},"expect":{"rows":[],"copies":[{"pkg":"glob","installed":"11.0.3","target":"11.1.0","links":[{"parent":"@nestjs/cli","release":{"to":"11.0.11","smallest":null,"floor":null,"brings":null},"levers":[],"lever_unknown":false}]}]}} +{"id":"pr-009","rule":"A row's only fix across a major raises the parent's own row, flagged breaking (the 4DA lead's decision 3, 2026-10-18): crossbeam-utils 0.5.0 is pinned by crossbeam-channel 0.2.4 (\"^0.5.0\"), and the smallest crossbeam-channel release whose requirement admits a clean crossbeam-utils is 0.5.0 (\"^0.8\"), a major upgrade from the parent's own target 0.4.4 (its own advisories' fix). The parent's row is raised to 0.5.0, breaking, so one release serves both rows (audit H1 rules a, b, f; final 6.2.5 audit M4: xsv crossbeam-channel 0.4.4 on its own row vs 0.5.0 as the lever)","source":"index.crates.io sparse records for crossbeam-channel and crossbeam-utils, and api.osv.dev /v1/query for both, read 2026-10-18 (raw answers kept in the MCP's scratch live-data/); BurntSushi/xsv Cargo.toml declares crossbeam-channel = \"0.2.4\" and its Cargo.lock holds crossbeam-utils 0.5.0 under crossbeam-channel 0.2.4. Expected values from @4da/mcp-server fix/final-audit-625 run on the same data","eco":"crates.io","tool":"cargo","parent":{"name":"crossbeam-channel","installed":"0.2.4","declared":"0.2.4","lever":"direct","latest":"0.5.17","deprecated":false,"releases":["0.2.4","0.2.5","0.2.6","0.3.0","0.3.1","0.3.2","0.3.3","0.3.4","0.3.6","0.3.7","0.3.8","0.3.9","0.4.0","0.4.2","0.4.4","0.5.0","0.5.8","0.5.9","0.5.10","0.5.11","0.5.15","0.5.16","0.5.17"],"advisories":[{"id":"GHSA-9g55-pg62-m8hh","ranges":[{"introduced":"0","fixed":"0.4.3"}]},{"id":"GHSA-m8h8-v6jh-c762","ranges":[{"introduced":"0.4.3","fixed":"0.4.4"}]},{"id":"GHSA-pg9f-39pc-qf8g","ranges":[{"introduced":"0.5.12","fixed":"0.5.15"}]},{"id":"GHSA-v5m7-53cv-f3hx","ranges":[{"introduced":"0.4.3","fixed":"0.4.4"}]},{"id":"RUSTSEC-2020-0052","ranges":[{"introduced":"0.4.3","fixed":"0.4.4"}]},{"id":"RUSTSEC-2022-0019","ranges":[{"introduced":"0.0.0-0","fixed":"0.4.3"}]},{"id":"RUSTSEC-2025-0024","ranges":[{"introduced":"0.5.12-0","fixed":"0.5.15"}]}],"own_target":"0.4.4"},"rows":[{"pkg":"crossbeam-utils","installed":"0.5.0","target":"0.8.7","releases":["0.1.0","0.2.0","0.2.1","0.2.2","0.3.0","0.3.1","0.3.2","0.4.0","0.4.1","0.5.0","0.6.0","0.6.1","0.6.2","0.6.3","0.6.4","0.6.5","0.6.6","0.7.0","0.7.2","0.8.7","0.8.8","0.8.9","0.8.10","0.8.11","0.8.12","0.8.14","0.8.15","0.8.16","0.8.17","0.8.18","0.8.19","0.8.20","0.8.21","0.8.22","0.8.23"],"advisories":[{"id":"GHSA-qc84-gqf4-9926","ranges":[{"introduced":"0","fixed":"0.8.7"}]},{"id":"RUSTSEC-2022-0041","ranges":[{"introduced":"0.0.0-0","fixed":"0.8.7"}]}],"requires":{"0.2.4":"^0.5.0","0.2.5":"^0.5.0","0.2.6":"^0.5.0","0.3.0":"^0.5.0","0.3.1":"^0.6","0.3.2":"^0.6","0.3.3":"^0.6","0.3.4":"^0.6","0.3.6":"^0.6.3","0.3.7":"^0.6","0.3.8":"^0.6.5","0.3.9":"^0.6.5","0.4.0":"^0.7","0.4.2":"^0.7","0.4.4":"^0.7","0.5.0":"^0.8","0.5.8":"^0.8","0.5.9":"^0.8.17","0.5.10":"^0.8.18","0.5.11":"^0.8.18","0.5.15":"^0.8.18","0.5.16":"^0.8.18","0.5.17":"^0.8.18"}}],"expect":{"parent_target":"0.5.0","rows":[{"pkg":"crossbeam-utils","outcome":"release","smallest":"0.5.0","to":"0.5.0","breaking":true,"requirement":"^0.8","peer":false,"resolves_to":"0.8.23","command":"cargo add crossbeam-channel@0.5.0","refresh":null}]}} +{"id":"pr-010","rule":"A move never introduces an advisory (parity decision 2, 2026-10-18): par 1.1.0 is the only par release whose requirement admits a clean leaf, but GHSA-synt-0000-0002 affects it and not the installed par 1.0.0, so no release is named (outcome introduces; text: every fixing release brings in GHSA-synt-0000-0002, which the installed version does not have)","source":"SYNTHETIC rule line (no clean live case exists; agreed with the MCP terminal 2026-10-18): package names, versions and advisory ids are invented to pin the rule, not read from a registry","eco":"npm","tool":"npm","parent":{"name":"par","installed":"1.0.0","declared":"1.0.0","lever":"direct","latest":"1.1.0","deprecated":false,"releases":["1.0.0","1.1.0"],"advisories":[{"id":"GHSA-synt-0000-0002","ranges":[{"introduced":"1.1.0"}]}],"own_target":null},"rows":[{"pkg":"leaf","installed":"1.0.0","target":"1.1.0","releases":["1.0.0","1.1.0"],"advisories":[{"id":"GHSA-synt-0000-0001","ranges":[{"introduced":"0","fixed":"1.1.0"}]}],"requires":{"1.0.0":"1.0.0","1.1.0":"^1.1.0"}}],"expect":{"parent_target":null,"rows":[{"pkg":"leaf","outcome":"introduces","smallest":null,"to":null,"breaking":false,"requirement":null,"peer":false,"resolves_to":null,"command":null,"refresh":null}]}} +{"id":"pr-011","rule":"A lever never introduces an advisory (parity decision 2 for levers, review of #994 S3): mid 1.1.0 admits a clean leaf, and lev 1.1.0 (the only lev release that brings mid 1.1.0 in) is blocked only by GHSA-synt-0000-0012, which the installed lev 1.0.0 does not have, so the lever names no release (release: introduces; text: every fixing release brings in GHSA-synt-0000-0012, which the installed version does not have)","source":"SYNTHETIC rule line (no live case across the 11 audit projects; agreed with the MCP terminal 2026-10-19): package names, versions and advisory ids are invented to pin the rule, not read from a registry","eco":"npm","mode":"graph","nodes":[["lev","1.0.0",true],["mid","1.0.0",false],["leaf","1.0.0",false]],"edges":[["lev","1.0.0","mid","1.0.0"],["mid","1.0.0","leaf","1.0.0"]],"advisories":[{"id":"GHSA-synt-0000-0011","package":"leaf","ranges":[{"introduced":"0","fixed":"1.1.0"}]},{"id":"GHSA-synt-0000-0012","package":"lev","ranges":[{"introduced":"1.1.0"}]}],"registry":{"lev":{"dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{"dependencies":{"mid":"1.0.0"}},"1.1.0":{"dependencies":{"mid":"1.1.0"}}}},"mid":{"dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{"dependencies":{"leaf":"1.0.0"}},"1.1.0":{"dependencies":{"leaf":"^1.1.0"}}}},"leaf":{"dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{},"1.1.0":{}}}},"expect":{"copies":[{"pkg":"leaf","installed":"1.0.0","target":"1.1.0","links":[{"parent":"mid","release":{"to":"1.1.0","smallest":null,"floor":null,"brings":null},"levers":[{"name":"lev","release":"introduces"}],"lever_unknown":false}]}]}} +{"id":"pr-012","rule":"A release blocked ONLY by advisories the installed version lacks introduces them whether they are fixed or not (review of #994 S2, the MCP's cleanButIntroduced): par 1.1.0 is the only par release whose requirement admits a clean leaf; GHSA-synt-0000-0003 affects [1.1.0, 1.2.0) and not the installed par 1.0.0 (it is fixed at 1.2.0, which pins the vulnerable leaf again), so no release is named (outcome introduces)","source":"SYNTHETIC rule line (no live case across the 11 audit projects; agreed with the MCP terminal 2026-10-19): package names, versions and advisory ids are invented to pin the rule, not read from a registry","eco":"npm","tool":"npm","parent":{"name":"par","installed":"1.0.0","declared":"1.0.0","lever":"direct","latest":"1.2.0","deprecated":false,"releases":["1.0.0","1.1.0","1.2.0"],"advisories":[{"id":"GHSA-synt-0000-0003","ranges":[{"introduced":"1.1.0","fixed":"1.2.0"}]}],"own_target":null},"rows":[{"pkg":"leaf","installed":"1.0.0","target":"1.1.0","releases":["1.0.0","1.1.0"],"advisories":[{"id":"GHSA-synt-0000-0001","ranges":[{"introduced":"0","fixed":"1.1.0"}]}],"requires":{"1.0.0":"1.0.0","1.1.0":"^1.1.0","1.2.0":"1.0.0"}}],"expect":{"parent_target":null,"rows":[{"pkg":"leaf","outcome":"introduces","smallest":null,"to":null,"breaking":false,"requirement":null,"peer":false,"resolves_to":null,"command":null,"refresh":null}]}}