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(advisories) = audit_data.get("advisories").and_then(|a| a.as_array()) {
225 for advisory in advisories {
226 let name = advisory.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
228 let version = advisory.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
229
230 let vuln_info = VulnerabilityInfo {
231 id: advisory.get("id").and_then(|i| i.as_str()).unwrap_or("unknown").to_string(),
232 severity: self.parse_severity(advisory.get("severity").and_then(|s| s.as_str())),
233 title: advisory.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
234 description: advisory.get("description").and_then(|d| d.as_str()).unwrap_or("").to_string(),
235 cve: advisory.get("cve").and_then(|c| c.as_str()).map(|s| s.to_string()),
236 ghsa: advisory.get("ghsa").and_then(|g| g.as_array())
237 .and_then(|arr| arr.first())
238 .and_then(|v| v.as_str())
239 .map(|s| s.to_string()),
240 affected_versions: advisory.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("").to_string(),
241 patched_versions: advisory.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
242 published_date: None, references: advisory.get("references").and_then(|r| r.as_array())
244 .map(|refs| refs.iter()
245 .filter_map(|r| r.as_str().map(|s| s.to_string()))
246 .collect())
247 .unwrap_or_default(),
248 };
249
250 if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
252 if let Some(existing) = vulnerable_deps.iter_mut()
254 .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
255 {
256 existing.vulnerabilities.push(vuln_info);
257 } else {
258 vulnerable_deps.push(VulnerableDependency {
259 name: dep.name.clone(),
260 version: version.clone(),
261 language: Language::JavaScript,
262 vulnerabilities: vec![vuln_info],
263 });
264 }
265 }
266 }
267 }
268
269 if vulnerable_deps.is_empty() {
270 Ok(None)
271 } else {
272 Ok(Some(vulnerable_deps))
273 }
274 }
275
276 fn parse_npm_audit_output(
277 &self,
278 audit_data: &serde_json::Value,
279 dependencies: &[DependencyInfo],
280 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
281 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
282
283 if let Some(actions) = audit_data.get("actions").and_then(|a| a.as_array()) {
285 for action in actions {
286 if let Some(resolves) = action.get("resolves").and_then(|r| r.as_array()) {
287 for resolve in resolves {
288 let name = resolve.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
289 let version = resolve.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
290
291 let advisory_id = resolve.get("id").and_then(|i| i.as_u64()).unwrap_or(0);
293
294 if let Some(advisories) = audit_data.get("advisories").and_then(|a| a.as_object()) {
296 if let Some(advisory) = advisories.get(&advisory_id.to_string()) {
297 let vuln_info = VulnerabilityInfo {
298 id: advisory.get("id").and_then(|i| i.as_u64())
299 .map(|id| id.to_string())
300 .unwrap_or("unknown".to_string()),
301 severity: self.parse_severity(advisory.get("severity").and_then(|s| s.as_str())),
302 title: advisory.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string(),
303 description: advisory.get("overview").and_then(|o| o.as_str()).unwrap_or("").to_string(),
304 cve: advisory.get("cves").and_then(|c| c.as_array())
305 .and_then(|arr| arr.first())
306 .and_then(|v| v.as_str())
307 .map(|s| s.to_string()),
308 ghsa: advisory.get("github_advisory_id").and_then(|g| g.as_str()).map(|s| s.to_string()),
309 affected_versions: advisory.get("vulnerable_versions").and_then(|v| v.as_str()).unwrap_or("").to_string(),
310 patched_versions: advisory.get("patched_versions").and_then(|p| p.as_str()).map(|s| s.to_string()),
311 published_date: advisory.get("publish_time")
312 .and_then(|d| d.as_u64())
313 .and_then(|timestamp| {
314 use chrono::TimeZone;
315 chrono::Utc.timestamp_opt(timestamp as i64, 0).single()
316 }),
317 references: advisory.get("references").and_then(|r| r.as_array())
318 .map(|refs| refs.iter()
319 .filter_map(|r| r.as_str().map(|s| s.to_string()))
320 .collect())
321 .unwrap_or_default(),
322 };
323
324 if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
326 if let Some(existing) = vulnerable_deps.iter_mut()
328 .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
329 {
330 existing.vulnerabilities.push(vuln_info);
331 } else {
332 vulnerable_deps.push(VulnerableDependency {
333 name: dep.name.clone(),
334 version: version.clone(),
335 language: Language::JavaScript,
336 vulnerabilities: vec![vuln_info],
337 });
338 }
339 }
340 }
341 }
342 }
343 }
344 }
345 }
346
347 if vulnerable_deps.is_empty() {
348 Ok(None)
349 } else {
350 Ok(Some(vulnerable_deps))
351 }
352 }
353
354 fn parse_yarn_audit_output(
355 &self,
356 audit_data: &serde_json::Value,
357 dependencies: &[DependencyInfo],
358 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
359 let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
360
361 if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
363 if let Some(vulnerabilities) = data.get("vulnerabilities").and_then(|v| v.as_array()) {
364 for vulnerability in vulnerabilities {
365 let name = vulnerability.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
366 let version = vulnerability.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
367
368 let vuln_info = VulnerabilityInfo {
369 id: vulnerability.get("advisory").and_then(|a| a.get("id"))
370 .and_then(|i| i.as_u64())
371 .map(|id| id.to_string())
372 .unwrap_or("unknown".to_string()),
373 severity: self.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str())),
374 title: vulnerability.get("advisory").and_then(|a| a.get("title"))
375 .and_then(|t| t.as_str())
376 .unwrap_or("")
377 .to_string(),
378 description: vulnerability.get("advisory").and_then(|a| a.get("description"))
379 .and_then(|d| d.as_str())
380 .unwrap_or("")
381 .to_string(),
382 cve: vulnerability.get("advisory").and_then(|a| a.get("cves"))
383 .and_then(|c| c.as_array())
384 .and_then(|arr| arr.first())
385 .and_then(|v| v.as_str())
386 .map(|s| s.to_string()),
387 ghsa: vulnerability.get("advisory").and_then(|a| a.get("github_advisory_id"))
388 .and_then(|g| g.as_str())
389 .map(|s| s.to_string()),
390 affected_versions: vulnerability.get("advisory").and_then(|a| a.get("vulnerable_versions"))
391 .and_then(|v| v.as_str())
392 .unwrap_or("")
393 .to_string(),
394 patched_versions: vulnerability.get("advisory").and_then(|a| a.get("patched_versions"))
395 .and_then(|p| p.as_str())
396 .map(|s| s.to_string()),
397 published_date: vulnerability.get("advisory").and_then(|a| a.get("publish_time"))
398 .and_then(|d| d.as_u64())
399 .and_then(|timestamp| {
400 use chrono::TimeZone;
401 chrono::Utc.timestamp_opt(timestamp as i64, 0).single()
402 }),
403 references: vulnerability.get("advisory").and_then(|a| a.get("references"))
404 .and_then(|r| r.as_array())
405 .map(|refs| refs.iter()
406 .filter_map(|r| r.as_str().map(|s| s.to_string()))
407 .collect())
408 .unwrap_or_default(),
409 };
410
411 if let Some(dep) = dependencies.iter().find(|d| d.name == name) {
413 if let Some(existing) = vulnerable_deps.iter_mut()
415 .find(|vuln_dep| vuln_dep.name == name && vuln_dep.version == version)
416 {
417 existing.vulnerabilities.push(vuln_info);
418 } else {
419 vulnerable_deps.push(VulnerableDependency {
420 name: dep.name.clone(),
421 version: version.clone(),
422 language: Language::JavaScript,
423 vulnerabilities: vec![vuln_info],
424 });
425 }
426 }
427 }
428 }
429 }
430
431 if vulnerable_deps.is_empty() {
432 Ok(None)
433 } else {
434 Ok(Some(vulnerable_deps))
435 }
436 }
437
438 fn parse_pnpm_audit_output(
439 &self,
440 audit_data: &serde_json::Value,
441 dependencies: &[DependencyInfo],
442 ) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
443 self.parse_npm_audit_output(audit_data, dependencies)
445 }
446
447 fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
448 match severity.map(|s| s.to_lowercase()).as_deref() {
449 Some("critical") => VulnerabilitySeverity::Critical,
450 Some("high") => VulnerabilitySeverity::High,
451 Some("moderate") => VulnerabilitySeverity::Medium,
452 Some("medium") => VulnerabilitySeverity::Medium,
453 Some("low") => VulnerabilitySeverity::Low,
454 _ => VulnerabilitySeverity::Medium, }
456 }
457}
458
459impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
460 fn check_vulnerabilities(
461 &mut self,
462 dependencies: &[DependencyInfo],
463 project_path: &Path,
464 ) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
465 info!("Checking JavaScript/TypeScript dependencies");
466
467 let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
468 let _detection_result = runtime_detector.detect_js_runtime_and_package_manager();
469
470 info!("Runtime detection: {}", runtime_detector.get_detection_summary());
471
472 let available_managers = runtime_detector.detect_all_package_managers();
474
475 let mut all_vulnerabilities = Vec::new();
477
478 for manager in available_managers {
479 if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
480 all_vulnerabilities.extend(vulns);
481 }
482 }
483
484 Ok(all_vulnerabilities)
485 }
486}