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