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 if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
259 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 vulnerable_deps.push(VulnerableDependency {
301 name: dep.name.clone(),
302 version: dep.version.clone(),
303 language: Language::JavaScript,
304 vulnerabilities: package_vulns,
305 });
306 }
307 }
308 }
309 }
310 }
311
312 if vulnerable_deps.is_empty() {
313 Ok(None)
314 } else {
315 Ok(Some(vulnerable_deps))
316 }
317 }
318
319 fn parse_npm_audit_output(
320 &self,
321 audit_data: &serde_json::Value,
322 dependencies: &[DependencyInfo],
323 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
324 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
325
326 if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
329 for (package_name, vulnerability_info) in vulnerabilities {
330 if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
332 let mut package_vulns = Vec::new();
333
334 if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
336 for advisory in via {
337 if let Some(advisory_obj) = advisory.as_object() {
338 if advisory_obj.contains_key("source") && !advisory_obj.contains_key("title") {
340 continue;
341 }
342
343 let id = advisory_obj.get("source")
344 .and_then(|s| s.as_u64())
345 .map(|id| id.to_string())
346 .or_else(|| advisory_obj.get("url")
347 .and_then(|u| u.as_str())
348 .and_then(|url| {
349 if url.contains("GHSA") {
350 url.split('/').last().map(|s| s.to_string())
351 } else {
352 None
353 }
354 }))
355 .unwrap_or("unknown".to_string());
356
357 let title = advisory_obj.get("title").and_then(|t| t.as_str())
358 .unwrap_or("Unknown vulnerability").to_string();
359 let description = title.clone();
360 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
361
362 let range = advisory_obj.get("range").and_then(|r| r.as_str())
363 .unwrap_or("*").to_string();
364
365 let cwe = advisory_obj.get("cwe").and_then(|c| c.as_array())
366 .and_then(|arr| arr.first())
367 .and_then(|v| v.as_str())
368 .map(|s| s.to_string());
369
370 let url = advisory_obj.get("url").and_then(|u| u.as_str())
371 .map(|s| s.to_string());
372
373 let vuln_info = VulnerabilityInfo {
374 id,
375 vuln_type: "security".to_string(), severity,
377 title,
378 description,
379 cve: cwe.clone(),
380 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
381 u.split('/').last().unwrap_or(&u).to_string()
382 }),
383 affected_versions: range,
384 patched_versions: None, published_date: None,
386 references: url.map(|u| vec![u]).unwrap_or_default(),
387 };
388
389 package_vulns.push(vuln_info);
390 }
391 }
392 }
393
394 if !package_vulns.is_empty() {
395 vulnerable_deps.push(VulnerableDependency {
396 name: dep.name.clone(),
397 version: dep.version.clone(),
398 language: Language::JavaScript,
399 vulnerabilities: package_vulns,
400 });
401 }
402 }
403 }
404 }
405
406 if vulnerable_deps.is_empty() {
407 Ok(None)
408 } else {
409 Ok(Some(vulnerable_deps))
410 }
411 }
412
413 fn parse_yarn_audit_output(
414 &self,
415 audit_data: &serde_json::Value,
416 dependencies: &[DependencyInfo],
417 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
418 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
419
420 if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
423 if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
424 for (advisory_id, advisory) in advisories {
425 if let Some(advisory_obj) = advisory.as_object() {
426 let package_name = advisory_obj.get("module_name").and_then(|n| n.as_str()).unwrap_or("").to_string();
427 if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
428 let (vuln_info, pkg_name) = self.extract_yarn_advisory(advisory_id, advisory_obj);
429 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
431 existing.vulnerabilities.push(vuln_info);
432 } else {
433 vulnerable_deps.push(VulnerableDependency {
434 name: dep.name.clone(),
435 version: dep.version.clone(),
436 language: Language::JavaScript,
437 vulnerabilities: vec![vuln_info],
438 });
439 }
440 }
441 }
442 }
443 }
444 }
445
446 if vulnerable_deps.is_empty() {
447 Ok(None)
448 } else {
449 Ok(Some(vulnerable_deps))
450 }
451 }
452
453 fn parse_yarn_streaming_audit_lines(
455 &self,
456 stdout: &[u8],
457 dependencies: &[DependencyInfo],
458 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
459 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
460 let text = String::from_utf8_lossy(stdout);
461 for line in text.lines() {
462 let line = line.trim();
463 if line.is_empty() { continue; }
464 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
465 if json.get("type").and_then(|t| t.as_str()) == Some("auditAdvisory") {
466 if let Some(advisory_obj) = json
467 .get("data")
468 .and_then(|d| d.get("advisory"))
469 .and_then(|a| a.as_object())
470 {
471 let package_name = advisory_obj
472 .get("module_name")
473 .and_then(|n| n.as_str())
474 .unwrap_or("")
475 .to_string();
476 if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
477 let (vuln_info, pkg_name) = self.extract_yarn_advisory(
478 advisory_obj
479 .get("id")
480 .and_then(|v| v.as_i64())
481 .map(|v| v.to_string())
482 .unwrap_or_else(|| "unknown".to_string())
483 .as_str(),
484 advisory_obj,
485 );
486
487 if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
488 existing.vulnerabilities.push(vuln_info);
489 } else {
490 vulnerable_deps.push(VulnerableDependency {
491 name: dep.name.clone(),
492 version: dep.version.clone(),
493 language: Language::JavaScript,
494 vulnerabilities: vec![vuln_info],
495 });
496 }
497 }
498 }
499 }
500 }
501 }
502
503 if vulnerable_deps.is_empty() { Ok(None) } else { Ok(Some(vulnerable_deps)) }
504 }
505
506 fn extract_yarn_advisory<'a>(
507 &self,
508 advisory_id: impl Into<String>,
509 advisory_obj: &serde_json::Map<String, serde_json::Value>,
510 ) -> (VulnerabilityInfo, String) {
511 let package_name = advisory_obj
512 .get("module_name")
513 .and_then(|n| n.as_str())
514 .unwrap_or("")
515 .to_string();
516 let id = advisory_id.into();
517 let title = advisory_obj.get("title").and_then(|t| t.as_str()).unwrap_or("Unknown vulnerability").to_string();
518 let description = advisory_obj.get("overview").and_then(|o| o.as_str()).unwrap_or("").to_string();
519 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
520 let vulnerable_versions = advisory_obj.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("*").to_string();
521 let cve = advisory_obj
522 .get("cves")
523 .and_then(|c| c.as_array())
524 .and_then(|arr| arr.first())
525 .and_then(|v| v.as_str())
526 .map(|s| s.to_string());
527 let url = advisory_obj.get("url").and_then(|u| u.as_str()).map(|s| s.to_string());
528
529 let vuln_info = VulnerabilityInfo {
530 id,
531 vuln_type: "security".to_string(),
532 severity,
533 title,
534 description,
535 cve,
536 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| u.split('/').last().unwrap_or(&u).to_string()),
537 affected_versions: vulnerable_versions,
538 patched_versions: advisory_obj.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
539 published_date: None,
540 references: url.map(|u| vec![u]).unwrap_or_default(),
541 };
542
543 (vuln_info, package_name)
544 }
545
546 fn parse_pnpm_audit_output(
547 &self,
548 audit_data: &serde_json::Value,
549 dependencies: &[DependencyInfo],
550 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
551 if audit_data.get("vulnerabilities").is_some() {
553 return self.parse_npm_audit_output(audit_data, dependencies);
554 }
555
556 if let Some(advisories) = audit_data.get("advisories").cloned() {
557 let yarn_like = serde_json::json!({
559 "data": { "advisories": advisories }
560 });
561 return self.parse_yarn_audit_output(&yarn_like, dependencies);
562 }
563
564 if let Some(findings) = audit_data.get("audit").or_else(|| audit_data.get("metadata")).or_else(|| audit_data.get("data")) {
566 if let Ok(res) = self.parse_npm_audit_output(audit_data, dependencies) {
568 if res.is_some() { return Ok(res); }
569 }
570 }
571
572 Ok(None)
573 }
574
575 fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
576 match severity.map(|s| s.to_lowercase()).as_deref() {
577 Some("critical") => VulnerabilitySeverity::Critical,
578 Some("high") => VulnerabilitySeverity::High,
579 Some("moderate") => VulnerabilitySeverity::Medium,
580 Some("medium") => VulnerabilitySeverity::Medium,
581 Some("low") => VulnerabilitySeverity::Low,
582 _ => VulnerabilitySeverity::Medium, }
584 }
585}
586
587impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
588 fn check_vulnerabilities(
589 &mut self,
590 dependencies: &[DependencyInfo],
591 project_path: &Path,
592 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
593 info!("Checking JavaScript/TypeScript dependencies");
594
595 let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
596 let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
597
598 info!("Runtime detection: {}", runtime_detector.get_detection_summary());
599
600 let mut managers = Vec::new();
602 if detection_result.package_manager != crate::analyzer::runtime::PackageManager::Unknown {
603 managers.push(detection_result.package_manager.clone());
604 }
605 for m in runtime_detector.detect_all_package_managers() {
606 if !managers.contains(&m) {
607 managers.push(m);
608 }
609 }
610
611 if !managers.contains(&crate::analyzer::runtime::PackageManager::Bun)
614 && runtime_detector.is_js_project()
615 {
616 managers.push(crate::analyzer::runtime::PackageManager::Bun);
617 }
618
619 if managers.is_empty() && runtime_detector.is_js_project() {
621 managers.push(crate::analyzer::runtime::PackageManager::Npm);
622 }
623
624 let mut all_vulnerabilities = Vec::new();
626
627 for manager in managers {
628 if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
629 all_vulnerabilities.extend(vulns);
630 }
631 }
632
633 Ok(all_vulnerabilities)
634 }
635}
636
637fn try_parse_json_tolerant(buf: &[u8]) -> Option<JsonValue> {
641 if let Ok(val) = serde_json::from_slice::<JsonValue>(buf) {
642 return Some(val);
643 }
644 let text = String::from_utf8_lossy(buf);
645 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
646 if start < end {
647 if let Ok(val) = serde_json::from_str::<JsonValue>(&text[start..=end]) {
648 return Some(val);
649 }
650 }
651 }
652 for line in text.lines() {
653 let line = line.trim();
654 if !line.starts_with('{') || !line.ends_with('}') { continue; }
655 if let Ok(val) = serde_json::from_str::<JsonValue>(line) {
656 return Some(val);
657 }
658 }
659 None
660}