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;
9use serde_json::Value as JsonValue;
10
11pub struct JavaScriptVulnerabilityChecker {
12    tool_detector: ToolDetector,
13}
14
15impl JavaScriptVulnerabilityChecker {
16    pub fn new() -> Self {
17        Self {
18            tool_detector: ToolDetector::new(),
19        }
20    }
21    
22    fn execute_audit_for_manager(
23        &mut self,
24        manager: &PackageManager,
25        project_path: &Path,
26        dependencies: &[DependencyInfo],
27    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
28        match manager {
29            PackageManager::Bun => self.execute_bun_audit(project_path, dependencies),
30            PackageManager::Npm => self.execute_npm_audit(project_path, dependencies),
31            PackageManager::Yarn => self.execute_yarn_audit(project_path, dependencies),
32            PackageManager::Pnpm => self.execute_pnpm_audit(project_path, dependencies),
33            PackageManager::Unknown => Ok(None),
34        }
35    }
36    
37    fn execute_bun_audit(
38        &mut self,
39        project_path: &Path,
40        dependencies: &[DependencyInfo],
41    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
42        // Check if bun is available
43        let bun_status = self.tool_detector.detect_tool("bun");
44        if !bun_status.available {
45            warn!("bun not found, skipping bun audit");
46            return Ok(None);
47        }
48        
49        info!("Executing bun audit in {}", project_path.display());
50        
51        // Execute bun audit --json
52        let output = Command::new("bun")
53            .args(&["audit", "--json"])
54            .current_dir(project_path)
55            .output()
56            .map_err(|e| VulnerabilityError::CommandError(
57                format!("Failed to run bun audit: {}", e)
58            ))?;
59        
60        // bun audit returns non-zero exit code when vulnerabilities found
61        // This is expected behavior, not an error
62        if !output.status.success() && !output.stdout.is_empty() {
63            info!("bun audit completed with findings");
64        }
65        
66        if output.stdout.is_empty() {
67            return Ok(None);
68        }
69        
70        // Parse bun audit output
71        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
72            .map_err(|e| VulnerabilityError::ParseError(
73                format!("Failed to parse bun audit output: {}", e)
74            ))?;
75        
76        self.parse_bun_audit_output(&audit_data, dependencies)
77    }
78    
79    fn execute_npm_audit(
80        &mut self,
81        project_path: &Path,
82        dependencies: &[DependencyInfo],
83    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
84        // Check if npm is available
85        let npm_status = self.tool_detector.detect_tool("npm");
86        if !npm_status.available {
87            warn!("npm not found, skipping npm audit");
88            return Ok(None);
89        }
90        
91        info!("Executing npm audit in {}", project_path.display());
92        
93        // Execute npm audit --json
94        let output = Command::new("npm")
95            .args(&["audit", "--json"])
96            .current_dir(project_path)
97            .output()
98            .map_err(|e| VulnerabilityError::CommandError(
99                format!("Failed to run npm audit: {}", e)
100            ))?;
101        
102        // npm audit returns 0 even when vulnerabilities are found
103        // Non-zero exit code indicates an actual error
104        if !output.status.success() && output.stdout.is_empty() {
105            return Err(VulnerabilityError::CommandError(
106                format!("npm audit failed with exit code {}: {}", 
107                    output.status.code().unwrap_or(-1),
108                    String::from_utf8_lossy(&output.stderr))
109            ));
110        }
111        
112        if output.stdout.is_empty() {
113            return Ok(None);
114        }
115        
116        // Parse npm audit output
117        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
118            .map_err(|e| VulnerabilityError::ParseError(
119                format!("Failed to parse npm audit output: {}", e)
120            ))?;
121        
122        self.parse_npm_audit_output(&audit_data, dependencies)
123    }
124    
125    fn execute_yarn_audit(
126        &mut self,
127        project_path: &Path,
128        dependencies: &[DependencyInfo],
129    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
130        // Check if yarn is available
131        let yarn_status = self.tool_detector.detect_tool("yarn");
132        if !yarn_status.available {
133            warn!("yarn not found, skipping yarn audit");
134            return Ok(None);
135        }
136
137        info!("Executing yarn audit in {}", project_path.display());
138
139        // Strategy:
140        // 1) Try Yarn Berry command: yarn npm audit --json (Yarn v2+)
141        // 2) Fallback to classic: yarn audit --json (Yarn v1)
142        // 3) Handle both single-JSON and line-delimited JSON formats
143        let candidates: Vec<Vec<&str>> = vec![
144            vec!["npm", "audit", "--json"],
145            vec!["audit", "--json"],
146        ];
147
148        for args in candidates {
149            let output = match Command::new("yarn").args(&args).current_dir(project_path).output() {
150                Ok(o) => o,
151                Err(e) => {
152                    warn!("Failed to run 'yarn {}': {}", args.join(" "), e);
153                    continue;
154                }
155            };
156
157            // Non-zero with empty stdout is a hard failure; otherwise attempt to parse what we got
158            if !output.status.success() && output.stdout.is_empty() {
159                warn!(
160                    "yarn {} failed (code {:?}): {}",
161                    args.join(" "),
162                    output.status.code(),
163                    String::from_utf8_lossy(&output.stderr)
164                );
165                continue;
166            }
167
168            if output.stdout.is_empty() {
169                // Nothing to parse
170                continue;
171            }
172
173            // Try to parse as a single JSON blob first (be tolerant of banners/noise)
174            if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
175                // If it looks like NPM's shape (common for `yarn npm audit`), reuse NPM parser
176                if audit_data.get("vulnerabilities").is_some() {
177                    if let Ok(res) = self.parse_npm_audit_output(&audit_data, dependencies) {
178                        if res.is_some() { return Ok(res); }
179                    }
180                }
181
182                // Otherwise try Yarn object shape
183                if let Ok(res) = self.parse_yarn_audit_output(&audit_data, dependencies) {
184                    if res.is_some() { return Ok(res); }
185                }
186            } else {
187                // If not a single JSON, try line-delimited JSON format (Yarn v1 classic)
188                if let Ok(res) = self.parse_yarn_streaming_audit_lines(&output.stdout, dependencies) {
189                    if res.is_some() { return Ok(res); }
190                }
191            }
192        }
193
194        // If we got here, we couldn't parse Yarn output; don't fail the whole scan
195        warn!("Unable to parse yarn audit output; skipping Yarn results");
196        Ok(None)
197    }
198    
199    fn execute_pnpm_audit(
200        &mut self,
201        project_path: &Path,
202        dependencies: &[DependencyInfo],
203    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
204        // Check if pnpm is available
205        let pnpm_status = self.tool_detector.detect_tool("pnpm");
206        if !pnpm_status.available {
207            warn!("pnpm not found, skipping pnpm audit");
208            return Ok(None);
209        }
210        
211        info!("Executing pnpm audit in {}", project_path.display());
212        
213        // Execute pnpm audit --json
214        let output = Command::new("pnpm")
215            .args(&["audit", "--json"])
216            .current_dir(project_path)
217            .output()
218            .map_err(|e| VulnerabilityError::CommandError(
219                format!("Failed to run pnpm audit: {}", e)
220            ))?;
221        
222        // pnpm audit behavior: returns 0 even when vulnerabilities are found
223        // Non-zero exit code indicates an actual error
224        if !output.status.success() && output.stdout.is_empty() {
225            return Err(VulnerabilityError::CommandError(
226                format!("pnpm audit failed with exit code {}: {}", 
227                    output.status.code().unwrap_or(-1),
228                    String::from_utf8_lossy(&output.stderr))
229            ));
230        }
231        
232        if output.stdout.is_empty() {
233            return Ok(None);
234        }
235        
236        // Parse pnpm audit output
237        let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
238            .map_err(|e| VulnerabilityError::ParseError(
239                format!("Failed to parse pnpm audit output: {}", e)
240            ))?;
241        
242        self.parse_pnpm_audit_output(&audit_data, dependencies)
243    }
244    
245    fn parse_bun_audit_output(
246        &self,
247        audit_data: &serde_json::Value,
248        dependencies: &[DependencyInfo],
249    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
250        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
251        
252        // Bun audit JSON structure parsing
253        // Bun returns a JSON object where keys are package names and values are arrays of vulnerabilities
254        if let Some(obj) = audit_data.as_object() {
255            for (package_name, vulnerabilities) in obj {
256                if let Some(vuln_array) = vulnerabilities.as_array() {
257                    // Include all vulnerable packages, not just direct dependencies
258                    // Audit tools report on the entire dependency tree
259                    let mut package_vulns = Vec::new();
260                    
261                    for vulnerability in vuln_array {
262                        // Extract vulnerability information
263                        let id = vulnerability.get("id").and_then(|i| i.as_u64())
264                            .map(|id| id.to_string())
265                            .unwrap_or("unknown".to_string());
266                        let title = vulnerability.get("title").and_then(|t| t.as_str())
267                            .unwrap_or("Unknown vulnerability").to_string();
268                        let description = vulnerability.get("title").and_then(|t| t.as_str())
269                            .unwrap_or("").to_string();
270                        let severity = self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str()));
271                        let affected_versions = vulnerability.get("vulnerable_versions").and_then(|v| v.as_str())
272                            .unwrap_or("*").to_string();
273                        let cwe = vulnerability.get("cwe").and_then(|c| c.as_array())
274                            .and_then(|arr| arr.first())
275                            .and_then(|v| v.as_str())
276                            .map(|s| s.to_string());
277                        let url = vulnerability.get("url").and_then(|u| u.as_str())
278                            .map(|s| s.to_string());
279                        
280                        let vuln_info = VulnerabilityInfo {
281                            id,
282                            vuln_type: "security".to_string(),  // Security vulnerability
283                            severity,
284                            title,
285                            description,
286                            cve: cwe.clone(), // Using CWE as CVE for now
287                            ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
288                                u.split('/').last().unwrap_or(&u).to_string()
289                            }),
290                            affected_versions,
291                            patched_versions: None, // Bun doesn't provide this directly
292                            published_date: None, // Bun audit may not provide this
293                            references: url.map(|u| vec![u]).unwrap_or_default(),
294                        };
295                        
296                        package_vulns.push(vuln_info);
297                    }
298                    
299                    if !package_vulns.is_empty() {
300                        // Try to find version from direct dependencies, otherwise use "transitive"
301                        let version = dependencies.iter()
302                            .find(|d| d.name == *package_name)
303                            .map(|d| d.version.clone())
304                            .unwrap_or_else(|| "transitive".to_string());
305                        
306                        vulnerable_deps.push(VulnerableDependency {
307                            name: package_name.clone(),
308                            version,
309                            language: Language::JavaScript,
310                            vulnerabilities: package_vulns,
311                        });
312                    }
313                }
314            }
315        }
316        
317        if vulnerable_deps.is_empty() {
318            Ok(None)
319        } else {
320            Ok(Some(vulnerable_deps))
321        }
322    }
323    
324    fn parse_npm_audit_output(
325        &self,
326        audit_data: &serde_json::Value,
327        dependencies: &[DependencyInfo],
328    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
329        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
330        
331        // NPM audit JSON structure parsing
332        // NPM returns a JSON object with a "vulnerabilities" field containing package vulnerabilities
333        if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
334            for (package_name, vulnerability_info) in vulnerabilities {
335                // Include all vulnerable packages, not just direct dependencies
336                // Audit tools report on the entire dependency tree
337                let mut package_vulns = Vec::new();
338                
339                // Get vulnerability details from the "via" array
340                if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
341                    for advisory in via {
342                        if let Some(advisory_obj) = advisory.as_object() {
343                            // Skip if this is just a reference to another package
344                            if advisory_obj.contains_key("source") && !advisory_obj.contains_key("title") {
345                                continue;
346                            }
347                            
348                            let id = advisory_obj.get("source")
349                                .and_then(|s| s.as_u64())
350                                .map(|id| id.to_string())
351                                .or_else(|| advisory_obj.get("url")
352                                    .and_then(|u| u.as_str())
353                                    .and_then(|url| {
354                                        if url.contains("GHSA") {
355                                            url.split('/').last().map(|s| s.to_string())
356                                        } else {
357                                            None
358                                        }
359                                    }))
360                                .unwrap_or("unknown".to_string());
361                            
362                            let title = advisory_obj.get("title").and_then(|t| t.as_str())
363                                .unwrap_or("Unknown vulnerability").to_string();
364                            let description = title.clone();
365                            let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
366                            
367                            let range = advisory_obj.get("range").and_then(|r| r.as_str())
368                                .unwrap_or("*").to_string();
369                            
370                            let cwe = advisory_obj.get("cwe").and_then(|c| c.as_array())
371                                .and_then(|arr| arr.first())
372                                .and_then(|v| v.as_str())
373                                .map(|s| s.to_string());
374                            
375                            let url = advisory_obj.get("url").and_then(|u| u.as_str())
376                                .map(|s| s.to_string());
377                            
378                            let vuln_info = VulnerabilityInfo {
379                                id,
380                                vuln_type: "security".to_string(),  // Security vulnerability
381                                severity,
382                                title,
383                                description,
384                                cve: cwe.clone(),
385                                ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
386                                    u.split('/').last().unwrap_or(&u).to_string()
387                                }),
388                                affected_versions: range,
389                                patched_versions: None, // NPM doesn't provide this directly in via
390                                published_date: None,
391                                references: url.map(|u| vec![u]).unwrap_or_default(),
392                            };
393                            
394                            package_vulns.push(vuln_info);
395                        }
396                    }
397                }
398                
399                if !package_vulns.is_empty() {
400                    // Try to find version from direct dependencies, otherwise use "transitive"
401                    let version = dependencies.iter()
402                        .find(|d| d.name == *package_name)
403                        .map(|d| d.version.clone())
404                        .unwrap_or_else(|| "transitive".to_string());
405                    
406                    vulnerable_deps.push(VulnerableDependency {
407                        name: package_name.clone(),
408                        version,
409                        language: Language::JavaScript,
410                        vulnerabilities: package_vulns,
411                    });
412                }
413            }
414        }
415        
416        if vulnerable_deps.is_empty() {
417            Ok(None)
418        } else {
419            Ok(Some(vulnerable_deps))
420        }
421    }
422    
423    fn parse_yarn_audit_output(
424        &self,
425        audit_data: &serde_json::Value,
426        dependencies: &[DependencyInfo],
427    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
428        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
429        
430        // Yarn audit JSON structure parsing
431        // Shape 1: Single object with { data: { advisories: { id: {...} } } } (rare)
432        if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
433            if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
434                for (advisory_id, advisory) in advisories {
435                    if let Some(advisory_obj) = advisory.as_object() {
436                        let (vuln_info, pkg_name) = self.extract_yarn_advisory(advisory_id, advisory_obj);
437                        // Include all vulnerable packages, not just direct dependencies
438                        if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
439                            existing.vulnerabilities.push(vuln_info);
440                        } else {
441                            // Try to find version from direct dependencies, otherwise use "transitive"
442                            let version = dependencies.iter()
443                                .find(|d| d.name == pkg_name)
444                                .map(|d| d.version.clone())
445                                .unwrap_or_else(|| "transitive".to_string());
446                            
447                            vulnerable_deps.push(VulnerableDependency {
448                                name: pkg_name,
449                                version,
450                                language: Language::JavaScript,
451                                vulnerabilities: vec![vuln_info],
452                            });
453                        }
454                    }
455                }
456            }
457        }
458        
459        if vulnerable_deps.is_empty() {
460            Ok(None)
461        } else {
462            Ok(Some(vulnerable_deps))
463        }
464    }
465
466    // Parse Yarn classic line-delimited JSON output
467    fn parse_yarn_streaming_audit_lines(
468        &self,
469        stdout: &[u8],
470        dependencies: &[DependencyInfo],
471    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
472        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
473        let text = String::from_utf8_lossy(stdout);
474        for line in text.lines() {
475            let line = line.trim();
476            if line.is_empty() { continue; }
477            if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
478                if json.get("type").and_then(|t| t.as_str()) == Some("auditAdvisory") {
479                    if let Some(advisory_obj) = json
480                        .get("data")
481                        .and_then(|d| d.get("advisory"))
482                        .and_then(|a| a.as_object())
483                    {
484                        let package_name = advisory_obj
485                            .get("module_name")
486                            .and_then(|n| n.as_str())
487                            .unwrap_or("")
488                            .to_string();
489                        let (vuln_info, pkg_name) = self.extract_yarn_advisory(
490                            advisory_obj
491                                .get("id")
492                                .and_then(|v| v.as_i64())
493                                .map(|v| v.to_string())
494                                .unwrap_or_else(|| "unknown".to_string())
495                                .as_str(),
496                            advisory_obj,
497                        );
498
499                        // Include all vulnerable packages, not just direct dependencies
500                        if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
501                            existing.vulnerabilities.push(vuln_info);
502                        } else {
503                            // Try to find version from direct dependencies, otherwise use "transitive"
504                            let version = dependencies.iter()
505                                .find(|d| d.name == pkg_name)
506                                .map(|d| d.version.clone())
507                                .unwrap_or_else(|| "transitive".to_string());
508                            
509                            vulnerable_deps.push(VulnerableDependency {
510                                name: pkg_name,
511                                version,
512                                language: Language::JavaScript,
513                                vulnerabilities: vec![vuln_info],
514                            });
515                        }
516                    }
517                }
518            }
519        }
520
521        if vulnerable_deps.is_empty() { Ok(None) } else { Ok(Some(vulnerable_deps)) }
522    }
523
524    fn extract_yarn_advisory<'a>(
525        &self,
526        advisory_id: impl Into<String>,
527        advisory_obj: &serde_json::Map<String, serde_json::Value>,
528    ) -> (VulnerabilityInfo, String) {
529        let package_name = advisory_obj
530            .get("module_name")
531            .and_then(|n| n.as_str())
532            .unwrap_or("")
533            .to_string();
534        let id = advisory_id.into();
535        let title = advisory_obj.get("title").and_then(|t| t.as_str()).unwrap_or("Unknown vulnerability").to_string();
536        let description = advisory_obj.get("overview").and_then(|o| o.as_str()).unwrap_or("").to_string();
537        let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
538        let vulnerable_versions = advisory_obj.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("*").to_string();
539        let cve = advisory_obj
540            .get("cves")
541            .and_then(|c| c.as_array())
542            .and_then(|arr| arr.first())
543            .and_then(|v| v.as_str())
544            .map(|s| s.to_string());
545        let url = advisory_obj.get("url").and_then(|u| u.as_str()).map(|s| s.to_string());
546
547        let vuln_info = VulnerabilityInfo {
548            id,
549            vuln_type: "security".to_string(),
550            severity,
551            title,
552            description,
553            cve,
554            ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| u.split('/').last().unwrap_or(&u).to_string()),
555            affected_versions: vulnerable_versions,
556            patched_versions: advisory_obj.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
557            published_date: None,
558            references: url.map(|u| vec![u]).unwrap_or_default(),
559        };
560
561        (vuln_info, package_name)
562    }
563    
564    fn parse_pnpm_audit_output(
565        &self,
566        audit_data: &serde_json::Value,
567        dependencies: &[DependencyInfo],
568    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
569        // PNPM audit output can resemble NPM or provide an advisories map similar to Yarn classic
570        if audit_data.get("vulnerabilities").is_some() {
571            return self.parse_npm_audit_output(audit_data, dependencies);
572        }
573
574        if let Some(advisories) = audit_data.get("advisories").cloned() {
575            // Wrap into Yarn-like shape and reuse Yarn parser
576            let yarn_like = serde_json::json!({
577                "data": { "advisories": advisories }
578            });
579            return self.parse_yarn_audit_output(&yarn_like, dependencies);
580        }
581
582        // Some pnpm versions produce per-advisory arrays; attempt best-effort mapping if present
583        if let Some(findings) = audit_data.get("audit").or_else(|| audit_data.get("metadata")).or_else(|| audit_data.get("data")) {
584            // Try npm parser as a reasonable default
585            if let Ok(res) = self.parse_npm_audit_output(audit_data, dependencies) {
586                if res.is_some() { return Ok(res); }
587            }
588        }
589
590        Ok(None)
591    }
592    
593    fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
594        match severity.map(|s| s.to_lowercase()).as_deref() {
595            Some("critical") => VulnerabilitySeverity::Critical,
596            Some("high") => VulnerabilitySeverity::High,
597            Some("moderate") => VulnerabilitySeverity::Medium,
598            Some("medium") => VulnerabilitySeverity::Medium,
599            Some("low") => VulnerabilitySeverity::Low,
600            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
601        }
602    }
603}
604
605impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
606    fn check_vulnerabilities(
607        &mut self,
608        dependencies: &[DependencyInfo],
609        project_path: &Path,
610    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
611        info!("Checking JavaScript/TypeScript dependencies");
612        
613        let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
614        let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
615
616        info!("Runtime detection: {}", runtime_detector.get_detection_summary());
617
618        // Build execution order: primary detected manager first, then any lockfile-based managers
619        let mut managers = Vec::new();
620        if detection_result.package_manager != crate::analyzer::runtime::PackageManager::Unknown {
621            managers.push(detection_result.package_manager.clone());
622        }
623        for m in runtime_detector.detect_all_package_managers() {
624            if !managers.contains(&m) {
625                managers.push(m);
626            }
627        }
628
629        // Always consider running Bun audit for JS projects if available,
630        // as Bun often surfaces advisories even when other managers don't.
631        if !managers.contains(&crate::analyzer::runtime::PackageManager::Bun)
632            && runtime_detector.is_js_project()
633        {
634            managers.push(crate::analyzer::runtime::PackageManager::Bun);
635        }
636
637        // If still empty but it's a JS project, default to npm as a last resort
638        if managers.is_empty() && runtime_detector.is_js_project() {
639            managers.push(crate::analyzer::runtime::PackageManager::Npm);
640        }
641
642        // Execute audit commands for each selected manager
643        let mut all_vulnerabilities = Vec::new();
644
645        for manager in managers {
646            if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
647                all_vulnerabilities.extend(vulns);
648            }
649        }
650        
651        // Deduplicate vulnerabilities by package name and vulnerability ID
652        let mut deduplicated: Vec<VulnerableDependency> = Vec::new();
653        for vuln_dep in all_vulnerabilities {
654            if let Some(existing) = deduplicated.iter_mut().find(|d| d.name == vuln_dep.name) {
655                // Merge vulnerabilities, avoiding duplicates by ID
656                for new_vuln in vuln_dep.vulnerabilities {
657                    if !existing.vulnerabilities.iter().any(|v| v.id == new_vuln.id) {
658                        existing.vulnerabilities.push(new_vuln);
659                    }
660                }
661            } else {
662                deduplicated.push(vuln_dep);
663            }
664        }
665        
666        Ok(deduplicated)
667    }
668}
669
670// Best-effort tolerant JSON extractor: handles banners/noise by
671// 1) parsing whole buffer, 2) slicing between first '{' and last '}',
672// 3) scanning lines for a valid JSON object.
673fn try_parse_json_tolerant(buf: &[u8]) -> Option<JsonValue> {
674    if let Ok(val) = serde_json::from_slice::<JsonValue>(buf) {
675        return Some(val);
676    }
677    let text = String::from_utf8_lossy(buf);
678    if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
679        if start < end {
680            if let Ok(val) = serde_json::from_str::<JsonValue>(&text[start..=end]) {
681                return Some(val);
682            }
683        }
684    }
685    for line in text.lines() {
686        let line = line.trim();
687        if !line.starts_with('{') || !line.ends_with('}') { continue; }
688        if let Ok(val) = serde_json::from_str::<JsonValue>(line) {
689            return Some(val);
690        }
691    }
692    None
693}