syncable_cli/analyzer/vulnerability/checkers/
rust.rs

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