1use std::path::Path;
2use std::process::Command;
3use log::{info, warn};
4use crate::analyzer::dependency_parser::{DependencyInfo, Language};
5use crate::analyzer::runtime::{RuntimeDetector, PackageManager};
6use crate::analyzer::tool_management::ToolDetector;
7use crate::analyzer::vulnerability::{VulnerableDependency, VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity};
8use super::MutableLanguageVulnerabilityChecker;
9use serde_json::Value as JsonValue;
10
11pub struct JavaScriptVulnerabilityChecker {
12 tool_detector: ToolDetector,
13}
14
15impl JavaScriptVulnerabilityChecker {
16 pub fn new() -> Self {
17 Self {
18 tool_detector: ToolDetector::new(),
19 }
20 }
21
22 fn execute_audit_for_manager(
23 &mut self,
24 manager: &PackageManager,
25 project_path: &Path,
26 dependencies: &[DependencyInfo],
27 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
28 match manager {
29 PackageManager::Bun => self.execute_bun_audit(project_path, dependencies),
30 PackageManager::Npm => self.execute_npm_audit(project_path, dependencies),
31 PackageManager::Yarn => self.execute_yarn_audit(project_path, dependencies),
32 PackageManager::Pnpm => self.execute_pnpm_audit(project_path, dependencies),
33 PackageManager::Unknown => Ok(None),
34 }
35 }
36
37 fn execute_bun_audit(
38 &mut self,
39 project_path: &Path,
40 dependencies: &[DependencyInfo],
41 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
42 let bun_status = self.tool_detector.detect_tool("bun");
44 if !bun_status.available {
45 warn!("bun not found, skipping bun audit");
46 return Ok(None);
47 }
48
49 info!("Executing bun audit in {}", project_path.display());
50
51 let output = Command::new("bun")
53 .args(&["audit", "--json"])
54 .current_dir(project_path)
55 .output()
56 .map_err(|e| VulnerabilityError::CommandError(
57 format!("Failed to run bun audit: {}", e)
58 ))?;
59
60 if !output.status.success() && !output.stdout.is_empty() {
63 info!("bun audit completed with findings");
64 }
65
66 if output.stdout.is_empty() {
67 return Ok(None);
68 }
69
70 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
72 .map_err(|e| VulnerabilityError::ParseError(
73 format!("Failed to parse bun audit output: {}", e)
74 ))?;
75
76 self.parse_bun_audit_output(&audit_data, dependencies)
77 }
78
79 fn execute_npm_audit(
80 &mut self,
81 project_path: &Path,
82 dependencies: &[DependencyInfo],
83 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
84 let npm_status = self.tool_detector.detect_tool("npm");
86 if !npm_status.available {
87 warn!("npm not found, skipping npm audit");
88 return Ok(None);
89 }
90
91 info!("Executing npm audit in {}", project_path.display());
92
93 let output = Command::new("npm")
95 .args(&["audit", "--json"])
96 .current_dir(project_path)
97 .output()
98 .map_err(|e| VulnerabilityError::CommandError(
99 format!("Failed to run npm audit: {}", e)
100 ))?;
101
102 if !output.status.success() && output.stdout.is_empty() {
105 return Err(VulnerabilityError::CommandError(
106 format!("npm audit failed with exit code {}: {}",
107 output.status.code().unwrap_or(-1),
108 String::from_utf8_lossy(&output.stderr))
109 ));
110 }
111
112 if output.stdout.is_empty() {
113 return Ok(None);
114 }
115
116 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
118 .map_err(|e| VulnerabilityError::ParseError(
119 format!("Failed to parse npm audit output: {}", e)
120 ))?;
121
122 self.parse_npm_audit_output(&audit_data, dependencies)
123 }
124
125 fn execute_yarn_audit(
126 &mut self,
127 project_path: &Path,
128 dependencies: &[DependencyInfo],
129 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
130 let yarn_status = self.tool_detector.detect_tool("yarn");
132 if !yarn_status.available {
133 warn!("yarn not found, skipping yarn audit");
134 return Ok(None);
135 }
136
137 info!("Executing yarn audit in {}", project_path.display());
138
139 let candidates: Vec<Vec<&str>> = vec![
144 vec!["npm", "audit", "--json"],
145 vec!["audit", "--json"],
146 ];
147
148 for args in candidates {
149 let output = match Command::new("yarn").args(&args).current_dir(project_path).output() {
150 Ok(o) => o,
151 Err(e) => {
152 warn!("Failed to run 'yarn {}': {}", args.join(" "), e);
153 continue;
154 }
155 };
156
157 if !output.status.success() && output.stdout.is_empty() {
159 warn!(
160 "yarn {} failed (code {:?}): {}",
161 args.join(" "),
162 output.status.code(),
163 String::from_utf8_lossy(&output.stderr)
164 );
165 continue;
166 }
167
168 if output.stdout.is_empty() {
169 continue;
171 }
172
173 if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
175 if audit_data.get("vulnerabilities").is_some() {
177 if let Ok(res) = self.parse_npm_audit_output(&audit_data, dependencies) {
178 if res.is_some() { return Ok(res); }
179 }
180 }
181
182 if let Ok(res) = self.parse_yarn_audit_output(&audit_data, dependencies) {
184 if res.is_some() { return Ok(res); }
185 }
186 } else {
187 if let Ok(res) = self.parse_yarn_streaming_audit_lines(&output.stdout, dependencies) {
189 if res.is_some() { return Ok(res); }
190 }
191 }
192 }
193
194 warn!("Unable to parse yarn audit output; skipping Yarn results");
196 Ok(None)
197 }
198
199 fn execute_pnpm_audit(
200 &mut self,
201 project_path: &Path,
202 dependencies: &[DependencyInfo],
203 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
204 let pnpm_status = self.tool_detector.detect_tool("pnpm");
206 if !pnpm_status.available {
207 warn!("pnpm not found, skipping pnpm audit");
208 return Ok(None);
209 }
210
211 info!("Executing pnpm audit in {}", project_path.display());
212
213 let output = Command::new("pnpm")
215 .args(&["audit", "--json"])
216 .current_dir(project_path)
217 .output()
218 .map_err(|e| VulnerabilityError::CommandError(
219 format!("Failed to run pnpm audit: {}", e)
220 ))?;
221
222 if !output.status.success() && output.stdout.is_empty() {
225 return Err(VulnerabilityError::CommandError(
226 format!("pnpm audit failed with exit code {}: {}",
227 output.status.code().unwrap_or(-1),
228 String::from_utf8_lossy(&output.stderr))
229 ));
230 }
231
232 if output.stdout.is_empty() {
233 return Ok(None);
234 }
235
236 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
238 .map_err(|e| VulnerabilityError::ParseError(
239 format!("Failed to parse pnpm audit output: {}", e)
240 ))?;
241
242 self.parse_pnpm_audit_output(&audit_data, dependencies)
243 }
244
245 fn parse_bun_audit_output(
246 &self,
247 audit_data: &serde_json::Value,
248 dependencies: &[DependencyInfo],
249 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
250 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
251
252 if let Some(obj) = audit_data.as_object() {
255 for (package_name, vulnerabilities) in obj {
256 if let Some(vuln_array) = vulnerabilities.as_array() {
257 let mut package_vulns = Vec::new();
260
261 for vulnerability in vuln_array {
262 let id = vulnerability.get("id").and_then(|i| i.as_u64())
264 .map(|id| id.to_string())
265 .unwrap_or("unknown".to_string());
266 let title = vulnerability.get("title").and_then(|t| t.as_str())
267 .unwrap_or("Unknown vulnerability").to_string();
268 let description = vulnerability.get("title").and_then(|t| t.as_str())
269 .unwrap_or("").to_string();
270 let severity = self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str()));
271 let affected_versions = vulnerability.get("vulnerable_versions").and_then(|v| v.as_str())
272 .unwrap_or("*").to_string();
273 let cwe = vulnerability.get("cwe").and_then(|c| c.as_array())
274 .and_then(|arr| arr.first())
275 .and_then(|v| v.as_str())
276 .map(|s| s.to_string());
277 let url = vulnerability.get("url").and_then(|u| u.as_str())
278 .map(|s| s.to_string());
279
280 let vuln_info = VulnerabilityInfo {
281 id,
282 vuln_type: "security".to_string(), severity,
284 title,
285 description,
286 cve: cwe.clone(), ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
288 u.split('/').last().unwrap_or(&u).to_string()
289 }),
290 affected_versions,
291 patched_versions: None, published_date: None, references: url.map(|u| vec![u]).unwrap_or_default(),
294 };
295
296 package_vulns.push(vuln_info);
297 }
298
299 if !package_vulns.is_empty() {
300 let version = dependencies.iter()
302 .find(|d| d.name == *package_name)
303 .map(|d| d.version.clone())
304 .unwrap_or_else(|| "transitive".to_string());
305
306 vulnerable_deps.push(VulnerableDependency {
307 name: package_name.clone(),
308 version,
309 language: Language::JavaScript,
310 vulnerabilities: package_vulns,
311 });
312 }
313 }
314 }
315 }
316
317 if vulnerable_deps.is_empty() {
318 Ok(None)
319 } else {
320 Ok(Some(vulnerable_deps))
321 }
322 }
323
324 fn parse_npm_audit_output(
325 &self,
326 audit_data: &serde_json::Value,
327 dependencies: &[DependencyInfo],
328 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
329 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
330
331 if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
334 for (package_name, vulnerability_info) in vulnerabilities {
335 let mut package_vulns = Vec::new();
338
339 if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
341 for advisory in via {
342 if let Some(advisory_obj) = advisory.as_object() {
343 if advisory_obj.contains_key("source") && !advisory_obj.contains_key("title") {
345 continue;
346 }
347
348 let id = advisory_obj.get("source")
349 .and_then(|s| s.as_u64())
350 .map(|id| id.to_string())
351 .or_else(|| advisory_obj.get("url")
352 .and_then(|u| u.as_str())
353 .and_then(|url| {
354 if url.contains("GHSA") {
355 url.split('/').last().map(|s| s.to_string())
356 } else {
357 None
358 }
359 }))
360 .unwrap_or("unknown".to_string());
361
362 let title = advisory_obj.get("title").and_then(|t| t.as_str())
363 .unwrap_or("Unknown vulnerability").to_string();
364 let description = title.clone();
365 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
366
367 let range = advisory_obj.get("range").and_then(|r| r.as_str())
368 .unwrap_or("*").to_string();
369
370 let cwe = advisory_obj.get("cwe").and_then(|c| c.as_array())
371 .and_then(|arr| arr.first())
372 .and_then(|v| v.as_str())
373 .map(|s| s.to_string());
374
375 let url = advisory_obj.get("url").and_then(|u| u.as_str())
376 .map(|s| s.to_string());
377
378 let vuln_info = VulnerabilityInfo {
379 id,
380 vuln_type: "security".to_string(), severity,
382 title,
383 description,
384 cve: cwe.clone(),
385 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
386 u.split('/').last().unwrap_or(&u).to_string()
387 }),
388 affected_versions: range,
389 patched_versions: None, published_date: None,
391 references: url.map(|u| vec![u]).unwrap_or_default(),
392 };
393
394 package_vulns.push(vuln_info);
395 }
396 }
397 }
398
399 if !package_vulns.is_empty() {
400 let version = dependencies.iter()
402 .find(|d| d.name == *package_name)
403 .map(|d| d.version.clone())
404 .unwrap_or_else(|| "transitive".to_string());
405
406 vulnerable_deps.push(VulnerableDependency {
407 name: package_name.clone(),
408 version,
409 language: Language::JavaScript,
410 vulnerabilities: package_vulns,
411 });
412 }
413 }
414 }
415
416 if vulnerable_deps.is_empty() {
417 Ok(None)
418 } else {
419 Ok(Some(vulnerable_deps))
420 }
421 }
422
423 fn parse_yarn_audit_output(
424 &self,
425 audit_data: &serde_json::Value,
426 dependencies: &[DependencyInfo],
427 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
428 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
429
430 if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
433 if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
434 for (advisory_id, advisory) in advisories {
435 if let Some(advisory_obj) = advisory.as_object() {
436 let (vuln_info, pkg_name) = self.extract_yarn_advisory(advisory_id, advisory_obj);
437 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
439 existing.vulnerabilities.push(vuln_info);
440 } else {
441 let version = dependencies.iter()
443 .find(|d| d.name == pkg_name)
444 .map(|d| d.version.clone())
445 .unwrap_or_else(|| "transitive".to_string());
446
447 vulnerable_deps.push(VulnerableDependency {
448 name: pkg_name,
449 version,
450 language: Language::JavaScript,
451 vulnerabilities: vec![vuln_info],
452 });
453 }
454 }
455 }
456 }
457 }
458
459 if vulnerable_deps.is_empty() {
460 Ok(None)
461 } else {
462 Ok(Some(vulnerable_deps))
463 }
464 }
465
466 fn parse_yarn_streaming_audit_lines(
468 &self,
469 stdout: &[u8],
470 dependencies: &[DependencyInfo],
471 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
472 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
473 let text = String::from_utf8_lossy(stdout);
474 for line in text.lines() {
475 let line = line.trim();
476 if line.is_empty() { continue; }
477 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
478 if json.get("type").and_then(|t| t.as_str()) == Some("auditAdvisory") {
479 if let Some(advisory_obj) = json
480 .get("data")
481 .and_then(|d| d.get("advisory"))
482 .and_then(|a| a.as_object())
483 {
484 let package_name = advisory_obj
485 .get("module_name")
486 .and_then(|n| n.as_str())
487 .unwrap_or("")
488 .to_string();
489 let (vuln_info, pkg_name) = self.extract_yarn_advisory(
490 advisory_obj
491 .get("id")
492 .and_then(|v| v.as_i64())
493 .map(|v| v.to_string())
494 .unwrap_or_else(|| "unknown".to_string())
495 .as_str(),
496 advisory_obj,
497 );
498
499 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
501 existing.vulnerabilities.push(vuln_info);
502 } else {
503 let version = dependencies.iter()
505 .find(|d| d.name == pkg_name)
506 .map(|d| d.version.clone())
507 .unwrap_or_else(|| "transitive".to_string());
508
509 vulnerable_deps.push(VulnerableDependency {
510 name: pkg_name,
511 version,
512 language: Language::JavaScript,
513 vulnerabilities: vec![vuln_info],
514 });
515 }
516 }
517 }
518 }
519 }
520
521 if vulnerable_deps.is_empty() { Ok(None) } else { Ok(Some(vulnerable_deps)) }
522 }
523
524 fn extract_yarn_advisory<'a>(
525 &self,
526 advisory_id: impl Into<String>,
527 advisory_obj: &serde_json::Map<String, serde_json::Value>,
528 ) -> (VulnerabilityInfo, String) {
529 let package_name = advisory_obj
530 .get("module_name")
531 .and_then(|n| n.as_str())
532 .unwrap_or("")
533 .to_string();
534 let id = advisory_id.into();
535 let title = advisory_obj.get("title").and_then(|t| t.as_str()).unwrap_or("Unknown vulnerability").to_string();
536 let description = advisory_obj.get("overview").and_then(|o| o.as_str()).unwrap_or("").to_string();
537 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
538 let vulnerable_versions = advisory_obj.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("*").to_string();
539 let cve = advisory_obj
540 .get("cves")
541 .and_then(|c| c.as_array())
542 .and_then(|arr| arr.first())
543 .and_then(|v| v.as_str())
544 .map(|s| s.to_string());
545 let url = advisory_obj.get("url").and_then(|u| u.as_str()).map(|s| s.to_string());
546
547 let vuln_info = VulnerabilityInfo {
548 id,
549 vuln_type: "security".to_string(),
550 severity,
551 title,
552 description,
553 cve,
554 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| u.split('/').last().unwrap_or(&u).to_string()),
555 affected_versions: vulnerable_versions,
556 patched_versions: advisory_obj.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
557 published_date: None,
558 references: url.map(|u| vec![u]).unwrap_or_default(),
559 };
560
561 (vuln_info, package_name)
562 }
563
564 fn parse_pnpm_audit_output(
565 &self,
566 audit_data: &serde_json::Value,
567 dependencies: &[DependencyInfo],
568 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
569 if audit_data.get("vulnerabilities").is_some() {
571 return self.parse_npm_audit_output(audit_data, dependencies);
572 }
573
574 if let Some(advisories) = audit_data.get("advisories").cloned() {
575 let yarn_like = serde_json::json!({
577 "data": { "advisories": advisories }
578 });
579 return self.parse_yarn_audit_output(&yarn_like, dependencies);
580 }
581
582 if let Some(findings) = audit_data.get("audit").or_else(|| audit_data.get("metadata")).or_else(|| audit_data.get("data")) {
584 if let Ok(res) = self.parse_npm_audit_output(audit_data, dependencies) {
586 if res.is_some() { return Ok(res); }
587 }
588 }
589
590 Ok(None)
591 }
592
593 fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
594 match severity.map(|s| s.to_lowercase()).as_deref() {
595 Some("critical") => VulnerabilitySeverity::Critical,
596 Some("high") => VulnerabilitySeverity::High,
597 Some("moderate") => VulnerabilitySeverity::Medium,
598 Some("medium") => VulnerabilitySeverity::Medium,
599 Some("low") => VulnerabilitySeverity::Low,
600 _ => VulnerabilitySeverity::Medium, }
602 }
603}
604
605impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
606 fn check_vulnerabilities(
607 &mut self,
608 dependencies: &[DependencyInfo],
609 project_path: &Path,
610 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
611 info!("Checking JavaScript/TypeScript dependencies");
612
613 let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
614 let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
615
616 info!("Runtime detection: {}", runtime_detector.get_detection_summary());
617
618 let mut managers = Vec::new();
620 if detection_result.package_manager != crate::analyzer::runtime::PackageManager::Unknown {
621 managers.push(detection_result.package_manager.clone());
622 }
623 for m in runtime_detector.detect_all_package_managers() {
624 if !managers.contains(&m) {
625 managers.push(m);
626 }
627 }
628
629 if !managers.contains(&crate::analyzer::runtime::PackageManager::Bun)
632 && runtime_detector.is_js_project()
633 {
634 managers.push(crate::analyzer::runtime::PackageManager::Bun);
635 }
636
637 if managers.is_empty() && runtime_detector.is_js_project() {
639 managers.push(crate::analyzer::runtime::PackageManager::Npm);
640 }
641
642 let mut all_vulnerabilities = Vec::new();
644
645 for manager in managers {
646 if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
647 all_vulnerabilities.extend(vulns);
648 }
649 }
650
651 let mut deduplicated: Vec<VulnerableDependency> = Vec::new();
653 for vuln_dep in all_vulnerabilities {
654 if let Some(existing) = deduplicated.iter_mut().find(|d| d.name == vuln_dep.name) {
655 for new_vuln in vuln_dep.vulnerabilities {
657 if !existing.vulnerabilities.iter().any(|v| v.id == new_vuln.id) {
658 existing.vulnerabilities.push(new_vuln);
659 }
660 }
661 } else {
662 deduplicated.push(vuln_dep);
663 }
664 }
665
666 Ok(deduplicated)
667 }
668}
669
670fn try_parse_json_tolerant(buf: &[u8]) -> Option<JsonValue> {
674 if let Ok(val) = serde_json::from_slice::<JsonValue>(buf) {
675 return Some(val);
676 }
677 let text = String::from_utf8_lossy(buf);
678 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
679 if start < end {
680 if let Ok(val) = serde_json::from_str::<JsonValue>(&text[start..=end]) {
681 return Some(val);
682 }
683 }
684 }
685 for line in text.lines() {
686 let line = line.trim();
687 if !line.starts_with('{') || !line.ends_with('}') { continue; }
688 if let Ok(val) = serde_json::from_str::<JsonValue>(line) {
689 return Some(val);
690 }
691 }
692 None
693}