syncable_cli/analyzer/vulnerability/checkers/
javascript.rs

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