syncable_cli/analyzer/vulnerability/checkers/
javascript.rs

1use std::path::Path;
2use std::process::Command;
3use log::{info, warn};
4use crate::analyzer::dependency_parser::{DependencyInfo, Language};
5use crate::analyzer::runtime::{RuntimeDetector, PackageManager};
6use crate::analyzer::tool_management::ToolDetector;
7use crate::analyzer::vulnerability::{VulnerableDependency, VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity};
8use super::MutableLanguageVulnerabilityChecker;
9
10pub struct JavaScriptVulnerabilityChecker {
11    tool_detector: ToolDetector,
12}
13
14impl JavaScriptVulnerabilityChecker {
15    pub fn new() -> Self {
16        Self {
17            tool_detector: ToolDetector::new(),
18        }
19    }
20    
21    fn execute_audit_for_manager(
22        &mut self,
23        manager: &PackageManager,
24        project_path: &Path,
25        dependencies: &[DependencyInfo],
26    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
27        match manager {
28            PackageManager::Bun => self.execute_bun_audit(project_path, dependencies),
29            PackageManager::Npm => self.execute_npm_audit(project_path, dependencies),
30            PackageManager::Yarn => self.execute_yarn_audit(project_path, dependencies),
31            PackageManager::Pnpm => self.execute_pnpm_audit(project_path, dependencies),
32            PackageManager::Unknown => Ok(None),
33        }
34    }
35    
36    fn execute_bun_audit(
37        &mut self,
38        project_path: &Path,
39        dependencies: &[DependencyInfo],
40    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
41        // Check if bun is available
42        let bun_status = self.tool_detector.detect_tool("bun");
43        if !bun_status.available {
44            warn!("bun not found, skipping bun audit");
45            return Ok(None);
46        }
47        
48        info!("Executing bun audit in {}", project_path.display());
49        
50        // Execute bun audit --json
51        let output = Command::new("bun")
52            .args(&["audit", "--json"])
53            .current_dir(project_path)
54            .output()
55            .map_err(|e| VulnerabilityError::CommandError(
56                format!("Failed to run bun audit: {}", e)
57            ))?;
58        
59        // bun audit returns non-zero exit code when vulnerabilities found
60        // This is expected behavior, not an error
61        if !output.status.success() && !output.stdout.is_empty() {
62            info!("bun audit completed with findings");
63        }
64        
65        if output.stdout.is_empty() {
66            return Ok(None);
67        }
68        
69        // Parse bun audit output
70        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
71            .map_err(|e| VulnerabilityError::ParseError(
72                format!("Failed to parse bun audit output: {}", e)
73            ))?;
74        
75        self.parse_bun_audit_output(&audit_data, dependencies)
76    }
77    
78    fn execute_npm_audit(
79        &mut self,
80        project_path: &Path,
81        dependencies: &[DependencyInfo],
82    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
83        // Check if npm is available
84        let npm_status = self.tool_detector.detect_tool("npm");
85        if !npm_status.available {
86            warn!("npm not found, skipping npm audit");
87            return Ok(None);
88        }
89        
90        info!("Executing npm audit in {}", project_path.display());
91        
92        // Execute npm audit --json
93        let output = Command::new("npm")
94            .args(&["audit", "--json"])
95            .current_dir(project_path)
96            .output()
97            .map_err(|e| VulnerabilityError::CommandError(
98                format!("Failed to run npm audit: {}", e)
99            ))?;
100        
101        // npm audit returns 0 even when vulnerabilities are found
102        // Non-zero exit code indicates an actual error
103        if !output.status.success() && output.stdout.is_empty() {
104            return Err(VulnerabilityError::CommandError(
105                format!("npm audit failed with exit code {}: {}", 
106                    output.status.code().unwrap_or(-1),
107                    String::from_utf8_lossy(&output.stderr))
108            ));
109        }
110        
111        if output.stdout.is_empty() {
112            return Ok(None);
113        }
114        
115        // Parse npm audit output
116        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
117            .map_err(|e| VulnerabilityError::ParseError(
118                format!("Failed to parse npm audit output: {}", e)
119            ))?;
120        
121        self.parse_npm_audit_output(&audit_data, dependencies)
122    }
123    
124    fn execute_yarn_audit(
125        &mut self,
126        project_path: &Path,
127        dependencies: &[DependencyInfo],
128    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
129        // Check if yarn is available
130        let yarn_status = self.tool_detector.detect_tool("yarn");
131        if !yarn_status.available {
132            warn!("yarn not found, skipping yarn audit");
133            return Ok(None);
134        }
135        
136        info!("Executing yarn audit in {}", project_path.display());
137        
138        // Execute yarn audit --json
139        let output = Command::new("yarn")
140            .args(&["audit", "--json"])
141            .current_dir(project_path)
142            .output()
143            .map_err(|e| VulnerabilityError::CommandError(
144                format!("Failed to run yarn audit: {}", e)
145            ))?;
146        
147        // yarn audit behavior: returns 0 even when vulnerabilities are found
148        // Non-zero exit code indicates an actual error
149        if !output.status.success() && output.stdout.is_empty() {
150            return Err(VulnerabilityError::CommandError(
151                format!("yarn audit failed with exit code {}: {}", 
152                    output.status.code().unwrap_or(-1),
153                    String::from_utf8_lossy(&output.stderr))
154            ));
155        }
156        
157        if output.stdout.is_empty() {
158            return Ok(None);
159        }
160        
161        // Parse yarn audit output
162        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
163            .map_err(|e| VulnerabilityError::ParseError(
164                format!("Failed to parse yarn audit output: {}", e)
165            ))?;
166        
167        self.parse_yarn_audit_output(&audit_data, dependencies)
168    }
169    
170    fn execute_pnpm_audit(
171        &mut self,
172        project_path: &Path,
173        dependencies: &[DependencyInfo],
174    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
175        // Check if pnpm is available
176        let pnpm_status = self.tool_detector.detect_tool("pnpm");
177        if !pnpm_status.available {
178            warn!("pnpm not found, skipping pnpm audit");
179            return Ok(None);
180        }
181        
182        info!("Executing pnpm audit in {}", project_path.display());
183        
184        // Execute pnpm audit --json
185        let output = Command::new("pnpm")
186            .args(&["audit", "--json"])
187            .current_dir(project_path)
188            .output()
189            .map_err(|e| VulnerabilityError::CommandError(
190                format!("Failed to run pnpm audit: {}", e)
191            ))?;
192        
193        // pnpm audit behavior: returns 0 even when vulnerabilities are found
194        // Non-zero exit code indicates an actual error
195        if !output.status.success() && output.stdout.is_empty() {
196            return Err(VulnerabilityError::CommandError(
197                format!("pnpm audit failed with exit code {}: {}", 
198                    output.status.code().unwrap_or(-1),
199                    String::from_utf8_lossy(&output.stderr))
200            ));
201        }
202        
203        if output.stdout.is_empty() {
204            return Ok(None);
205        }
206        
207        // Parse pnpm audit output
208        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
209            .map_err(|e| VulnerabilityError::ParseError(
210                format!("Failed to parse pnpm audit output: {}", e)
211            ))?;
212        
213        self.parse_pnpm_audit_output(&audit_data, dependencies)
214    }
215    
216    fn parse_bun_audit_output(
217        &self,
218        audit_data: &serde_json::Value,
219        dependencies: &[DependencyInfo],
220    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
221        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
222        
223        // Bun audit JSON structure parsing
224        if let Some(advisories) = audit_data.get("advisories").and_then(|a| a.as_array()) {
225            for advisory in advisories {
226                // Extract vulnerability information
227                let name = advisory.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
228                let version = advisory.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
229                
230                let vuln_info = VulnerabilityInfo {
231                    id: advisory.get("id").and_then(|i| i.as_str()).unwrap_or("unknown").to_string(),
232                    severity: self.parse_severity(advisory.get("severity").and_then(|s| s.as_str())),
233                    title: advisory.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
234                    description: advisory.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(),
235                    cve: advisory.get("cve").and_then(|c| c.as_str()).map(|s| s.to_string()),
236                    ghsa: advisory.get("ghsa").and_then(|g| g.as_array())
237                        .and_then(|arr| arr.first())
238                        .and_then(|v| v.as_str())
239                        .map(|s| s.to_string()),
240                    affected_versions: advisory.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("").to_string(),
241                    patched_versions: advisory.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
242                    published_date: None, // Bun audit may not provide this
243                    references: advisory.get("references").and_then(|r| r.as_array())
244                        .map(|refs| refs.iter()
245                            .filter_map(|r| r.as_str().map(|s| s.to_string()))
246                            .collect())
247                        .unwrap_or_default(),
248                };
249                
250                // Find matching dependency
251                if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
252                    // Check if we already have this dependency
253                    if let Some(existing) = vulnerable_deps.iter_mut()
254                        .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
255                    {
256                        existing.vulnerabilities.push(vuln_info);
257                    } else {
258                        vulnerable_deps.push(VulnerableDependency {
259                            name: dep.name.clone(),
260                            version: version.clone(),
261                            language: Language::JavaScript,
262                            vulnerabilities: vec![vuln_info],
263                        });
264                    }
265                }
266            }
267        }
268        
269        if vulnerable_deps.is_empty() {
270            Ok(None)
271        } else {
272            Ok(Some(vulnerable_deps))
273        }
274    }
275    
276    fn parse_npm_audit_output(
277        &self,
278        audit_data: &serde_json::Value,
279        dependencies: &[DependencyInfo],
280    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
281        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
282        
283        // NPM audit JSON structure parsing
284        if let Some(actions) = audit_data.get("actions").and_then(|a| a.as_array()) {
285            for action in actions {
286                if let Some(resolves) = action.get("resolves").and_then(|r| r.as_array()) {
287                    for resolve in resolves {
288                        let name = resolve.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
289                        let version = resolve.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
290                        
291                        // Get advisory details
292                        let advisory_id = resolve.get("id").and_then(|i| i.as_u64()).unwrap_or(0);
293                        
294                        // Find the advisory in the advisories section
295                        if let Some(advisories) = audit_data.get("advisories").and_then(|a| a.as_object()) {
296                            if let Some(advisory) = advisories.get(&advisory_id.to_string()) {
297                                let vuln_info = VulnerabilityInfo {
298                                    id: advisory.get("id").and_then(|i| i.as_u64())
299                                        .map(|id| id.to_string())
300                                        .unwrap_or("unknown".to_string()),
301                                    severity: self.parse_severity(advisory.get("severity").and_then(|s| s.as_str())),
302                                    title: advisory.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
303                                    description: advisory.get("overview").and_then(|o| o.as_str()).unwrap_or("").to_string(),
304                                    cve: advisory.get("cves").and_then(|c| c.as_array())
305                                        .and_then(|arr| arr.first())
306                                        .and_then(|v| v.as_str())
307                                        .map(|s| s.to_string()),
308                                    ghsa: advisory.get("github_advisory_id").and_then(|g| g.as_str()).map(|s| s.to_string()),
309                                    affected_versions: advisory.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("").to_string(),
310                                    patched_versions: advisory.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
311                                    published_date: advisory.get("publish_time")
312                                        .and_then(|d| d.as_u64())
313                                        .and_then(|timestamp| {
314                                            use chrono::TimeZone;
315                                            chrono::Utc.timestamp_opt(timestamp as i64, 0).single()
316                                        }),
317                                    references: advisory.get("references").and_then(|r| r.as_array())
318                                        .map(|refs| refs.iter()
319                                            .filter_map(|r| r.as_str().map(|s| s.to_string()))
320                                            .collect())
321                                        .unwrap_or_default(),
322                                };
323                                
324                                // Find matching dependency
325                                if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
326                                    // Check if we already have this dependency
327                                    if let Some(existing) = vulnerable_deps.iter_mut()
328                                        .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
329                                    {
330                                        existing.vulnerabilities.push(vuln_info);
331                                    } else {
332                                        vulnerable_deps.push(VulnerableDependency {
333                                            name: dep.name.clone(),
334                                            version: version.clone(),
335                                            language: Language::JavaScript,
336                                            vulnerabilities: vec![vuln_info],
337                                        });
338                                    }
339                                }
340                            }
341                        }
342                    }
343                }
344            }
345        }
346        
347        if vulnerable_deps.is_empty() {
348            Ok(None)
349        } else {
350            Ok(Some(vulnerable_deps))
351        }
352    }
353    
354    fn parse_yarn_audit_output(
355        &self,
356        audit_data: &serde_json::Value,
357        dependencies: &[DependencyInfo],
358    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
359        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
360        
361        // Yarn audit JSON structure parsing
362        if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
363            if let Some(vulnerabilities) = data.get("vulnerabilities").and_then(|v| v.as_array()) {
364                for vulnerability in vulnerabilities {
365                    let name = vulnerability.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
366                    let version = vulnerability.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
367                    
368                    let vuln_info = VulnerabilityInfo {
369                        id: vulnerability.get("advisory").and_then(|a| a.get("id"))
370                            .and_then(|i| i.as_u64())
371                            .map(|id| id.to_string())
372                            .unwrap_or("unknown".to_string()),
373                        severity: self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str())),
374                        title: vulnerability.get("advisory").and_then(|a| a.get("title"))
375                            .and_then(|t| t.as_str())
376                            .unwrap_or("")
377                            .to_string(),
378                        description: vulnerability.get("advisory").and_then(|a| a.get("description"))
379                            .and_then(|d| d.as_str())
380                            .unwrap_or("")
381                            .to_string(),
382                        cve: vulnerability.get("advisory").and_then(|a| a.get("cves"))
383                            .and_then(|c| c.as_array())
384                            .and_then(|arr| arr.first())
385                            .and_then(|v| v.as_str())
386                            .map(|s| s.to_string()),
387                        ghsa: vulnerability.get("advisory").and_then(|a| a.get("github_advisory_id"))
388                            .and_then(|g| g.as_str())
389                            .map(|s| s.to_string()),
390                        affected_versions: vulnerability.get("advisory").and_then(|a| a.get("vulnerable_versions"))
391                            .and_then(|v| v.as_str())
392                            .unwrap_or("")
393                            .to_string(),
394                        patched_versions: vulnerability.get("advisory").and_then(|a| a.get("patched_versions"))
395                            .and_then(|p| p.as_str())
396                            .map(|s| s.to_string()),
397                        published_date: vulnerability.get("advisory").and_then(|a| a.get("publish_time"))
398                            .and_then(|d| d.as_u64())
399                            .and_then(|timestamp| {
400                                use chrono::TimeZone;
401                                chrono::Utc.timestamp_opt(timestamp as i64, 0).single()
402                            }),
403                        references: vulnerability.get("advisory").and_then(|a| a.get("references"))
404                            .and_then(|r| r.as_array())
405                            .map(|refs| refs.iter()
406                                .filter_map(|r| r.as_str().map(|s| s.to_string()))
407                                .collect())
408                            .unwrap_or_default(),
409                    };
410                    
411                    // Find matching dependency
412                    if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
413                        // Check if we already have this dependency
414                        if let Some(existing) = vulnerable_deps.iter_mut()
415                            .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
416                        {
417                            existing.vulnerabilities.push(vuln_info);
418                        } else {
419                            vulnerable_deps.push(VulnerableDependency {
420                                name: dep.name.clone(),
421                                version: version.clone(),
422                                language: Language::JavaScript,
423                                vulnerabilities: vec![vuln_info],
424                            });
425                        }
426                    }
427                }
428            }
429        }
430        
431        if vulnerable_deps.is_empty() {
432            Ok(None)
433        } else {
434            Ok(Some(vulnerable_deps))
435        }
436    }
437    
438    fn parse_pnpm_audit_output(
439        &self,
440        audit_data: &serde_json::Value,
441        dependencies: &[DependencyInfo],
442    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
443        // PNPM audit output is similar to NPM
444        self.parse_npm_audit_output(audit_data, dependencies)
445    }
446    
447    fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
448        match severity.map(|s| s.to_lowercase()).as_deref() {
449            Some("critical") => VulnerabilitySeverity::Critical,
450            Some("high") => VulnerabilitySeverity::High,
451            Some("moderate") => VulnerabilitySeverity::Medium,
452            Some("medium") => VulnerabilitySeverity::Medium,
453            Some("low") => VulnerabilitySeverity::Low,
454            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
455        }
456    }
457}
458
459impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
460    fn check_vulnerabilities(
461        &mut self,
462        dependencies: &[DependencyInfo],
463        project_path: &Path,
464    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
465        info!("Checking JavaScript/TypeScript dependencies");
466        
467        let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
468        let _detection_result = runtime_detector.detect_js_runtime_and_package_manager();
469        
470        info!("Runtime detection: {}", runtime_detector.get_detection_summary());
471        
472        // Get all available package managers
473        let available_managers = runtime_detector.detect_all_package_managers();
474        
475        // Execute audit commands for each available manager
476        let mut all_vulnerabilities = Vec::new();
477        
478        for manager in available_managers {
479            if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
480                all_vulnerabilities.extend(vulns);
481            }
482        }
483        
484        Ok(all_vulnerabilities)
485    }
486}