diff --git a/src/core/actions/mod.rs b/src/core/actions/mod.rs index 00c0b6f..1d83b22 100644 --- a/src/core/actions/mod.rs +++ b/src/core/actions/mod.rs @@ -1,3 +1,4 @@ pub mod analyze_project_dependencies; pub mod process_bump; pub mod update_transitive_dependencies; +pub mod update_vulnerable_dependencies; diff --git a/src/core/actions/update_vulnerable_dependencies.rs b/src/core/actions/update_vulnerable_dependencies.rs new file mode 100644 index 0000000..eac94df --- /dev/null +++ b/src/core/actions/update_vulnerable_dependencies.rs @@ -0,0 +1,96 @@ +use crate::core::application::Application; +use anyhow::Result; +use tempfile::TempDir; + +pub async fn run(app: &Application, project_id: String) -> Result<()> { + let project = app.query().project(&project_id).await?; + let patchers = app.patchers(); + + let view = app.project_repository_view(&project.id).await?; + let default_branch = view.get_default_branch().await?; + let base_revision = view.get_revision(&default_branch).await?; + let mutator = app.project_repository_mutator(&project.id).await?; + let snapshot = view.snapshot(&base_revision); + + tokio::spawn(async move { + let mut all_modifications = Vec::new(); + let mut combined_pr_body = String::from("This pull request automatically resolves known security vulnerabilities by bumping affected dependencies.\n\n"); + let mut has_changes = false; + + for (ecosystem_name, patcher) in patchers { + let temp_dir_result = TempDir::new(); + if let Err(e) = temp_dir_result { + tracing::error!( + "Failed to create temporary directory for {}: {}", + ecosystem_name, + e + ); + continue; + } + let temp_dir = temp_dir_result.unwrap(); + + match patcher + .update_vulnerable_dependencies(snapshot.as_ref(), temp_dir.path()) + .await + { + Ok(Some(result)) => { + if !result.modifications.is_empty() { + all_modifications.extend(result.modifications); + if has_changes { + combined_pr_body.push_str("\n\n---\n\n"); + } + combined_pr_body.push_str(&result.summary); + has_changes = true; + } + } + Ok(None) => {} + Err(e) => { + tracing::error!( + "Failed to update vulnerable dependencies for {}: {}", + ecosystem_name, + e + ); + } + } + } + + let branch_name = "syz/audit"; + + if !has_changes { + tracing::info!("No fixable security vulnerabilities found."); + let _ = mutator + .close_pull_request(branch_name, &default_branch) + .await; + return; + } + + let title = "Update dependencies to fix security vulnerabilities"; + + let commit_res = mutator + .commit_changes(&base_revision, branch_name, title, all_modifications) + .await; + + match commit_res { + Ok(Some(_sha)) => { + match mutator + .create_pull_request(title, branch_name, &default_branch, &combined_pr_body) + .await + { + Ok(url) => tracing::info!("Created vulnerable update PR: {}", url), + Err(e) => tracing::error!("Failed to create PR: {}", e), + } + } + Ok(None) => { + tracing::info!("Modifications resulted in no changes relative to base branch. Cleaning up empty PRs."); + let _ = mutator + .close_pull_request(branch_name, &default_branch) + .await; + } + Err(e) => { + tracing::error!("Failed to commit changes: {}", e); + } + } + }); + + Ok(()) +} diff --git a/src/core/application.rs b/src/core/application.rs index fc8dea5..202ba0c 100644 --- a/src/core/application.rs +++ b/src/core/application.rs @@ -446,7 +446,9 @@ impl Application { vec![ ( "npm", - Box::new(crate::core::engine::ecosystems::npm::NpmPatcher), + Box::new(crate::core::engine::ecosystems::npm::NpmPatcher::new( + crate::core::clients::npm::Npm::new(self.http_agent.clone()), + )), ), // cargo doesn't have a patcher yet in application.rs, but we can add it later if needed ] @@ -457,7 +459,11 @@ impl Application { ecosystem: &str, ) -> Option> { match ecosystem { - "npm" => Some(Box::new(crate::core::engine::ecosystems::npm::NpmPatcher)), + "npm" => Some(Box::new( + crate::core::engine::ecosystems::npm::NpmPatcher::new( + crate::core::clients::npm::Npm::new(self.http_agent.clone()), + ), + )), _ => None, } } diff --git a/src/core/clients/npm.rs b/src/core/clients/npm.rs index 5c6f385..2ece591 100644 --- a/src/core/clients/npm.rs +++ b/src/core/clients/npm.rs @@ -30,6 +30,58 @@ impl Npm { Self { agent } } + pub async fn resolve_mature_version( + &self, + package: &str, + vulnerable_constraints: &[String], + minimum_release_age: Option, + ) -> Result { + let response = self.get_registry_response(package).await?; + + // The npm registry sometimes uses spaces in constraints (e.g. ">=4.17.21 <5.0.0") + // but the Rust `semver` crate expects commas. + let mut parsed_reqs = Vec::new(); + for constraint in vulnerable_constraints { + let cleaned_constraint = constraint.replace(" ", ", "); + let req = semver::VersionReq::parse(&cleaned_constraint).map_err(|e| { + anyhow::anyhow!("Failed to parse constraint '{}': {}", cleaned_constraint, e) + })?; + parsed_reqs.push(req); + } + + let mut available_releases = Vec::new(); + + for (version_str, time_str) in &response.time { + // Skip metadata keys in the time object + if version_str == "modified" || version_str == "created" || version_str == "unpublished" + { + continue; + } + + let parsed_time = match DateTime::parse_from_rfc3339(time_str) { + Ok(t) => t.with_timezone(&Utc), + Err(_) => continue, // Skip if time is unparseable + }; + + if let Ok(ver) = Version::parse(version_str) { + if ver.pre.is_empty() { + available_releases.push(crate::core::version_resolver::AvailableRelease { + version: ver, + published_at: parsed_time, + }); + } + } + } + + let resolution = crate::core::version_resolver::resolve_mature_versions( + &parsed_reqs, + &available_releases, + minimum_release_age, + ); + + Ok(resolution) + } + async fn get_registry_response(&self, package: &str) -> Result { let url = format!("https://registry.npmjs.org/{}", package); let response: RegistryResponse = self.agent.json(&url).await?; diff --git a/src/core/engine/ecosystems/mod.rs b/src/core/engine/ecosystems/mod.rs index 305a77e..21c4537 100644 --- a/src/core/engine/ecosystems/mod.rs +++ b/src/core/engine/ecosystems/mod.rs @@ -78,4 +78,13 @@ pub trait Patcher: Send + Sync { ) -> Result> { Ok(None) } + + /// Automatically resolves known security vulnerabilities by bumping affected dependencies. + async fn update_vulnerable_dependencies( + &self, + _snapshot: &dyn ProjectRepositorySnapshot, + _temp_dir: &std::path::Path, + ) -> Result> { + Ok(None) + } } diff --git a/src/core/engine/ecosystems/npm/mod.rs b/src/core/engine/ecosystems/npm/mod.rs index 6151dcd..057083e 100644 --- a/src/core/engine/ecosystems/npm/mod.rs +++ b/src/core/engine/ecosystems/npm/mod.rs @@ -59,7 +59,15 @@ impl Registry for NpmRegistry { } } -pub struct NpmPatcher; +pub struct NpmPatcher { + npm_client: clients::npm::Npm, +} + +impl NpmPatcher { + pub fn new(npm_client: clients::npm::Npm) -> Self { + Self { npm_client } + } +} #[async_trait] impl crate::core::engine::ecosystems::Patcher for NpmPatcher { @@ -510,6 +518,380 @@ impl crate::core::engine::ecosystems::Patcher for NpmPatcher { summary: pr_body, })) } + async fn update_vulnerable_dependencies( + &self, + snapshot: &dyn ProjectRepositorySnapshot, + temp_dir: &std::path::Path, + ) -> Result> { + let workspace = snapshot.read_file("pnpm-workspace.yaml").await.ok(); + let lockfile = snapshot.read_file("pnpm-lock.yaml").await.ok(); + + let mut minimum_release_age = None; + if let Some(ref w_content) = workspace { + // Very simple extraction, normally we'd parse the WorkspaceConfig properly + if let Ok(val) = serde_yml::from_str::(w_content) { + if let Some(age) = val.get("minimum_release_age").and_then(|v| v.as_i64()) { + minimum_release_age = Some(chrono::Duration::minutes(age)); + } + } + } + + tracing::info!("Fetching repository tree for NPM audit..."); + let files = snapshot.list_files().await?; + + let mut pkg_json_paths_vec = Vec::new(); + for path in &files { + if path.ends_with("package.json") { + pkg_json_paths_vec.push(path.to_string()); + } + } + + if pkg_json_paths_vec.is_empty() { + return Ok(None); + } + + let mut original_pkg_jsons = std::collections::HashMap::new(); + let mut mutable_pkg_jsons = std::collections::HashMap::new(); + + for path in &pkg_json_paths_vec { + if let Ok(content) = snapshot.read_file(path).await { + original_pkg_jsons.insert(path.clone(), content.clone()); + if let Ok(val) = serde_json::from_str::(&content) { + mutable_pkg_jsons.insert(path.clone(), val); + } + let full_path = temp_dir.join(path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&full_path, content)?; + } + } + + if let Some(ref w) = workspace { + fs::write(temp_dir.join("pnpm-workspace.yaml"), w)?; + } + if let Some(ref l) = lockfile { + fs::write(temp_dir.join("pnpm-lock.yaml"), l)?; + } + + tracing::info!("Running pnpm install --lockfile-only to set baseline..."); + let mut cmd = Command::new("pnpm"); + cmd.arg("install") + .arg("--lockfile-only") + .arg("--ignore-scripts"); + + if workspace.is_some() { + cmd.arg("--recursive"); + } + + if !cmd.current_dir(temp_dir).status()?.success() { + tracing::warn!("pnpm install failed, continuing anyway..."); + } + + tracing::info!("Running pnpm audit --json for baseline..."); + let baseline_audit_output = Command::new("pnpm") + .arg("audit") + .arg("--json") + .current_dir(temp_dir) + .output()?; + + let baseline_json: serde_json::Value = + serde_json::from_slice(&baseline_audit_output.stdout).unwrap_or(serde_json::json!({})); + + let mut baseline_advisories = std::collections::HashMap::new(); + if let Some(advs) = baseline_json.get("advisories").and_then(|a| a.as_object()) { + for (id, adv) in advs { + baseline_advisories.insert(id.clone(), adv.clone()); + } + } + + if baseline_advisories.is_empty() { + tracing::info!("No vulnerabilities found in baseline."); + return Ok(None); + } + + tracing::info!( + "Found {} vulnerabilities. Discovering mature fixes...", + baseline_advisories.len() + ); + + let mut overrides = std::collections::HashMap::new(); + let mut module_to_vulnerable_versions = std::collections::HashMap::new(); + let mut blocked_by_age: std::collections::HashMap< + String, + Vec<(semver::Version, chrono::DateTime)>, + > = std::collections::HashMap::new(); + + for (_id, adv) in &baseline_advisories { + if let (Some(module), Some(vulnerable)) = ( + adv.get("module_name").and_then(|m| m.as_str()), + adv.get("vulnerable_versions").and_then(|p| p.as_str()), + ) { + module_to_vulnerable_versions + .entry(module.to_string()) + .or_insert_with(Vec::new) + .push(vulnerable.to_string()); + } + } + + for (module, vulnerable_list) in module_to_vulnerable_versions { + match self.npm_client + .resolve_mature_version(&module, &vulnerable_list, minimum_release_age.clone()) + .await + { + Ok(resolution) => { + let resolved_empty = resolution.resolved.is_empty(); + let blocked_empty = resolution.blocked.is_empty(); + + if !resolved_empty { + tracing::info!( + "Found mature fixes for {}: {:?}", + module, + resolution.resolved + ); + overrides.insert(module.clone(), resolution.resolved); + } + if !blocked_empty { + tracing::info!( + "Fixes for {} are blocked by age policy: {:?}", + module, + resolution + .blocked + .iter() + .map(|(v, _)| v.to_string()) + .collect::>() + ); + blocked_by_age.insert(module.clone(), resolution.blocked); + } + + if resolved_empty && blocked_empty { + tracing::warn!( + "No mature fix found for {} satisfying vulnerabilities {:?}", + module, + vulnerable_list + ); + } + } + Err(e) => { + tracing::error!("Error resolving mature fix for {}: {}", module, e); + } + } + } + + if overrides.is_empty() { + tracing::info!("No mature fixes could be found for any vulnerabilities."); + return Ok(None); + } + + tracing::info!("Applying fixes to package.json files..."); + + let mut all_modifications = Vec::new(); + + for (path, pkg_json) in mutable_pkg_jsons.iter_mut() { + let mut file_updated = false; + for (module, mature_versions) in &overrides { + for key in ["dependencies", "devDependencies"] { + if let Some(deps) = pkg_json.get_mut(key).and_then(|d| d.as_object_mut()) { + if let Some(req_val) = deps.get(module) { + if let Some(req) = req_val.as_str() { + let clean_req = req.trim_start_matches(&['^', '~', '=', 'v'][..]); + let current_ver = match semver::Version::parse(clean_req) { + Ok(v) => v, + Err(_) => continue, // Cannot parse, skip + }; + + let mut target_mature_version = None; + for mv in mature_versions { + if current_ver.major == 0 { + if mv.major == 0 && mv.minor == current_ver.minor { + target_mature_version = Some(mv); + } + } else if mv.major == current_ver.major { + target_mature_version = Some(mv); + } + } + + if let Some(mature_version) = target_mature_version { + let prefix = if req.starts_with('^') { + "^" + } else if req.starts_with('~') { + "~" + } else { + "" + }; + let new_req = format!("{}{}", prefix, mature_version); + deps.insert(module.to_string(), serde_json::Value::String(new_req)); + file_updated = true; + } else { + tracing::warn!("Skipping direct dependency bump for {} in {} because no mature fix exists in major version {}", module, path, current_ver.major); + } + } + } + } + } + } + + if file_updated { + let updated_pkg_json_str = serde_json::to_string_pretty(pkg_json)? + "\n"; + let full_path = temp_dir.join(path); + fs::write(&full_path, &updated_pkg_json_str)?; + all_modifications.push(FileModification { + path: path.clone(), + state: crate::core::engine::repository::FileState::Write(updated_pkg_json_str), + }); + } + } + + let before_versions = extract_versions_from_lock(&lockfile.clone().unwrap_or_default()); + + if let Some(root_pkg) = mutable_pkg_jsons.get_mut("package.json") { + let mut file_updated = false; + if let Some(obj) = root_pkg.as_object_mut() { + let mut dev_deps = obj + .remove("devDependencies") + .unwrap_or_else(|| serde_json::json!({})); + if let Some(dev_deps_map) = dev_deps.as_object_mut() { + for (module, mature_versions) in &overrides { + let active_majors = if let Some(vers) = before_versions.get(module) { + let mut majors = std::collections::HashSet::new(); + for v in vers { + if let Ok(ver) = semver::Version::parse(v) { + if ver.major == 0 { + majors.insert((0, ver.minor)); + } else { + majors.insert((ver.major, 0)); + } + } + } + majors + } else { + std::collections::HashSet::new() + }; + + for mature_version in mature_versions { + let is_used = if mature_version.major == 0 { + active_majors.contains(&(0, mature_version.minor)) + } else { + active_majors.contains(&(mature_version.major, 0)) + }; + + if is_used { + let alias_name = format!( + "syz-force-{}-{}", + module.replace('@', "").replace('/', "-"), + mature_version + ); + dev_deps_map.insert( + alias_name, + serde_json::Value::String(format!("npm:{}@^{}", module, mature_version)), + ); + file_updated = true; + } + } + } + } + obj.insert("devDependencies".to_string(), dev_deps); + + if file_updated { + let updated_root_str = serde_json::to_string_pretty(&root_pkg)? + "\n"; + let root_full_path = temp_dir.join("package.json"); + fs::write(&root_full_path, &updated_root_str)?; + + // Update or insert modification for package.json + if let Some(m) = all_modifications.iter_mut().find(|m| m.path == "package.json") { + m.state = crate::core::engine::repository::FileState::Write(updated_root_str); + } else { + all_modifications.push(FileModification { + path: "package.json".to_string(), + state: crate::core::engine::repository::FileState::Write(updated_root_str), + }); + } + } + } + } + + tracing::info!("Running pnpm install in temp directory to update lockfile with forced fixes..."); + let mut install_cmd = Command::new("pnpm"); + install_cmd + .arg("install") + .arg("--lockfile-only") + .arg("--ignore-scripts"); + + if workspace.is_some() { + install_cmd.arg("--recursive"); + } + + if !install_cmd.current_dir(temp_dir).status()?.success() { + tracing::warn!("pnpm install failed, continuing anyway..."); + } + + tracing::info!("Running pnpm dedupe..."); + let mut dedupe_cmd = Command::new("pnpm"); + dedupe_cmd.arg("dedupe").arg("--ignore-scripts"); + let dedupe_status = dedupe_cmd.current_dir(temp_dir).status()?; + if !dedupe_status.success() { + tracing::warn!("pnpm dedupe failed, continuing anyway..."); + } + + let updated_lock = fs::read_to_string(temp_dir.join("pnpm-lock.yaml")).ok(); + + let mut pr_body = String::new(); + + if !blocked_by_age.is_empty() { + pr_body.push_str("> [!WARNING]\n> The following vulnerable packages have fixes available, but they have not met the `minimumReleaseAge` requirement yet and were skipped:\n>\n"); + + let mut sorted_blocked: Vec = blocked_by_age.keys().cloned().collect(); + sorted_blocked.sort(); + + let now = chrono::Utc::now(); + let min_age = minimum_release_age + .clone() + .unwrap_or(chrono::Duration::zero()); + + for module in sorted_blocked { + let blocked_versions = blocked_by_age.get(&module).unwrap(); + for (_ver, publish_time) in blocked_versions { + let available_time = *publish_time + min_age; + let remaining = available_time.signed_duration_since(now).num_seconds(); + let days = (remaining as f64 / 86400.0).ceil() as i64; + pr_body.push_str(&format!( + "> - `{}`: mature in {} days\n", + module, + days + )); + } + } + pr_body.push_str("\n---\n\n"); + } + + pr_body.push_str("### Fixed Vulnerabilities\n\n"); + let mut sorted_overrides: Vec = overrides.keys().cloned().collect(); + sorted_overrides.sort(); + for module in sorted_overrides { + let vers = overrides.get(&module).unwrap(); + let vers_str = vers.iter().map(|v| v.to_string()).collect::>().join(", "); + pr_body.push_str(&format!("- `{}`: bumped to `{}`\n", module, vers_str)); + } + + if let Some(lock) = updated_lock { + let old_lock = lockfile.unwrap_or_default(); + if lock != old_lock { + all_modifications.push(FileModification { + path: "pnpm-lock.yaml".to_string(), + state: crate::core::engine::repository::FileState::Write(lock), + }); + } + } + + if all_modifications.is_empty() { + Ok(None) + } else { + Ok(Some(crate::core::engine::TransitiveUpdateResult { + modifications: all_modifications, + summary: pr_body, + })) + } + } } fn run_pnpm_install(dir_path: &std::path::Path, is_workspace: bool, silent: bool) -> Result<()> { diff --git a/src/core/message.rs b/src/core/message.rs index 81963ef..9b5eb53 100644 --- a/src/core/message.rs +++ b/src/core/message.rs @@ -50,6 +50,10 @@ pub enum Payload { project_id: String, }, + UpdateVulnerableDependencies { + project_id: String, + }, + #[serde(skip)] PersistBumpResult { bump_id: String, @@ -171,6 +175,10 @@ impl Payload { super::actions::update_transitive_dependencies::run(app, project_id.clone()).await } + Payload::UpdateVulnerableDependencies { project_id } => { + super::actions::update_vulnerable_dependencies::run(app, project_id.clone()).await + } + Payload::PersistBumpResult { bump_id, pull_request_url,