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 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 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 let mut package_vulns = Vec::new();
374
375 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 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(), 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, 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 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 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 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name)
497 {
498 existing.vulnerabilities.push(vuln_info);
499 } else {
500 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 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 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
563 existing.vulnerabilities.push(vuln_info);
564 } else {
565 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 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 let yarn_like = serde_json::json!({
664 "data": { "advisories": advisories }
665 });
666 return self.parse_yarn_audit_output(&yarn_like, dependencies);
667 }
668
669 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, }
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 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 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 managers.is_empty() && runtime_detector.is_js_project() {
733 managers.push(crate::analyzer::runtime::PackageManager::Npm);
734 }
735
736 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 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 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
766fn 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}