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        // Bun returns a JSON object where keys are package names and values are arrays of vulnerabilities
225        if let Some(obj) = audit_data.as_object() {
226            for (package_name, vulnerabilities) in obj {
227                if let Some(vuln_array) = vulnerabilities.as_array() {
228                    // Find matching dependency
229                    if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
230                        let mut package_vulns = Vec::new();
231                        
232                        for vulnerability in vuln_array {
233                            // Extract vulnerability information
234                            let id = vulnerability.get("id").and_then(|i| i.as_u64())
235                                .map(|id| id.to_string())
236                                .unwrap_or("unknown".to_string());
237                            let title = vulnerability.get("title").and_then(|t| t.as_str())
238                                .unwrap_or("Unknown vulnerability").to_string();
239                            let description = vulnerability.get("title").and_then(|t| t.as_str())
240                                .unwrap_or("").to_string();
241                            let severity = self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str()));
242                            let affected_versions = vulnerability.get("vulnerable_versions").and_then(|v| v.as_str())
243                                .unwrap_or("*").to_string();
244                            let cwe = vulnerability.get("cwe").and_then(|c| c.as_array())
245                                .and_then(|arr| arr.first())
246                                .and_then(|v| v.as_str())
247                                .map(|s| s.to_string());
248                            let url = vulnerability.get("url").and_then(|u| u.as_str())
249                                .map(|s| s.to_string());
250                            
251                            let vuln_info = VulnerabilityInfo {
252                                id,
253                                vuln_type: "security".to_string(),  // Security vulnerability
254                                severity,
255                                title,
256                                description,
257                                cve: cwe.clone(), // Using CWE as CVE for now
258                                ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
259                                    u.split('/').last().unwrap_or(&u).to_string()
260                                }),
261                                affected_versions,
262                                patched_versions: None, // Bun doesn't provide this directly
263                                published_date: None, // Bun audit may not provide this
264                                references: url.map(|u| vec![u]).unwrap_or_default(),
265                            };
266                            
267                            package_vulns.push(vuln_info);
268                        }
269                        
270                        if !package_vulns.is_empty() {
271                            vulnerable_deps.push(VulnerableDependency {
272                                name: dep.name.clone(),
273                                version: dep.version.clone(),
274                                language: Language::JavaScript,
275                                vulnerabilities: package_vulns,
276                            });
277                        }
278                    }
279                }
280            }
281        }
282        
283        if vulnerable_deps.is_empty() {
284            Ok(None)
285        } else {
286            Ok(Some(vulnerable_deps))
287        }
288    }
289    
290    fn parse_npm_audit_output(
291        &self,
292        audit_data: &serde_json::Value,
293        dependencies: &[DependencyInfo],
294    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
295        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
296        
297        // NPM audit JSON structure parsing
298        // NPM returns a JSON object with a "vulnerabilities" field containing package vulnerabilities
299        if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
300            for (package_name, vulnerability_info) in vulnerabilities {
301                // Find matching dependency
302                if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
303                    let mut package_vulns = Vec::new();
304                    
305                    // Get vulnerability details from the "via" array
306                    if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
307                        for advisory in via {
308                            if let Some(advisory_obj) = advisory.as_object() {
309                                // Skip if this is just a reference to another package
310                                if advisory_obj.contains_key("source") && !advisory_obj.contains_key("title") {
311                                    continue;
312                                }
313                                
314                                let id = advisory_obj.get("source")
315                                    .and_then(|s| s.as_u64())
316                                    .map(|id| id.to_string())
317                                    .or_else(|| advisory_obj.get("url")
318                                        .and_then(|u| u.as_str())
319                                        .and_then(|url| {
320                                            if url.contains("GHSA") {
321                                                url.split('/').last().map(|s| s.to_string())
322                                            } else {
323                                                None
324                                            }
325                                        }))
326                                    .unwrap_or("unknown".to_string());
327                                
328                                let title = advisory_obj.get("title").and_then(|t| t.as_str())
329                                    .unwrap_or("Unknown vulnerability").to_string();
330                                let description = title.clone();
331                                let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
332                                
333                                let range = advisory_obj.get("range").and_then(|r| r.as_str())
334                                    .unwrap_or("*").to_string();
335                                
336                                let cwe = advisory_obj.get("cwe").and_then(|c| c.as_array())
337                                    .and_then(|arr| arr.first())
338                                    .and_then(|v| v.as_str())
339                                    .map(|s| s.to_string());
340                                
341                                let url = advisory_obj.get("url").and_then(|u| u.as_str())
342                                    .map(|s| s.to_string());
343                                
344                                let vuln_info = VulnerabilityInfo {
345                                    id,
346                                    vuln_type: "security".to_string(),  // Security vulnerability
347                                    severity,
348                                    title,
349                                    description,
350                                    cve: cwe.clone(),
351                                    ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
352                                        u.split('/').last().unwrap_or(&u).to_string()
353                                    }),
354                                    affected_versions: range,
355                                    patched_versions: None, // NPM doesn't provide this directly in via
356                                    published_date: None,
357                                    references: url.map(|u| vec![u]).unwrap_or_default(),
358                                };
359                                
360                                package_vulns.push(vuln_info);
361                            }
362                        }
363                    }
364                    
365                    if !package_vulns.is_empty() {
366                        vulnerable_deps.push(VulnerableDependency {
367                            name: dep.name.clone(),
368                            version: dep.version.clone(),
369                            language: Language::JavaScript,
370                            vulnerabilities: package_vulns,
371                        });
372                    }
373                }
374            }
375        }
376        
377        if vulnerable_deps.is_empty() {
378            Ok(None)
379        } else {
380            Ok(Some(vulnerable_deps))
381        }
382    }
383    
384    fn parse_yarn_audit_output(
385        &self,
386        audit_data: &serde_json::Value,
387        dependencies: &[DependencyInfo],
388    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
389        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
390        
391        // Yarn audit JSON structure parsing
392        // Yarn returns audit data in a different format than npm
393        if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
394            if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
395                for (advisory_id, advisory) in advisories {
396                    if let Some(advisory_obj) = advisory.as_object() {
397                        let package_name = advisory_obj.get("module_name").and_then(|n| n.as_str())
398                            .unwrap_or("").to_string();
399                        
400                        // Find matching dependency
401                        if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
402                            let id = advisory_id.clone();
403                            let title = advisory_obj.get("title").and_then(|t| t.as_str())
404                                .unwrap_or("Unknown vulnerability").to_string();
405                            let description = advisory_obj.get("overview").and_then(|o| o.as_str())
406                                .unwrap_or("").to_string();
407                            let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
408                            let vulnerable_versions = advisory_obj.get("vulnerable_versions").and_then(|v| v.as_str())
409                                .unwrap_or("*").to_string();
410                            
411                            let cve = advisory_obj.get("cves").and_then(|c| c.as_array())
412                                .and_then(|arr| arr.first())
413                                .and_then(|v| v.as_str())
414                                .map(|s| s.to_string());
415                            
416                            let url = advisory_obj.get("url").and_then(|u| u.as_str())
417                                .map(|s| s.to_string());
418                            
419                            let vuln_info = VulnerabilityInfo {
420                                id,
421                                vuln_type: "security".to_string(),  // Security vulnerability
422                                severity,
423                                title,
424                                description,
425                                cve,
426                                ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
427                                    u.split('/').last().unwrap_or(&u).to_string()
428                                }),
429                                affected_versions: vulnerable_versions,
430                                patched_versions: advisory_obj.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
431                                published_date: None,
432                                references: url.map(|u| vec![u]).unwrap_or_default(),
433                            };
434                            
435                            // Check if we already have this dependency
436                            if let Some(existing) = vulnerable_deps.iter_mut().find(|vuln_dep| vuln_dep.name == package_name) {
437                                existing.vulnerabilities.push(vuln_info);
438                            } else {
439                                vulnerable_deps.push(VulnerableDependency {
440                                    name: dep.name.clone(),
441                                    version: dep.version.clone(),
442                                    language: Language::JavaScript,
443                                    vulnerabilities: vec![vuln_info],
444                                });
445                            }
446                        }
447                    }
448                }
449            }
450        }
451        
452        if vulnerable_deps.is_empty() {
453            Ok(None)
454        } else {
455            Ok(Some(vulnerable_deps))
456        }
457    }
458    
459    fn parse_pnpm_audit_output(
460        &self,
461        audit_data: &serde_json::Value,
462        dependencies: &[DependencyInfo],
463    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
464        // PNPM audit output is similar to NPM
465        self.parse_npm_audit_output(audit_data, dependencies)
466    }
467    
468    fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
469        match severity.map(|s| s.to_lowercase()).as_deref() {
470            Some("critical") => VulnerabilitySeverity::Critical,
471            Some("high") => VulnerabilitySeverity::High,
472            Some("moderate") => VulnerabilitySeverity::Medium,
473            Some("medium") => VulnerabilitySeverity::Medium,
474            Some("low") => VulnerabilitySeverity::Low,
475            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
476        }
477    }
478}
479
480impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
481    fn check_vulnerabilities(
482        &mut self,
483        dependencies: &[DependencyInfo],
484        project_path: &Path,
485    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
486        info!("Checking JavaScript/TypeScript dependencies");
487        
488        let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
489        let _detection_result = runtime_detector.detect_js_runtime_and_package_manager();
490        
491        info!("Runtime detection: {}", runtime_detector.get_detection_summary());
492        
493        // Get all available package managers
494        let available_managers = runtime_detector.detect_all_package_managers();
495        
496        // Execute audit commands for each available manager
497        let mut all_vulnerabilities = Vec::new();
498        
499        for manager in available_managers {
500            if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
501                all_vulnerabilities.extend(vulns);
502            }
503        }
504        
505        Ok(all_vulnerabilities)
506    }
507}