From e76ea06cd87a2f78fe73001c82eb5b404931a434 Mon Sep 17 00:00:00 2001 From: Tomas Carnecky Date: Tue, 14 Jul 2026 13:11:09 +0200 Subject: [PATCH] List resolved vulnerabilities in transitive dependencies update --- .../actions/update_transitive_dependencies.rs | 90 ++++++++++++++++++- src/core/engine/ecosystems/npm/mod.rs | 1 + src/core/engine/mod.rs | 4 + src/core/engine/pull_request_generator/mod.rs | 55 ++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/core/actions/update_transitive_dependencies.rs b/src/core/actions/update_transitive_dependencies.rs index 814ad55..694c640 100644 --- a/src/core/actions/update_transitive_dependencies.rs +++ b/src/core/actions/update_transitive_dependencies.rs @@ -15,6 +15,7 @@ pub async fn run(app: &Application, project_id: String) -> Result<()> { let snapshot = view.snapshot(&base_revision); let pr_generator = app.transitive_pull_request_generator(); + let advisory_resolver = app.advisory_resolver(); let span = tracing::Span::current(); @@ -34,10 +35,18 @@ pub async fn run(app: &Application, project_id: String) -> Result<()> { .update_transitive_dependencies(snapshot.as_ref(), temp_dir.path()) .await { - Ok(Some(result)) => { + Ok(Some(mut result)) => { if !result.modifications.is_empty() { all_modifications.extend(result.modifications); + // Resolve advisories for bumped packages + resolve_advisories_for_bumps( + advisory_resolver.as_ref(), + ecosystem_name, + &mut result.summary, + ) + .await; + let body = match pr_generator .generate_pull_request_body(&result.summary) .await @@ -105,3 +114,82 @@ pub async fn run(app: &Application, project_id: String) -> Result<()> { Ok(()) } + +/// Parse a version bump description like `` `1.2.3` -> `2.0.0` `` or +/// `` `1.2.3, 1.2.4` -> `2.0.0` `` and return (before_vers, after_vers). +fn parse_bump_description(desc: &str) -> (Vec, Vec) { + let stripped = desc.trim_matches('`'); + let mut parts = stripped.splitn(2, " -> "); + let before_str = parts.next().unwrap_or("").trim().trim_matches('`'); + let after_str = parts.next().unwrap_or("").trim().trim_matches('`'); + + let before_vers: Vec = before_str + .split(", ") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let after_vers: Vec = after_str + .split(", ") + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + (before_vers, after_vers) +} + +/// Resolve security advisories for each bumped module in the summary and +/// populate `resolved_advisories`. +async fn resolve_advisories_for_bumps( + resolver: &dyn crate::core::engine::advisories::AdvisoryResolver, + ecosystem: &str, + summary: &mut crate::core::engine::TransitiveUpdateSummary, +) { + for (module, desc) in summary.major_bumps.iter().chain(summary.minor_bumps.iter()) { + let (before_vers, after_vers) = parse_bump_description(desc); + let current_version = before_vers.first().cloned().unwrap_or_default(); + let target_version = after_vers.first().cloned().unwrap_or_default(); + + if current_version.is_empty() || target_version.is_empty() { + continue; + } + + match resolver + .resolve_advisories(ecosystem, module, ¤t_version, &target_version) + .await + { + Ok(advisories) if !advisories.is_empty() => { + let advisory_values: Vec = advisories + .into_iter() + .map(|a| { + serde_json::json!({ + "id": a.id, + "title": a.title, + "url": a.url, + "severity": a.severity, + "github_advisory_id": a.id, + }) + }) + .collect(); + + summary.resolved_advisories.insert( + module.clone(), + crate::core::engine::advisories::ResolvedAdvisoryBump { + before_versions: before_vers, + after_versions: after_vers, + advisories: advisory_values, + }, + ); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + "Failed to resolve advisories for `{}` ({}): {}", + module, + desc, + e + ); + } + } + } +} diff --git a/src/core/engine/ecosystems/npm/mod.rs b/src/core/engine/ecosystems/npm/mod.rs index 226c0c3..5cc6b08 100644 --- a/src/core/engine/ecosystems/npm/mod.rs +++ b/src/core/engine/ecosystems/npm/mod.rs @@ -459,6 +459,7 @@ impl crate::core::engine::ecosystems::Patcher for NpmPatcher { removed, major_bumps, minor_bumps, + resolved_advisories: std::collections::HashMap::new(), }; Ok(Some(crate::core::engine::TransitiveUpdateResult { diff --git a/src/core/engine/mod.rs b/src/core/engine/mod.rs index cb0fc2d..9a7b229 100644 --- a/src/core/engine/mod.rs +++ b/src/core/engine/mod.rs @@ -90,6 +90,10 @@ pub struct TransitiveUpdateSummary { pub removed: Vec<(String, String)>, pub major_bumps: Vec<(String, String)>, pub minor_bumps: Vec<(String, String)>, + /// Security vulnerabilities resolved by this update. + /// Populated by the action layer after patcher execution. + pub resolved_advisories: + std::collections::HashMap, } pub struct TransitiveUpdateResult { diff --git a/src/core/engine/pull_request_generator/mod.rs b/src/core/engine/pull_request_generator/mod.rs index 03bc013..0b5e2e6 100644 --- a/src/core/engine/pull_request_generator/mod.rs +++ b/src/core/engine/pull_request_generator/mod.rs @@ -56,6 +56,61 @@ impl TransitiveUpdatesPullRequestGenerator for DefaultTransitiveUpdatesPullReque ) -> Result { let mut pr_body = "This pull request automatically bumps all transitive dependencies to their latest versions.\n\n".to_string(); + if !summary.resolved_advisories.is_empty() { + pr_body.push_str("### Resolved Vulnerabilities\n\n"); + + let mut sorted_modules: Vec = + summary.resolved_advisories.keys().cloned().collect(); + sorted_modules.sort(); + + for module_name in sorted_modules { + let bump = summary.resolved_advisories.get(&module_name).unwrap(); + pr_body.push_str(&format!("#### `{}`\n", module_name)); + + let before_str = bump.before_versions.join(", "); + let after_str = bump.after_versions.join(", "); + pr_body.push_str(&format!("`{}` -> `{}`\n\n", before_str, after_str)); + + pr_body.push_str("Resolved advisories:\n"); + let mut seen_advisories = std::collections::HashSet::new(); + for adv in &bump.advisories { + let title = adv + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("Unknown Advisory"); + + let line = if let Some(gh_id) = + adv.get("github_advisory_id").and_then(|i| i.as_str()) + { + let url = adv + .get("url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://github.com/advisories/{}", gh_id)); + format!("- [{}]({}) - {}\n", gh_id, url, title) + } else if let Some(id) = adv.get("id").and_then(|i| { + if i.is_number() { + i.as_i64().map(|n| n.to_string()) + } else { + i.as_str().map(|s| s.to_string()) + } + }) { + let url = adv.get("url").and_then(|u| u.as_str()).unwrap_or(""); + format!("- [{}]({}) - {}\n", id, url, title) + } else { + format!("- {}\n", title) + }; + + if seen_advisories.insert(line.clone()) { + pr_body.push_str(&line); + } + } + pr_body.push('\n'); + } + + pr_body.push_str("---\n\n"); + } + if !summary.major_bumps.is_empty() { pr_body.push_str("### Major Version Bumps\n"); for (module, desc) in &summary.major_bumps { -- 2.51.2