syncable_cli/analyzer/vulnerability/checkers/
rust.rs

1use std::path::Path;
2use std::process::Command;
3use log::{info, warn};
4
5use crate::analyzer::dependency_parser::{DependencyInfo, Language};
6use crate::analyzer::tool_management::ToolDetector;
7use super::{LanguageVulnerabilityChecker};
8use crate::analyzer::vulnerability::{VulnerableDependency, VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity};
9
10pub struct RustVulnerabilityChecker;
11
12impl RustVulnerabilityChecker {
13    pub fn new() -> Self {
14        Self
15    }
16}
17
18impl LanguageVulnerabilityChecker for RustVulnerabilityChecker {
19    fn check_vulnerabilities(
20        &self,
21        dependencies: &[DependencyInfo],
22        _project_path: &Path,
23    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
24        info!("Checking Rust dependencies with cargo-audit");
25        
26        // Check if cargo-audit is installed
27        let mut detector = ToolDetector::new();
28        let cargo_audit_status = detector.detect_tool("cargo-audit");
29        
30        if !cargo_audit_status.available {
31            warn!("cargo-audit not installed. Install with: cargo install cargo-audit");
32            warn!("Skipping Rust vulnerability checks");
33            return Ok(vec![]);
34        }
35        
36        info!("Using cargo-audit {} at {:?}", 
37              cargo_audit_status.version.as_deref().unwrap_or("unknown"),
38              cargo_audit_status.path.as_deref().unwrap_or_else(|| std::path::Path::new("cargo-audit")));
39        
40        // Run cargo audit in JSON format
41        let output = Command::new("cargo")
42            .args(&["audit", "--json"])
43            .output()
44            .map_err(|e| VulnerabilityError::CommandError(
45                format!("Failed to run cargo audit: {}", e)
46            ))?;
47        
48        if output.stdout.is_empty() {
49            return Ok(vec![]);
50        }
51        
52        // Parse cargo audit output
53        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)?;
54        
55        self.parse_cargo_audit_output(&audit_data, dependencies)
56    }
57}
58
59impl RustVulnerabilityChecker {
60    // Make this method public for testing
61    pub fn parse_cargo_audit_output(
62        &self,
63        audit_data: &serde_json::Value,
64        dependencies: &[DependencyInfo],
65    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
66        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
67        
68        // Process actual vulnerabilities
69        if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.get("list")).and_then(|l| l.as_array()) {
70            self.parse_cargo_audit_vulnerabilities(&vulnerabilities, dependencies, &mut vulnerable_deps)?;
71        }
72        
73        // Process warnings (unmaintained/yanked)
74        if let Some(warnings) = audit_data.get("warnings") {
75            // Handle unmaintained warnings
76            if let Some(unmaintained) = warnings.get("unmaintained").and_then(|w| w.as_array()) {
77                self.parse_cargo_audit_warnings(&unmaintained, dependencies, &mut vulnerable_deps)?;
78            }
79            
80            // Handle yanked warnings
81            if let Some(yanked) = warnings.get("yanked").and_then(|w| w.as_array()) {
82                self.parse_cargo_audit_warnings(&yanked, dependencies, &mut vulnerable_deps)?;
83            }
84        }
85        
86        Ok(vulnerable_deps)
87    }
88    
89    // Make this method public for testing
90    pub fn parse_cargo_audit_vulnerabilities(
91        &self,
92        vulnerabilities: &Vec<serde_json::Value>,
93        dependencies: &[DependencyInfo],
94        vulnerable_deps: &mut Vec<VulnerableDependency>,
95    ) -> Result<(), VulnerabilityError> {
96        for vuln in vulnerabilities {
97            if let Some(advisory) = vuln.get("advisory") {
98                let package_name = advisory.get("package")
99                    .and_then(|n| n.as_str())
100                    .unwrap_or("");
101                
102                let package_version = vuln.get("package")
103                    .and_then(|p| p.get("version"))
104                    .and_then(|v| v.as_str())
105                    .unwrap_or("");
106                
107                if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
108                    let vuln_info = VulnerabilityInfo {
109                        id: advisory.get("id")
110                            .and_then(|id| id.as_str())
111                            .unwrap_or("unknown")
112                            .to_string(),
113                        vuln_type: "security".to_string(),  // Security vulnerability
114                        severity: self.parse_rustsec_severity(
115                            advisory.get("severity")
116                                .and_then(|s| s.as_str())
117                        ),
118                        title: advisory.get("title")
119                            .and_then(|t| t.as_str())
120                            .unwrap_or("Unknown vulnerability")
121                            .to_string(),
122                        description: advisory.get("description")
123                            .and_then(|d| d.as_str())
124                            .unwrap_or("")
125                            .to_string(),
126                        cve: advisory.get("aliases")
127                            .and_then(|a| a.as_array())
128                            .and_then(|arr| arr.iter()
129                                .filter_map(|v| v.as_str())
130                                .find(|s| s.starts_with("CVE-"))
131                                .map(|s| s.to_string())),
132                        ghsa: advisory.get("aliases")
133                            .and_then(|a| a.as_array())
134                            .and_then(|arr| arr.iter()
135                                .filter_map(|v| v.as_str())
136                                .find(|s| s.starts_with("GHSA-"))
137                                .map(|s| s.to_string())),
138                        affected_versions: format!("< {}", 
139                            vuln.get("versions")
140                                .and_then(|v| v.get("patched"))
141                                .and_then(|p| p.as_array())
142                                .and_then(|arr| arr.first())
143                                .and_then(|s| s.as_str())
144                                .unwrap_or("unknown")
145                        ),
146                        patched_versions: vuln.get("versions")
147                            .and_then(|v| v.get("patched"))
148                            .and_then(|p| p.as_array())
149                            .and_then(|arr| arr.first())
150                            .and_then(|s| s.as_str())
151                            .map(|s| s.to_string()),
152                        published_date: advisory.get("date")
153                            .and_then(|d| d.as_str())
154                            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
155                            .map(|dt| dt.with_timezone(&chrono::Utc)),
156                        references: advisory.get("references")
157                            .and_then(|r| r.as_array())
158                            .map(|refs| refs.iter()
159                                .filter_map(|r| r.as_str().map(|s| s.to_string()))
160                                .collect())
161                            .unwrap_or_default(),
162                    };
163                    
164                    // Check if we already have this dependency
165                    if let Some(existing) = vulnerable_deps.iter_mut()
166                        .find(|vuln_dep: &&mut VulnerableDependency| vuln_dep.name == dep.name && vuln_dep.version == package_version) 
167                    {
168                        existing.vulnerabilities.push(vuln_info);
169                    } else {
170                        vulnerable_deps.push(VulnerableDependency {
171                            name: dep.name.clone(),
172                            version: package_version.to_string(),
173                            language: Language::Rust,
174                            vulnerabilities: vec![vuln_info],
175                        });
176                    }
177                }
178            }
179        }
180        
181        Ok(())
182    }
183    
184    // Make this method public for testing
185    pub fn parse_cargo_audit_warnings(
186        &self,
187        warnings: &Vec<serde_json::Value>,
188        dependencies: &[DependencyInfo],
189        vulnerable_deps: &mut Vec<VulnerableDependency>,
190    ) -> Result<(), VulnerabilityError> {
191        for warning in warnings {
192            let kind = warning.get("kind").and_then(|k| k.as_str()).unwrap_or("");
193            
194            // Extract package info from the nested structure
195            let (package_name, package_version) = if let Some(package_obj) = warning.get("package") {
196                (
197                    package_obj.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(),
198                    package_obj.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string()
199                )
200            } else {
201                ("".to_string(), "".to_string())
202            };
203            
204            // Only process unmaintained and yanked warnings
205            if kind == "unmaintained" || kind == "yanked" {
206                if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
207                    let (severity, title, description) = match kind {
208                        "unmaintained" => (
209                            VulnerabilitySeverity::Low,
210                            format!("Unmaintained package: {}", package_name),
211                            warning.get("advisory")
212                                .and_then(|a| a.get("description"))
213                                .and_then(|d| d.as_str())
214                                .unwrap_or("Package is unmaintained").to_string()
215                        ),
216                        "yanked" => (
217                            VulnerabilitySeverity::Medium,
218                            format!("Yanked package: {}", package_name),
219                            "Package version has been yanked".to_string()
220                        ),
221                        _ => continue, // Should not happen due to the if condition above
222                    };
223                    
224                    let vuln_info = VulnerabilityInfo {
225                        id: format!("{}-{}", kind, package_name),
226                        vuln_type: kind.to_string(),  // "unmaintained" or "yanked"
227                        severity,
228                        title,
229                        description,
230                        cve: None,
231                        ghsa: None,
232                        affected_versions: package_version.to_string(),
233                        patched_versions: None,
234                        published_date: None,
235                        references: vec![],
236                    };
237                    
238                    // Check if we already have this dependency
239                    if let Some(existing) = vulnerable_deps.iter_mut()
240                        .find(|vuln_dep: &&mut VulnerableDependency| vuln_dep.name == dep.name && vuln_dep.version == package_version) 
241                    {
242                        existing.vulnerabilities.push(vuln_info);
243                    } else {
244                        vulnerable_deps.push(VulnerableDependency {
245                            name: dep.name.clone(),
246                            version: package_version.to_string(),
247                            language: Language::Rust,
248                            vulnerabilities: vec![vuln_info],
249                        });
250                    }
251                }
252            }
253        }
254        
255        Ok(())
256    }
257    
258    fn parse_rustsec_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
259        match severity.map(|s| s.to_lowercase()).as_deref() {
260            Some("critical") => VulnerabilitySeverity::Critical,
261            Some("high") => VulnerabilitySeverity::High,
262            Some("medium") | Some("moderate") => VulnerabilitySeverity::Medium,
263            Some("low") => VulnerabilitySeverity::Low,
264            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
265        }
266    }
267}