1use anyhow::{Context, Result};
2use log::{debug, info, warn};
3use petgraph::{
4 dot::{Config, Dot},
5 graph::{DiGraph, NodeIndex},
6 visit::EdgeRef,
7};
8use petgraph::visit::Dfs;
9use petgraph::Direction;
10use pubgrub::{
11 error::PubGrubError,
12 range::Range,
13 solver::{Dependencies, DependencyProvider},
14 version::{SemanticVersion as PubgrubVersion, Version as PubgrubVersionTrait},
15};
16use regex::Regex;
17use serde::{Deserialize, Serialize};
18use std::collections::{HashMap, HashSet};
19use std::fs::File;
20use std::io::Write;
21use std::path::Path;
22use std::sync::Mutex;
23use walkdir::WalkDir;
24use semver;
25use reqwest;
26use serde_json;
27use lazy_static::lazy_static;
28
29use crate::models::Package;
30
31lazy_static! {
33 static ref SAFETY_DB_CACHE: Mutex<Option<serde_json::Value>> = Mutex::new(None);
34}
35
36#[derive(Debug)]
38pub struct AdvancedDependencyGraph {
39 pub graph: DiGraph<String, String>,
41 pub node_map: HashMap<String, NodeIndex>,
43 pub direct_deps: HashSet<String>,
45 pub conflicts: Vec<(String, String, String)>,
47}
48
49pub fn create_advanced_dependency_graph(
51 packages: &[Package],
52 dependency_map: &HashMap<String, Vec<String>>,
53) -> AdvancedDependencyGraph {
54 info!("Creating advanced dependency graph");
55 let mut graph = DiGraph::<String, String>::new();
56 let mut node_map = HashMap::new();
57 let mut direct_deps = HashSet::new();
58
59 for package in packages {
61 let node_idx = graph.add_node(package.name.clone());
62 node_map.insert(package.name.clone(), node_idx);
63 direct_deps.insert(package.name.clone());
64 }
65
66 for (pkg_name, deps) in dependency_map {
68 if let Some(&from_idx) = node_map.get(pkg_name) {
69 for dep in deps {
70 if let Some(&to_idx) = node_map.get(dep) {
71 graph.add_edge(from_idx, to_idx, "depends on".to_string());
73 }
74 }
75 }
76 }
77
78 let transitive_deps = find_transitive_dependencies(packages, dependency_map);
80
81 for (pkg_name, deps) in &transitive_deps {
83 if let Some(&from_idx) = node_map.get(pkg_name) {
84 for dep in deps {
85 if let Some(&to_idx) = node_map.get(dep) {
86 if !direct_edge_exists(&graph, from_idx, to_idx) {
88 graph.add_edge(from_idx, to_idx, "transitive".to_string());
89 }
90 }
91 }
92 }
93 }
94
95 let conflicts = detect_conflicts(packages, dependency_map);
97
98 AdvancedDependencyGraph {
99 graph,
100 node_map,
101 direct_deps,
102 conflicts,
103 }
104}
105
106fn direct_edge_exists(graph: &DiGraph<String, String>, from: NodeIndex, to: NodeIndex) -> bool {
108 graph.edges_connecting(from, to).next().is_some()
109}
110
111fn find_transitive_dependencies(
113 packages: &[Package],
114 dependency_map: &HashMap<String, Vec<String>>,
115) -> HashMap<String, HashSet<String>> {
116 let mut transitive_deps: HashMap<String, HashSet<String>> = HashMap::new();
117
118 let mut graph = DiGraph::<String, ()>::new();
120 let mut node_map = HashMap::new();
121
122 for package in packages {
124 let node_idx = graph.add_node(package.name.clone());
125 node_map.insert(package.name.clone(), node_idx);
126 }
127
128 for (pkg_name, deps) in dependency_map {
130 if let Some(&from_idx) = node_map.get(pkg_name) {
131 for dep in deps {
132 if let Some(&to_idx) = node_map.get(dep) {
133 graph.add_edge(from_idx, to_idx, ());
134 }
135 }
136 }
137 }
138
139 for package in packages {
141 let mut visited = HashSet::new();
142 let mut deps = HashSet::new();
143
144 if let Some(&node_idx) = node_map.get(&package.name) {
145 dfs_collect_deps(&graph, node_idx, &mut visited, &mut deps, &node_map);
146 }
147
148 deps.remove(&package.name);
150
151 if let Some(direct_deps) = dependency_map.get(&package.name) {
153 for dep in direct_deps {
154 deps.remove(dep);
155 }
156 }
157
158 transitive_deps.insert(package.name.clone(), deps);
159 }
160
161 transitive_deps
162}
163
164fn dfs_collect_deps(
166 graph: &DiGraph<String, ()>,
167 node: NodeIndex,
168 visited: &mut HashSet<NodeIndex>,
169 deps: &mut HashSet<String>,
170 node_map: &HashMap<String, NodeIndex>,
171) {
172 if visited.contains(&node) {
173 return;
174 }
175
176 visited.insert(node);
177 let pkg_name = &graph[node];
178 deps.insert(pkg_name.clone());
179
180 for edge in graph.edges(node) {
182 let neighbor = edge.target();
183 dfs_collect_deps(graph, neighbor, visited, deps, node_map);
184 }
185}
186
187fn detect_conflicts(
189 packages: &[Package],
190 dependency_map: &HashMap<String, Vec<String>>,
191) -> Vec<(String, String, String)> {
192 let mut conflicts = Vec::new();
193
194 let version_map: HashMap<_, _> = packages
196 .iter()
197 .filter_map(|p| {
198 p.version.as_ref().map(|v| (p.name.clone(), v.clone()))
199 })
200 .collect();
201
202 let _mock_provider = MockDependencyProvider {
204 packages: version_map.clone(),
205 dependencies: dependency_map.clone(),
206 };
207
208 let mut shared_deps = HashMap::new();
210
211 for (pkg, deps) in dependency_map {
212 for dep in deps {
213 shared_deps
214 .entry(dep.clone())
215 .or_insert_with(Vec::new)
216 .push(pkg.clone());
217 }
218 }
219
220 for (dep, dependents) in shared_deps {
222 if dependents.len() < 2 {
223 continue;
224 }
225
226 for i in 0..dependents.len() {
227 for j in i+1..dependents.len() {
228 let pkg1 = &dependents[i];
229 let pkg2 = &dependents[j];
230
231 if let (Some(ver1), Some(ver2)) = (
232 find_version_requirement(dependency_map, pkg1, &dep),
233 find_version_requirement(dependency_map, pkg2, &dep)
234 ) {
235 if !versions_compatible(&ver1, &ver2) {
236 conflicts.push((
237 pkg1.clone(),
238 pkg2.clone(),
239 format!("{} ({}≠{})", dep, ver1, ver2),
240 ));
241 }
242 }
243 }
244 }
245 }
246
247 conflicts
248}
249
250fn find_version_requirement(
252 dependency_map: &HashMap<String, Vec<String>>,
253 pkg: &str,
254 dep: &str,
255) -> Option<String> {
256 if let Some(deps) = dependency_map.get(pkg) {
257 for dep_str in deps {
259 if dep_str == dep {
261 return Some("*".to_string());
263 } else if dep_str.starts_with(dep) {
264 let version_part = &dep_str[dep.len()..];
266 if !version_part.is_empty() {
267 return Some(version_part.to_string());
268 }
269 } else if dep_str.contains(dep) {
270 let mut parts = dep_str.split(&['=', '>', '<', '~', '^'][..]);
272 let dep_name = parts.next().unwrap_or("");
273
274 if dep_name.contains(dep) {
275 let version_op = dep_str.chars().find(|&c| c == '=' || c == '>' || c == '<' || c == '~' || c == '^');
277 if let Some(op_char) = version_op {
278 let op_pos = dep_str.find(op_char).unwrap();
279 return Some(dep_str[op_pos..].to_string());
280 }
281 }
282 }
283 }
284 }
285 None
286}
287
288fn versions_compatible(ver1: &str, ver2: &str) -> bool {
290 if let (Ok(v1), Ok(v2)) = (semver::VersionReq::parse(ver1), semver::VersionReq::parse(ver2)) {
292 let test_versions = [
295 "0.1.0", "1.0.0", "1.1.0", "2.0.0", "3.0.0", "4.0.0",
296 "1.2.3", "2.3.4", "3.4.5", "4.5.6"
297 ];
298
299 for version_str in &test_versions {
300 if let Ok(version) = semver::Version::parse(version_str) {
301 if v1.matches(&version) && v2.matches(&version) {
302 return true;
303 }
304 }
305 }
306 return false;
307 }
308
309 ver1 == ver2 || ver1 == "any" || ver2 == "any"
312}
313
314pub fn export_advanced_dependency_graph<P: AsRef<Path>>(
316 graph: &AdvancedDependencyGraph,
317 output_path: P,
318) -> Result<()> {
319 let mut file = File::create(output_path)
320 .with_context(|| "Failed to create advanced graph file")?;
321
322 let dot = Dot::with_config(&graph.graph, &[Config::EdgeNoLabel]);
324
325 write!(file, "{:?}", dot)?;
326
327 Ok(())
328}
329
330struct MockDependencyProvider {
332 packages: HashMap<String, String>,
333 dependencies: HashMap<String, Vec<String>>,
334}
335
336#[derive(Clone)]
338pub struct CondaDependencyProvider {
339 packages: HashMap<String, Vec<String>>,
341 dependencies: HashMap<(String, String), Vec<(String, String)>>,
343}
344
345impl CondaDependencyProvider {
346 pub fn new(packages: &[Package], dependency_map: &HashMap<String, Vec<String>>) -> Self {
348 let mut provider = CondaDependencyProvider {
349 packages: HashMap::new(),
350 dependencies: HashMap::new(),
351 };
352
353 for package in packages {
355 if let Some(version) = &package.version {
356 provider.packages
357 .entry(package.name.clone())
358 .or_insert_with(Vec::new)
359 .push(version.clone());
360 }
361 }
362
363 for (pkg_name, deps) in dependency_map {
365 if let Some(versions) = provider.packages.get(pkg_name) {
366 for version in versions {
367 let mut parsed_deps = Vec::new();
368
369 for dep_str in deps {
370 if let Some((dep_name, constraint)) = parse_dependency(dep_str) {
372 parsed_deps.push((dep_name, constraint));
373 }
374 }
375
376 provider.dependencies.insert((pkg_name.clone(), version.clone()), parsed_deps);
377 }
378 }
379 }
380
381 provider
382 }
383
384 pub fn solve(&self, root_packages: &[String]) -> Result<HashMap<String, String>, String> {
386 let mut solution = HashMap::new();
387 let mut visited = HashSet::new();
388
389 for pkg in root_packages {
391 if visited.contains(pkg) {
392 continue;
393 }
394
395 if let Err(e) = self.add_package_to_solution(pkg, &mut solution, &mut visited) {
396 return Err(format!("Failed to resolve dependencies: {}", e));
397 }
398 }
399
400 Ok(solution)
401 }
402
403 fn add_package_to_solution(
405 &self,
406 pkg: &str,
407 solution: &mut HashMap<String, String>,
408 visited: &mut HashSet<String>
409 ) -> Result<(), String> {
410 if visited.contains(pkg) {
411 return Ok(());
412 }
413
414 visited.insert(pkg.to_string());
415
416 if solution.contains_key(pkg) {
418 return Ok(());
419 }
420
421 let versions = self.packages.get(pkg)
423 .ok_or_else(|| format!("Package {} not found", pkg))?;
424
425 if versions.is_empty() {
426 return Err(format!("No versions available for package {}", pkg));
427 }
428
429 let mut sorted_versions = versions.clone();
431 sorted_versions.sort_by(|a, b| {
432 let a_semver = semver::Version::parse(a).unwrap_or_else(|_| semver::Version::new(0, 0, 0));
433 let b_semver = semver::Version::parse(b).unwrap_or_else(|_| semver::Version::new(0, 0, 0));
434 b_semver.cmp(&a_semver)
435 });
436
437 let latest_version = &sorted_versions[0];
438
439 solution.insert(pkg.to_string(), latest_version.clone());
441
442 if let Some(deps) = self.dependencies.get(&(pkg.to_string(), latest_version.clone())) {
444 for (dep_name, _) in deps {
445 self.add_package_to_solution(dep_name, solution, visited)?;
446 }
447 }
448
449 Ok(())
450 }
451}
452
453fn parse_dependency(dep_str: &str) -> Option<(String, String)> {
455 let re = Regex::new(r"^([a-zA-Z0-9_-]+)([<>=~^]+.+)?$").ok()?;
461 let captures = re.captures(dep_str)?;
462
463 let name = captures.get(1)?.as_str().to_string();
464 let constraint = captures.get(2)
465 .map(|m| m.as_str().to_string())
466 .unwrap_or_else(|| "".to_string());
467
468 Some((name, constraint))
469}
470
471pub fn find_vulnerabilities(packages: &[Package]) -> Vec<(String, String, String)> {
473 info!("Scanning {} packages for security vulnerabilities", packages.len());
474 let mut vulnerabilities = Vec::new();
475
476 let client = reqwest::blocking::Client::builder()
478 .timeout(std::time::Duration::from_secs(15))
479 .build()
480 .unwrap_or_default();
481
482 for package in packages {
484 if let Some(version) = &package.version {
485 debug!("Checking vulnerabilities for {} {}", package.name, version);
486
487 check_local_vulnerability_db(package, version, &mut vulnerabilities);
489
490 if let Err(e) = check_osv_database(&client, package, version, &mut vulnerabilities) {
492 warn!("OSV API error for {}: {}", package.name, e);
493 }
494
495 if package.channel.as_deref().map_or(false, |c| c == "pip" || c == "conda-forge") {
497 if let Err(e) = check_pypi_security(&client, package, version, &mut vulnerabilities) {
498 warn!("PyPI security API error for {}: {}", package.name, e);
499 }
500 }
501
502 check_version_gap(package, version, &mut vulnerabilities);
504 }
505 }
506
507 deduplicate_vulnerabilities(&mut vulnerabilities);
509
510 info!("Found {} vulnerabilities across {} packages",
511 vulnerabilities.len(), packages.len());
512
513 vulnerabilities
514}
515
516fn check_local_vulnerability_db(
518 package: &Package,
519 version: &str,
520 vulnerabilities: &mut Vec<(String, String, String)>
521) {
522 let known_vulnerabilities = [
525 ("log4j", "2.0", "Log4Shell vulnerability, CVE-2021-44228"),
526 ("numpy", "1.19.0", "Buffer overflow in numpy.lib.arraypad, CVE-2021-33430"),
527 ("tensorflow", "2.4.0", "Integer overflow in TensorFlow, CVE-2021-37678"),
528 ("torch", "1.4", "Improper size validation in older PyTorch, CVE-2022-45907"),
529 ("pillow", "8.3.0", "Multiple buffer overflow vulnerabilities, CVE-2021-34552"),
530 ("django", "2.0", "XSS vulnerability in Django admin, CVE-2019-19844"),
531 ("django", "1.11", "Potential SQL injection in Django, CVE-2020-9402"),
532 ("requests", "2.2", "SSRF vulnerability in Requests, CVE-2018-18074"),
533 ("flask", "0.12", "Session fixation in Flask, CVE-2018-1000656"),
534 ("jinja2", "2.10", "Sandbox bypass in Jinja2, CVE-2019-10906"),
535 ("sqlalchemy", "1.3.0", "SQL injection in SQLAlchemy, CVE-2019-7164"),
536 ("cryptography", "2.8", "Improper certificate validation, CVE-2020-25659"),
537 ("werkzeug", "0.14", "Open redirect vulnerability, CVE-2019-14806"),
538 ("click", "7.0", "Command argument injection, CVE-2021-29622"),
539 ("pandas", "0.24", "Use-after-free in read_stata, CVE-2020-13091"),
540 ("nltk", "3.4", "Arbitrary code execution in nltk, CVE-2019-14751"),
541 ("lxml", "4.6.2", "XML external entity vulnerability, CVE-2021-28957"),
542 ("psycopg2", "2.8.5", "SQL injection vulnerability, CVE-2022-31116"),
543 ("scipy", "1.5.0", "Buffer overflow in scipy.special, CVE-2020-15864"),
544 ("tornado", "6.0.3", "Improper certificate validation, CVE-2020-28476"),
545 ];
546
547 for &(pkg, ver, desc) in &known_vulnerabilities {
548 if package.name == pkg && is_vulnerable_version(version, ver) {
549 vulnerabilities.push((
550 package.name.clone(),
551 version.to_string(),
552 desc.to_string(),
553 ));
554 }
555 }
556}
557
558fn is_vulnerable_version(version: &str, vulnerable_pattern: &str) -> bool {
560 if version.starts_with(vulnerable_pattern) {
562 return true;
563 }
564
565 if let (Ok(version_semver), Ok(pattern_semver)) =
567 (semver::Version::parse(version), semver::Version::parse(vulnerable_pattern)) {
568 version_semver <= pattern_semver
570 } else {
571 version.trim() == vulnerable_pattern.trim()
573 }
574}
575
576fn check_osv_database(
578 client: &reqwest::blocking::Client,
579 package: &Package,
580 version: &str,
581 vulnerabilities: &mut Vec<(String, String, String)>
582) -> Result<(), String> {
583 debug!("Checking OSV database for {} {}", package.name, version);
584
585 let ecosystem = if package.channel.as_deref() == Some("pip") {
587 "PyPI"
588 } else {
589 "Conda"
590 };
591
592 let url = "https://api.osv.dev/v1/query";
594 let request_body = serde_json::json!({
595 "package": {
596 "name": package.name,
597 "ecosystem": ecosystem
598 },
599 "version": version
600 });
601
602 let response = client.post(url)
604 .json(&request_body)
605 .send()
606 .map_err(|e| format!("OSV API request failed: {}", e))?;
607
608 if !response.status().is_success() {
609 return Err(format!("OSV API error: HTTP {}", response.status()));
610 }
611
612 let osv_response: serde_json::Value = response.json()
614 .map_err(|e| format!("Failed to parse OSV response: {}", e))?;
615
616 if let Some(vulns) = osv_response["vulns"].as_array() {
618 for vuln in vulns {
619 if let (Some(id), Some(summary)) = (vuln["id"].as_str(), vuln["summary"].as_str()) {
620 let description = format!("{} ({})", summary, id);
621 vulnerabilities.push((
622 package.name.clone(),
623 version.to_string(),
624 description,
625 ));
626 }
627 }
628 }
629
630 Ok(())
631}
632
633fn check_pypi_security(
635 client: &reqwest::blocking::Client,
636 package: &Package,
637 version: &str,
638 vulnerabilities: &mut Vec<(String, String, String)>
639) -> Result<(), String> {
640 debug!("Checking PyPI security advisories for {} {}", package.name, version);
641
642 let url = format!("https://raw.githubusercontent.com/pyupio/safety-db/master/data/insecure_full.json");
645
646 let safety_db = {
648 let mut cache = SAFETY_DB_CACHE.lock().map_err(|e| format!("Failed to lock cache: {}", e))?;
649
650 if cache.is_none() {
651 debug!("Safety DB not cached, fetching from source");
652 let response = client.get(&url)
653 .send()
654 .map_err(|e| format!("Safety DB request failed: {}", e))?;
655
656 if !response.status().is_success() {
657 return Err(format!("Safety DB error: HTTP {}", response.status()));
658 }
659
660 let db: serde_json::Value = response.json()
661 .map_err(|e| format!("Failed to parse Safety DB: {}", e))?;
662
663 *cache = Some(db);
664 }
665
666 cache.as_ref().unwrap().clone()
667 };
668
669 if let Some(pkg_data) = safety_db[package.name.to_lowercase()].as_array() {
671 for vuln in pkg_data {
672 if let (Some(vuln_versions), Some(vuln_id), Some(vuln_desc)) =
673 (vuln["vulnerable_versions"].as_array(), vuln["id"].as_str(), vuln["advisory"].as_str()) {
674
675 for v_ver in vuln_versions {
677 if let Some(v_ver_str) = v_ver.as_str() {
678 if is_version_affected(version, v_ver_str) {
679 let desc = format!("{} ({})", vuln_desc, vuln_id);
680 vulnerabilities.push((
681 package.name.clone(),
682 version.to_string(),
683 desc,
684 ));
685 break;
686 }
687 }
688 }
689 }
690 }
691 }
692
693 Ok(())
694}
695
696fn is_version_affected(version: &str, spec: &str) -> bool {
698 if spec.contains(version) {
702 return true;
703 }
704
705 if let Ok(version_semver) = semver::Version::parse(version) {
707 for part in spec.split(',') {
709 let part = part.trim();
710
711 if part.starts_with("<=") {
713 if let Ok(spec_ver) = semver::Version::parse(&part[2..]) {
714 if version_semver <= spec_ver {
715 return true;
716 }
717 }
718 } else if part.starts_with("<") {
719 if let Ok(spec_ver) = semver::Version::parse(&part[1..]) {
720 if version_semver < spec_ver {
721 return true;
722 }
723 }
724 } else if part.starts_with(">=") {
725 if let Ok(spec_ver) = semver::Version::parse(&part[2..]) {
726 if version_semver >= spec_ver {
727 return true;
728 }
729 }
730 } else if part.starts_with(">") {
731 if let Ok(spec_ver) = semver::Version::parse(&part[1..]) {
732 if version_semver > spec_ver {
733 return true;
734 }
735 }
736 } else if part.starts_with("==") {
737 if let Ok(spec_ver) = semver::Version::parse(&part[2..]) {
738 if version_semver == spec_ver {
739 return true;
740 }
741 }
742 }
743 }
744 }
745
746 false
747}
748
749fn check_version_gap(
751 package: &Package,
752 version: &str,
753 vulnerabilities: &mut Vec<(String, String, String)>
754) {
755 if let Some(latest) = &package.latest_version {
757 if package.is_outdated && version_gap_significant(version, latest) {
758 vulnerabilities.push((
759 package.name.clone(),
760 version.to_string(),
761 format!(
762 "Potentially vulnerable due to being significantly outdated (current: {}, latest: {})",
763 version, latest
764 ),
765 ));
766 }
767 }
768}
769
770fn deduplicate_vulnerabilities(vulnerabilities: &mut Vec<(String, String, String)>) {
772 let mut seen = HashSet::new();
773 vulnerabilities.retain(|(name, version, description)| {
774 let key = format!("{}:{}:{}", name, version, description);
775 seen.insert(key)
776 });
777}
778
779fn version_gap_significant(current: &str, latest: &str) -> bool {
781 let parse_version = |version: &str| -> Option<(u32, u32, u32)> {
782 let parts: Vec<&str> = version.split('.').collect();
783 if parts.len() >= 3 {
784 let major = parts[0].parse::<u32>().ok()?;
785 let minor = parts[1].parse::<u32>().ok()?;
786 let patch = parts[2].parse::<u32>().ok()?;
787 Some((major, minor, patch))
788 } else {
789 None
790 }
791 };
792
793 if let (Some(current_parts), Some(latest_parts)) = (parse_version(current), parse_version(latest)) {
794 let (curr_major, curr_minor, _) = current_parts;
795 let (latest_major, latest_minor, _) = latest_parts;
796
797 latest_major > curr_major || (latest_major == curr_major && latest_minor >= curr_minor + 2)
799 } else {
800 false
802 }
803}