Skip to main content

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                            source_dir: None,
216                        });
217                    }
218                }
219            }
220        }
221
222        Ok(())
223    }
224
225    // Make this method public for testing
226    pub fn parse_cargo_audit_warnings(
227        &self,
228        warnings: &Vec<serde_json::Value>,
229        dependencies: &[DependencyInfo],
230        vulnerable_deps: &mut Vec<VulnerableDependency>,
231    ) -> Result<(), VulnerabilityError> {
232        for warning in warnings {
233            let kind = warning.get("kind").and_then(|k| k.as_str()).unwrap_or("");
234
235            // Extract package info from the nested structure
236            let (package_name, package_version) = if let Some(package_obj) = warning.get("package")
237            {
238                (
239                    package_obj
240                        .get("name")
241                        .and_then(|n| n.as_str())
242                        .unwrap_or("")
243                        .to_string(),
244                    package_obj
245                        .get("version")
246                        .and_then(|v| v.as_str())
247                        .unwrap_or("")
248                        .to_string(),
249                )
250            } else {
251                ("".to_string(), "".to_string())
252            };
253
254            // Only process unmaintained and yanked warnings
255            if (kind == "unmaintained" || kind == "yanked")
256                && let Some(dep) = dependencies.iter().find(|d| d.name == package_name)
257            {
258                let (severity, title, description) = match kind {
259                    "unmaintained" => (
260                        VulnerabilitySeverity::Low,
261                        format!("Unmaintained package: {}", package_name),
262                        warning
263                            .get("advisory")
264                            .and_then(|a| a.get("description"))
265                            .and_then(|d| d.as_str())
266                            .unwrap_or("Package is unmaintained")
267                            .to_string(),
268                    ),
269                    "yanked" => (
270                        VulnerabilitySeverity::Medium,
271                        format!("Yanked package: {}", package_name),
272                        "Package version has been yanked".to_string(),
273                    ),
274                    _ => continue, // Should not happen due to the if condition above
275                };
276
277                let vuln_info = VulnerabilityInfo {
278                    id: format!("{}-{}", kind, package_name),
279                    vuln_type: kind.to_string(), // "unmaintained" or "yanked"
280                    severity,
281                    title,
282                    description,
283                    cve: None,
284                    ghsa: None,
285                    affected_versions: package_version.to_string(),
286                    patched_versions: None,
287                    published_date: None,
288                    references: vec![],
289                };
290
291                // Check if we already have this dependency
292                if let Some(existing) =
293                    vulnerable_deps
294                        .iter_mut()
295                        .find(|vuln_dep: &&mut VulnerableDependency| {
296                            vuln_dep.name == dep.name && vuln_dep.version == package_version
297                        })
298                {
299                    existing.vulnerabilities.push(vuln_info);
300                } else {
301                    vulnerable_deps.push(VulnerableDependency {
302                        name: dep.name.clone(),
303                        version: package_version.to_string(),
304                        language: Language::Rust,
305                        vulnerabilities: vec![vuln_info],
306                        source_dir: None,
307                    });
308                }
309            }
310        }
311
312        Ok(())
313    }
314
315    fn parse_rustsec_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
316        match severity.map(|s| s.to_lowercase()).as_deref() {
317            Some("critical") => VulnerabilitySeverity::Critical,
318            Some("high") => VulnerabilitySeverity::High,
319            Some("medium") | Some("moderate") => VulnerabilitySeverity::Medium,
320            Some("low") => VulnerabilitySeverity::Low,
321            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
322        }
323    }
324}