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 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 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 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 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 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 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 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 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 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 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 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 continue;
175 }
176
177 if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
179 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 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 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 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 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 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 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 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 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 let mut package_vulns = Vec::new();
271
272 for vulnerability in vuln_array {
273 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(), severity,
311 title,
312 description,
313 cve: cwe.clone(), 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, published_date: None, 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 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 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 let mut package_vulns = Vec::new();
370
371 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 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(), 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, 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 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 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 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 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 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 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 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 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 let yarn_like = serde_json::json!({
662 "data": { "advisories": advisories }
663 });
664 return self.parse_yarn_audit_output(&yarn_like, dependencies);
665 }
666
667 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 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, }
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 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}