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