Skip to main content

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                            source_dir: None,
344                        });
345                    }
346                }
347            }
348        }
349
350        if vulnerable_deps.is_empty() {
351            Ok(None)
352        } else {
353            Ok(Some(vulnerable_deps))
354        }
355    }
356
357    fn parse_npm_audit_output(
358        &self,
359        audit_data: &serde_json::Value,
360        dependencies: &[DependencyInfo],
361    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
362        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
363
364        // NPM audit JSON structure parsing
365        // NPM returns a JSON object with a "vulnerabilities" field containing package vulnerabilities
366        if let Some(vulnerabilities) = audit_data
367            .get("vulnerabilities")
368            .and_then(|v| v.as_object())
369        {
370            for (package_name, vulnerability_info) in vulnerabilities {
371                // Include all vulnerable packages, not just direct dependencies
372                // Audit tools report on the entire dependency tree
373                let mut package_vulns = Vec::new();
374
375                // Get vulnerability details from the "via" array
376                if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
377                    for advisory in via {
378                        if let Some(advisory_obj) = advisory.as_object() {
379                            // Skip if this is just a reference to another package
380                            if advisory_obj.contains_key("source")
381                                && !advisory_obj.contains_key("title")
382                            {
383                                continue;
384                            }
385
386                            let id = advisory_obj
387                                .get("source")
388                                .and_then(|s| s.as_u64())
389                                .map(|id| id.to_string())
390                                .or_else(|| {
391                                    advisory_obj.get("url").and_then(|u| u.as_str()).and_then(
392                                        |url| {
393                                            if url.contains("GHSA") {
394                                                url.rsplit('/').next().map(|s| s.to_string())
395                                            } else {
396                                                None
397                                            }
398                                        },
399                                    )
400                                })
401                                .unwrap_or("unknown".to_string());
402
403                            let title = advisory_obj
404                                .get("title")
405                                .and_then(|t| t.as_str())
406                                .unwrap_or("Unknown vulnerability")
407                                .to_string();
408                            let description = title.clone();
409                            let severity = self.parse_severity(
410                                advisory_obj.get("severity").and_then(|s| s.as_str()),
411                            );
412
413                            let range = advisory_obj
414                                .get("range")
415                                .and_then(|r| r.as_str())
416                                .unwrap_or("*")
417                                .to_string();
418
419                            let cwe = advisory_obj
420                                .get("cwe")
421                                .and_then(|c| c.as_array())
422                                .and_then(|arr| arr.first())
423                                .and_then(|v| v.as_str())
424                                .map(|s| s.to_string());
425
426                            let url = advisory_obj
427                                .get("url")
428                                .and_then(|u| u.as_str())
429                                .map(|s| s.to_string());
430
431                            let vuln_info = VulnerabilityInfo {
432                                id,
433                                vuln_type: "security".to_string(), // Security vulnerability
434                                severity,
435                                title,
436                                description,
437                                cve: cwe.clone(),
438                                ghsa: url
439                                    .clone()
440                                    .filter(|u| u.contains("GHSA"))
441                                    .map(|u| u.split('/').next_back().unwrap_or(&u).to_string()),
442                                affected_versions: range,
443                                patched_versions: None, // NPM doesn't provide this directly in via
444                                published_date: None,
445                                references: url.map(|u| vec![u]).unwrap_or_default(),
446                            };
447
448                            package_vulns.push(vuln_info);
449                        }
450                    }
451                }
452
453                if !package_vulns.is_empty() {
454                    // Try to find version from direct dependencies, otherwise use "transitive"
455                    let version = dependencies
456                        .iter()
457                        .find(|d| d.name == *package_name)
458                        .map(|d| d.version.clone())
459                        .unwrap_or_else(|| "transitive".to_string());
460
461                    vulnerable_deps.push(VulnerableDependency {
462                        name: package_name.clone(),
463                        version,
464                        language: Language::JavaScript,
465                        vulnerabilities: package_vulns,
466                        source_dir: None,
467                    });
468                }
469            }
470        }
471
472        if vulnerable_deps.is_empty() {
473            Ok(None)
474        } else {
475            Ok(Some(vulnerable_deps))
476        }
477    }
478
479    fn parse_yarn_audit_output(
480        &self,
481        audit_data: &serde_json::Value,
482        dependencies: &[DependencyInfo],
483    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
484        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
485
486        // Yarn audit JSON structure parsing
487        // Shape 1: Single object with { data: { advisories: { id: {...} } } } (rare)
488        if let Some(data) = audit_data.get("data").and_then(|d| d.as_object())
489            && let Some(advisories) = data.get("advisories").and_then(|a| a.as_object())
490        {
491            for (advisory_id, advisory) in advisories {
492                if let Some(advisory_obj) = advisory.as_object() {
493                    let (vuln_info, pkg_name) =
494                        self.extract_yarn_advisory(advisory_id, advisory_obj);
495                    // Include all vulnerable packages, not just direct dependencies
496                    if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name)
497                    {
498                        existing.vulnerabilities.push(vuln_info);
499                    } else {
500                        // Try to find version from direct dependencies, otherwise use "transitive"
501                        let version = dependencies
502                            .iter()
503                            .find(|d| d.name == pkg_name)
504                            .map(|d| d.version.clone())
505                            .unwrap_or_else(|| "transitive".to_string());
506
507                        vulnerable_deps.push(VulnerableDependency {
508                            name: pkg_name,
509                            version,
510                            language: Language::JavaScript,
511                            vulnerabilities: vec![vuln_info],
512                            source_dir: None,
513                        });
514                    }
515                }
516            }
517        }
518
519        if vulnerable_deps.is_empty() {
520            Ok(None)
521        } else {
522            Ok(Some(vulnerable_deps))
523        }
524    }
525
526    // Parse Yarn classic line-delimited JSON output
527    fn parse_yarn_streaming_audit_lines(
528        &self,
529        stdout: &[u8],
530        dependencies: &[DependencyInfo],
531    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
532        let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
533        let text = String::from_utf8_lossy(stdout);
534        for line in text.lines() {
535            let line = line.trim();
536            if line.is_empty() {
537                continue;
538            }
539            if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
540                && json.get("type").and_then(|t| t.as_str()) == Some("auditAdvisory")
541                && let Some(advisory_obj) = json
542                    .get("data")
543                    .and_then(|d| d.get("advisory"))
544                    .and_then(|a| a.as_object())
545            {
546                let _package_name = advisory_obj
547                    .get("module_name")
548                    .and_then(|n| n.as_str())
549                    .unwrap_or("")
550                    .to_string();
551                let (vuln_info, pkg_name) = self.extract_yarn_advisory(
552                    advisory_obj
553                        .get("id")
554                        .and_then(|v| v.as_i64())
555                        .map(|v| v.to_string())
556                        .unwrap_or_else(|| "unknown".to_string())
557                        .as_str(),
558                    advisory_obj,
559                );
560
561                // Include all vulnerable packages, not just direct dependencies
562                if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
563                    existing.vulnerabilities.push(vuln_info);
564                } else {
565                    // Try to find version from direct dependencies, otherwise use "transitive"
566                    let version = dependencies
567                        .iter()
568                        .find(|d| d.name == pkg_name)
569                        .map(|d| d.version.clone())
570                        .unwrap_or_else(|| "transitive".to_string());
571
572                    vulnerable_deps.push(VulnerableDependency {
573                        name: pkg_name,
574                        version,
575                        language: Language::JavaScript,
576                        vulnerabilities: vec![vuln_info],
577                        source_dir: None,
578                    });
579                }
580            }
581        }
582
583        if vulnerable_deps.is_empty() {
584            Ok(None)
585        } else {
586            Ok(Some(vulnerable_deps))
587        }
588    }
589
590    fn extract_yarn_advisory(
591        &self,
592        advisory_id: impl Into<String>,
593        advisory_obj: &serde_json::Map<String, serde_json::Value>,
594    ) -> (VulnerabilityInfo, String) {
595        let package_name = advisory_obj
596            .get("module_name")
597            .and_then(|n| n.as_str())
598            .unwrap_or("")
599            .to_string();
600        let id = advisory_id.into();
601        let title = advisory_obj
602            .get("title")
603            .and_then(|t| t.as_str())
604            .unwrap_or("Unknown vulnerability")
605            .to_string();
606        let description = advisory_obj
607            .get("overview")
608            .and_then(|o| o.as_str())
609            .unwrap_or("")
610            .to_string();
611        let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
612        let vulnerable_versions = advisory_obj
613            .get("vulnerable_versions")
614            .and_then(|v| v.as_str())
615            .unwrap_or("*")
616            .to_string();
617        let cve = advisory_obj
618            .get("cves")
619            .and_then(|c| c.as_array())
620            .and_then(|arr| arr.first())
621            .and_then(|v| v.as_str())
622            .map(|s| s.to_string());
623        let url = advisory_obj
624            .get("url")
625            .and_then(|u| u.as_str())
626            .map(|s| s.to_string());
627
628        let vuln_info = VulnerabilityInfo {
629            id,
630            vuln_type: "security".to_string(),
631            severity,
632            title,
633            description,
634            cve,
635            ghsa: url
636                .clone()
637                .filter(|u| u.contains("GHSA"))
638                .map(|u| u.split('/').next_back().unwrap_or(&u).to_string()),
639            affected_versions: vulnerable_versions,
640            patched_versions: advisory_obj
641                .get("patched_versions")
642                .and_then(|p| p.as_str())
643                .map(|s| s.to_string()),
644            published_date: None,
645            references: url.map(|u| vec![u]).unwrap_or_default(),
646        };
647
648        (vuln_info, package_name)
649    }
650
651    fn parse_pnpm_audit_output(
652        &self,
653        audit_data: &serde_json::Value,
654        dependencies: &[DependencyInfo],
655    ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
656        // PNPM audit output can resemble NPM or provide an advisories map similar to Yarn classic
657        if audit_data.get("vulnerabilities").is_some() {
658            return self.parse_npm_audit_output(audit_data, dependencies);
659        }
660
661        if let Some(advisories) = audit_data.get("advisories").cloned() {
662            // Wrap into Yarn-like shape and reuse Yarn parser
663            let yarn_like = serde_json::json!({
664                "data": { "advisories": advisories }
665            });
666            return self.parse_yarn_audit_output(&yarn_like, dependencies);
667        }
668
669        // Some pnpm versions produce per-advisory arrays; attempt best-effort mapping if present
670        if audit_data
671            .get("audit")
672            .or_else(|| audit_data.get("metadata"))
673            .or_else(|| audit_data.get("data"))
674            .is_some()
675            && let Ok(res) = self.parse_npm_audit_output(audit_data, dependencies)
676            && res.is_some()
677        {
678            return Ok(res);
679        }
680
681        Ok(None)
682    }
683
684    fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
685        match severity.map(|s| s.to_lowercase()).as_deref() {
686            Some("critical") => VulnerabilitySeverity::Critical,
687            Some("high") => VulnerabilitySeverity::High,
688            Some("moderate") => VulnerabilitySeverity::Medium,
689            Some("medium") => VulnerabilitySeverity::Medium,
690            Some("low") => VulnerabilitySeverity::Low,
691            _ => VulnerabilitySeverity::Medium, // Default to medium if not specified
692        }
693    }
694}
695
696impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
697    fn check_vulnerabilities(
698        &mut self,
699        dependencies: &[DependencyInfo],
700        project_path: &Path,
701    ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
702        info!("Checking JavaScript/TypeScript dependencies");
703
704        let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
705        let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
706
707        info!(
708            "Runtime detection: {}",
709            runtime_detector.get_detection_summary()
710        );
711
712        // Build execution order: primary detected manager first, then any lockfile-based managers
713        let mut managers = Vec::new();
714        if detection_result.package_manager != crate::analyzer::runtime::PackageManager::Unknown {
715            managers.push(detection_result.package_manager.clone());
716        }
717        for m in runtime_detector.detect_all_package_managers() {
718            if !managers.contains(&m) {
719                managers.push(m);
720            }
721        }
722
723        // Always consider running Bun audit for JS projects if available,
724        // as Bun often surfaces advisories even when other managers don't.
725        if !managers.contains(&crate::analyzer::runtime::PackageManager::Bun)
726            && runtime_detector.is_js_project()
727        {
728            managers.push(crate::analyzer::runtime::PackageManager::Bun);
729        }
730
731        // If still empty but it's a JS project, default to npm as a last resort
732        if managers.is_empty() && runtime_detector.is_js_project() {
733            managers.push(crate::analyzer::runtime::PackageManager::Npm);
734        }
735
736        // Execute audit commands for each selected manager
737        let mut all_vulnerabilities = Vec::new();
738
739        for manager in managers {
740            if let Some(vulns) =
741                self.execute_audit_for_manager(&manager, project_path, dependencies)?
742            {
743                all_vulnerabilities.extend(vulns);
744            }
745        }
746
747        // Deduplicate vulnerabilities by package name and vulnerability ID
748        let mut deduplicated: Vec<VulnerableDependency> = Vec::new();
749        for vuln_dep in all_vulnerabilities {
750            if let Some(existing) = deduplicated.iter_mut().find(|d| d.name == vuln_dep.name) {
751                // Merge vulnerabilities, avoiding duplicates by ID
752                for new_vuln in vuln_dep.vulnerabilities {
753                    if !existing.vulnerabilities.iter().any(|v| v.id == new_vuln.id) {
754                        existing.vulnerabilities.push(new_vuln);
755                    }
756                }
757            } else {
758                deduplicated.push(vuln_dep);
759            }
760        }
761
762        Ok(deduplicated)
763    }
764}
765
766// Best-effort tolerant JSON extractor: handles banners/noise by
767// 1) parsing whole buffer, 2) slicing between first '{' and last '}',
768// 3) scanning lines for a valid JSON object.
769fn try_parse_json_tolerant(buf: &[u8]) -> Option<JsonValue> {
770    if let Ok(val) = serde_json::from_slice::<JsonValue>(buf) {
771        return Some(val);
772    }
773    let text = String::from_utf8_lossy(buf);
774    if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}'))
775        && start < end
776        && let Ok(val) = serde_json::from_str::<JsonValue>(&text[start..=end])
777    {
778        return Some(val);
779    }
780    for line in text.lines() {
781        let line = line.trim();
782        if !line.starts_with('{') || !line.ends_with('}') {
783            continue;
784        }
785        if let Ok(val) = serde_json::from_str::<JsonValue>(line) {
786            return Some(val);
787        }
788    }
789    None
790}