syncable_cli/analyzer/vulnerability/checkers/
javascript.rs1use 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;
9
10pub struct JavaScriptVulnerabilityChecker {
11 tool_detector: ToolDetector,
12}
13
14impl JavaScriptVulnerabilityChecker {
15 pub fn new() -> Self {
16 Self {
17 tool_detector: ToolDetector::new(),
18 }
19 }
20
21 fn execute_audit_for_manager(
22 &mut self,
23 manager: &PackageManager,
24 project_path: &Path,
25 dependencies: &[DependencyInfo],
26 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
27 match manager {
28 PackageManager::Bun => self.execute_bun_audit(project_path, dependencies),
29 PackageManager::Npm => self.execute_npm_audit(project_path, dependencies),
30 PackageManager::Yarn => self.execute_yarn_audit(project_path, dependencies),
31 PackageManager::Pnpm => self.execute_pnpm_audit(project_path, dependencies),
32 PackageManager::Unknown => Ok(None),
33 }
34 }
35
36 fn execute_bun_audit(
37 &mut self,
38 project_path: &Path,
39 dependencies: &[DependencyInfo],
40 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
41 let bun_status = self.tool_detector.detect_tool("bun");
43 if !bun_status.available {
44 warn!("bun not found, skipping bun audit");
45 return Ok(None);
46 }
47
48 info!("Executing bun audit in {}", project_path.display());
49
50 let output = Command::new("bun")
52 .args(&["audit", "--json"])
53 .current_dir(project_path)
54 .output()
55 .map_err(|e| VulnerabilityError::CommandError(
56 format!("Failed to run bun audit: {}", e)
57 ))?;
58
59 if !output.status.success() && !output.stdout.is_empty() {
62 info!("bun audit completed with findings");
63 }
64
65 if output.stdout.is_empty() {
66 return Ok(None);
67 }
68
69 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
71 .map_err(|e| VulnerabilityError::ParseError(
72 format!("Failed to parse bun audit output: {}", e)
73 ))?;
74
75 self.parse_bun_audit_output(&audit_data, dependencies)
76 }
77
78 fn execute_npm_audit(
79 &mut self,
80 project_path: &Path,
81 dependencies: &[DependencyInfo],
82 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
83 let npm_status = self.tool_detector.detect_tool("npm");
85 if !npm_status.available {
86 warn!("npm not found, skipping npm audit");
87 return Ok(None);
88 }
89
90 info!("Executing npm audit in {}", project_path.display());
91
92 let output = Command::new("npm")
94 .args(&["audit", "--json"])
95 .current_dir(project_path)
96 .output()
97 .map_err(|e| VulnerabilityError::CommandError(
98 format!("Failed to run npm audit: {}", e)
99 ))?;
100
101 if !output.status.success() && output.stdout.is_empty() {
104 return Err(VulnerabilityError::CommandError(
105 format!("npm audit failed with exit code {}: {}",
106 output.status.code().unwrap_or(-1),
107 String::from_utf8_lossy(&output.stderr))
108 ));
109 }
110
111 if output.stdout.is_empty() {
112 return Ok(None);
113 }
114
115 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
117 .map_err(|e| VulnerabilityError::ParseError(
118 format!("Failed to parse npm audit output: {}", e)
119 ))?;
120
121 self.parse_npm_audit_output(&audit_data, dependencies)
122 }
123
124 fn execute_yarn_audit(
125 &mut self,
126 project_path: &Path,
127 dependencies: &[DependencyInfo],
128 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
129 let yarn_status = self.tool_detector.detect_tool("yarn");
131 if !yarn_status.available {
132 warn!("yarn not found, skipping yarn audit");
133 return Ok(None);
134 }
135
136 info!("Executing yarn audit in {}", project_path.display());
137
138 let output = Command::new("yarn")
140 .args(&["audit", "--json"])
141 .current_dir(project_path)
142 .output()
143 .map_err(|e| VulnerabilityError::CommandError(
144 format!("Failed to run yarn audit: {}", e)
145 ))?;
146
147 if !output.status.success() && output.stdout.is_empty() {
150 return Err(VulnerabilityError::CommandError(
151 format!("yarn audit failed with exit code {}: {}",
152 output.status.code().unwrap_or(-1),
153 String::from_utf8_lossy(&output.stderr))
154 ));
155 }
156
157 if output.stdout.is_empty() {
158 return Ok(None);
159 }
160
161 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
163 .map_err(|e| VulnerabilityError::ParseError(
164 format!("Failed to parse yarn audit output: {}", e)
165 ))?;
166
167 self.parse_yarn_audit_output(&audit_data, dependencies)
168 }
169
170 fn execute_pnpm_audit(
171 &mut self,
172 project_path: &Path,
173 dependencies: &[DependencyInfo],
174 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
175 let pnpm_status = self.tool_detector.detect_tool("pnpm");
177 if !pnpm_status.available {
178 warn!("pnpm not found, skipping pnpm audit");
179 return Ok(None);
180 }
181
182 info!("Executing pnpm audit in {}", project_path.display());
183
184 let output = Command::new("pnpm")
186 .args(&["audit", "--json"])
187 .current_dir(project_path)
188 .output()
189 .map_err(|e| VulnerabilityError::CommandError(
190 format!("Failed to run pnpm audit: {}", e)
191 ))?;
192
193 if !output.status.success() && output.stdout.is_empty() {
196 return Err(VulnerabilityError::CommandError(
197 format!("pnpm audit failed with exit code {}: {}",
198 output.status.code().unwrap_or(-1),
199 String::from_utf8_lossy(&output.stderr))
200 ));
201 }
202
203 if output.stdout.is_empty() {
204 return Ok(None);
205 }
206
207 let audit_data: serde_json::Value = serde_json::from_slice(&output.stdout)
209 .map_err(|e| VulnerabilityError::ParseError(
210 format!("Failed to parse pnpm audit output: {}", e)
211 ))?;
212
213 self.parse_pnpm_audit_output(&audit_data, dependencies)
214 }
215
216 fn parse_bun_audit_output(
217 &self,
218 audit_data: &serde_json::Value,
219 dependencies: &[DependencyInfo],
220 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
221 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
222
223 if let Some(obj) = audit_data.as_object() {
226 for (package_name, vulnerabilities) in obj {
227 if let Some(vuln_array) = vulnerabilities.as_array() {
228 if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
230 let mut package_vulns = Vec::new();
231
232 for vulnerability in vuln_array {
233 let id = vulnerability.get("id").and_then(|i| i.as_u64())
235 .map(|id| id.to_string())
236 .unwrap_or("unknown".to_string());
237 let title = vulnerability.get("title").and_then(|t| t.as_str())
238 .unwrap_or("Unknown vulnerability").to_string();
239 let description = vulnerability.get("title").and_then(|t| t.as_str())
240 .unwrap_or("").to_string();
241 let severity = self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str()));
242 let affected_versions = vulnerability.get("vulnerable_versions").and_then(|v| v.as_str())
243 .unwrap_or("*").to_string();
244 let cwe = vulnerability.get("cwe").and_then(|c| c.as_array())
245 .and_then(|arr| arr.first())
246 .and_then(|v| v.as_str())
247 .map(|s| s.to_string());
248 let url = vulnerability.get("url").and_then(|u| u.as_str())
249 .map(|s| s.to_string());
250
251 let vuln_info = VulnerabilityInfo {
252 id,
253 vuln_type: "security".to_string(), severity,
255 title,
256 description,
257 cve: cwe.clone(), ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
259 u.split('/').last().unwrap_or(&u).to_string()
260 }),
261 affected_versions,
262 patched_versions: None, published_date: None, references: url.map(|u| vec![u]).unwrap_or_default(),
265 };
266
267 package_vulns.push(vuln_info);
268 }
269
270 if !package_vulns.is_empty() {
271 vulnerable_deps.push(VulnerableDependency {
272 name: dep.name.clone(),
273 version: dep.version.clone(),
274 language: Language::JavaScript,
275 vulnerabilities: package_vulns,
276 });
277 }
278 }
279 }
280 }
281 }
282
283 if vulnerable_deps.is_empty() {
284 Ok(None)
285 } else {
286 Ok(Some(vulnerable_deps))
287 }
288 }
289
290 fn parse_npm_audit_output(
291 &self,
292 audit_data: &serde_json::Value,
293 dependencies: &[DependencyInfo],
294 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
295 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
296
297 if let Some(vulnerabilities) = audit_data.get("vulnerabilities").and_then(|v| v.as_object()) {
300 for (package_name, vulnerability_info) in vulnerabilities {
301 if let Some(dep) = dependencies.iter().find(|d| d.name == *package_name) {
303 let mut package_vulns = Vec::new();
304
305 if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
307 for advisory in via {
308 if let Some(advisory_obj) = advisory.as_object() {
309 if advisory_obj.contains_key("source") && !advisory_obj.contains_key("title") {
311 continue;
312 }
313
314 let id = advisory_obj.get("source")
315 .and_then(|s| s.as_u64())
316 .map(|id| id.to_string())
317 .or_else(|| advisory_obj.get("url")
318 .and_then(|u| u.as_str())
319 .and_then(|url| {
320 if url.contains("GHSA") {
321 url.split('/').last().map(|s| s.to_string())
322 } else {
323 None
324 }
325 }))
326 .unwrap_or("unknown".to_string());
327
328 let title = advisory_obj.get("title").and_then(|t| t.as_str())
329 .unwrap_or("Unknown vulnerability").to_string();
330 let description = title.clone();
331 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
332
333 let range = advisory_obj.get("range").and_then(|r| r.as_str())
334 .unwrap_or("*").to_string();
335
336 let cwe = advisory_obj.get("cwe").and_then(|c| c.as_array())
337 .and_then(|arr| arr.first())
338 .and_then(|v| v.as_str())
339 .map(|s| s.to_string());
340
341 let url = advisory_obj.get("url").and_then(|u| u.as_str())
342 .map(|s| s.to_string());
343
344 let vuln_info = VulnerabilityInfo {
345 id,
346 vuln_type: "security".to_string(), severity,
348 title,
349 description,
350 cve: cwe.clone(),
351 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
352 u.split('/').last().unwrap_or(&u).to_string()
353 }),
354 affected_versions: range,
355 patched_versions: None, published_date: None,
357 references: url.map(|u| vec![u]).unwrap_or_default(),
358 };
359
360 package_vulns.push(vuln_info);
361 }
362 }
363 }
364
365 if !package_vulns.is_empty() {
366 vulnerable_deps.push(VulnerableDependency {
367 name: dep.name.clone(),
368 version: dep.version.clone(),
369 language: Language::JavaScript,
370 vulnerabilities: package_vulns,
371 });
372 }
373 }
374 }
375 }
376
377 if vulnerable_deps.is_empty() {
378 Ok(None)
379 } else {
380 Ok(Some(vulnerable_deps))
381 }
382 }
383
384 fn parse_yarn_audit_output(
385 &self,
386 audit_data: &serde_json::Value,
387 dependencies: &[DependencyInfo],
388 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
389 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
390
391 if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
394 if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
395 for (advisory_id, advisory) in advisories {
396 if let Some(advisory_obj) = advisory.as_object() {
397 let package_name = advisory_obj.get("module_name").and_then(|n| n.as_str())
398 .unwrap_or("").to_string();
399
400 if let Some(dep) = dependencies.iter().find(|d| d.name == package_name) {
402 let id = advisory_id.clone();
403 let title = advisory_obj.get("title").and_then(|t| t.as_str())
404 .unwrap_or("Unknown vulnerability").to_string();
405 let description = advisory_obj.get("overview").and_then(|o| o.as_str())
406 .unwrap_or("").to_string();
407 let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
408 let vulnerable_versions = advisory_obj.get("vulnerable_versions").and_then(|v| v.as_str())
409 .unwrap_or("*").to_string();
410
411 let cve = advisory_obj.get("cves").and_then(|c| c.as_array())
412 .and_then(|arr| arr.first())
413 .and_then(|v| v.as_str())
414 .map(|s| s.to_string());
415
416 let url = advisory_obj.get("url").and_then(|u| u.as_str())
417 .map(|s| s.to_string());
418
419 let vuln_info = VulnerabilityInfo {
420 id,
421 vuln_type: "security".to_string(), severity,
423 title,
424 description,
425 cve,
426 ghsa: url.clone().filter(|u| u.contains("GHSA")).map(|u| {
427 u.split('/').last().unwrap_or(&u).to_string()
428 }),
429 affected_versions: vulnerable_versions,
430 patched_versions: advisory_obj.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
431 published_date: None,
432 references: url.map(|u| vec![u]).unwrap_or_default(),
433 };
434
435 if let Some(existing) = vulnerable_deps.iter_mut().find(|vuln_dep| vuln_dep.name == package_name) {
437 existing.vulnerabilities.push(vuln_info);
438 } else {
439 vulnerable_deps.push(VulnerableDependency {
440 name: dep.name.clone(),
441 version: dep.version.clone(),
442 language: Language::JavaScript,
443 vulnerabilities: vec![vuln_info],
444 });
445 }
446 }
447 }
448 }
449 }
450 }
451
452 if vulnerable_deps.is_empty() {
453 Ok(None)
454 } else {
455 Ok(Some(vulnerable_deps))
456 }
457 }
458
459 fn parse_pnpm_audit_output(
460 &self,
461 audit_data: &serde_json::Value,
462 dependencies: &[DependencyInfo],
463 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
464 self.parse_npm_audit_output(audit_data, dependencies)
466 }
467
468 fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
469 match severity.map(|s| s.to_lowercase()).as_deref() {
470 Some("critical") => VulnerabilitySeverity::Critical,
471 Some("high") => VulnerabilitySeverity::High,
472 Some("moderate") => VulnerabilitySeverity::Medium,
473 Some("medium") => VulnerabilitySeverity::Medium,
474 Some("low") => VulnerabilitySeverity::Low,
475 _ => VulnerabilitySeverity::Medium, }
477 }
478}
479
480impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
481 fn check_vulnerabilities(
482 &mut self,
483 dependencies: &[DependencyInfo],
484 project_path: &Path,
485 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
486 info!("Checking JavaScript/TypeScript dependencies");
487
488 let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
489 let _detection_result = runtime_detector.detect_js_runtime_and_package_manager();
490
491 info!("Runtime detection: {}", runtime_detector.get_detection_summary());
492
493 let available_managers = runtime_detector.detect_all_package_managers();
495
496 let mut all_vulnerabilities = Vec::new();
498
499 for manager in available_managers {
500 if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
501 all_vulnerabilities.extend(vulns);
502 }
503 }
504
505 Ok(all_vulnerabilities)
506 }
507}