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 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 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 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 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 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 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 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 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 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 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 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 continue;
181 }
182
183 if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
185 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 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 return Ok(res);
205 }
206 }
207
208 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 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 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 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 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 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 let mut package_vulns = Vec::new();
274
275 for vulnerability in vuln_array {
276 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(), severity,
314 title,
315 description,
316 cve: cwe.clone(), 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, published_date: None, 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 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 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 let mut package_vulns = Vec::new();
373
374 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 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(), 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, 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 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 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 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name)
495 {
496 existing.vulnerabilities.push(vuln_info);
497 } else {
498 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 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 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
560 existing.vulnerabilities.push(vuln_info);
561 } else {
562 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 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 let yarn_like = serde_json::json!({
660 "data": { "advisories": advisories }
661 });
662 return self.parse_yarn_audit_output(&yarn_like, dependencies);
663 }
664
665 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, }
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 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 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 managers.is_empty() && runtime_detector.is_js_project() {
729 managers.push(crate::analyzer::runtime::PackageManager::Npm);
730 }
731
732 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 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 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
762fn 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}