Skip to content

Commit d8209c6

Browse files
authored
Merge pull request #161 from syncable-dev/develop
feat: testing analyze
2 parents 13e7699 + 1206118 commit d8209c6

2 files changed

Lines changed: 238 additions & 83 deletions

File tree

src/analyzer/vulnerability/checkers/javascript.rs

Lines changed: 236 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::analyzer::runtime::{RuntimeDetector, PackageManager};
66
use crate::analyzer::tool_management::ToolDetector;
77
use crate::analyzer::vulnerability::{VulnerableDependency, VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity};
88
use super::MutableLanguageVulnerabilityChecker;
9+
use serde_json::Value as JsonValue;
910

1011
pub struct JavaScriptVulnerabilityChecker {
1112
tool_detector: ToolDetector,
@@ -132,39 +133,67 @@ impl JavaScriptVulnerabilityChecker {
132133
warn!("yarn not found, skipping yarn audit");
133134
return Ok(None);
134135
}
135-
136+
136137
info!("Executing yarn audit in {}", project_path.display());
137-
138-
// Execute yarn audit --json
139-
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-
// yarn audit behavior: returns 0 even when vulnerabilities are found
148-
// Non-zero exit code indicates an actual error
149-
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);
138+
139+
// Strategy:
140+
// 1) Try Yarn Berry command: yarn npm audit --json (Yarn v2+)
141+
// 2) Fallback to classic: yarn audit --json (Yarn v1)
142+
// 3) Handle both single-JSON and line-delimited JSON formats
143+
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+
// Non-zero with empty stdout is a hard failure; otherwise attempt to parse what we got
158+
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+
// Nothing to parse
170+
continue;
171+
}
172+
173+
// Try to parse as a single JSON blob first (be tolerant of banners/noise)
174+
if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
175+
// If it looks like NPM's shape (common for `yarn npm audit`), reuse NPM parser
176+
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+
// Otherwise try Yarn object shape
183+
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 not a single JSON, try line-delimited JSON format (Yarn v1 classic)
188+
if let Ok(res) = self.parse_yarn_streaming_audit_lines(&output.stdout, dependencies) {
189+
if res.is_some() { return Ok(res); }
190+
}
191+
}
159192
}
160-
161-
// Parse yarn audit output
162-
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)
193+
194+
// If we got here, we couldn't parse Yarn output; don't fail the whole scan
195+
warn!("Unable to parse yarn audit output; skipping Yarn results");
196+
Ok(None)
168197
}
169198

170199
fn execute_pnpm_audit(
@@ -389,51 +418,16 @@ impl JavaScriptVulnerabilityChecker {
389418
let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
390419

391420
// Yarn audit JSON structure parsing
392-
// Yarn returns audit data in a different format than npm
421+
// Shape 1: Single object with { data: { advisories: { id: {...} } } } (rare)
393422
if let Some(data) = audit_data.get("data").and_then(|d| d.as_object()) {
394423
if let Some(advisories) = data.get("advisories").and_then(|a| a.as_object()) {
395424
for (advisory_id, advisory) in advisories {
396425
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-
// Find matching dependency
426+
let package_name = advisory_obj.get("module_name").and_then(|n| n.as_str()).unwrap_or("").to_string();
401427
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(), // Security vulnerability
422-
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-
// Check if we already have this dependency
436-
if let Some(existing) = vulnerable_deps.iter_mut().find(|vuln_dep| vuln_dep.name == package_name) {
428+
let (vuln_info, pkg_name) = self.extract_yarn_advisory(advisory_id, advisory_obj);
429+
// Use dep.name to keep version/source consistent
430+
if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
437431
existing.vulnerabilities.push(vuln_info);
438432
} else {
439433
vulnerable_deps.push(VulnerableDependency {
@@ -455,14 +449,127 @@ impl JavaScriptVulnerabilityChecker {
455449
Ok(Some(vulnerable_deps))
456450
}
457451
}
452+
453+
// Parse Yarn classic line-delimited JSON output
454+
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+
}
458545

459546
fn parse_pnpm_audit_output(
460547
&self,
461548
audit_data: &serde_json::Value,
462549
dependencies: &[DependencyInfo],
463550
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
464-
// PNPM audit output is similar to NPM
465-
self.parse_npm_audit_output(audit_data, dependencies)
551+
// PNPM audit output can resemble NPM or provide an advisories map similar to Yarn classic
552+
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+
// Wrap into Yarn-like shape and reuse Yarn parser
558+
let yarn_like = serde_json::json!({
559+
"data": { "advisories": advisories }
560+
});
561+
return self.parse_yarn_audit_output(&yarn_like, dependencies);
562+
}
563+
564+
// Some pnpm versions produce per-advisory arrays; attempt best-effort mapping if present
565+
if let Some(findings) = audit_data.get("audit").or_else(|| audit_data.get("metadata")).or_else(|| audit_data.get("data")) {
566+
// Try npm parser as a reasonable default
567+
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)
466573
}
467574

468575
fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
@@ -486,22 +593,68 @@ impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
486593
info!("Checking JavaScript/TypeScript dependencies");
487594

488595
let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
489-
let _detection_result = runtime_detector.detect_js_runtime_and_package_manager();
490-
596+
let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
597+
491598
info!("Runtime detection: {}", runtime_detector.get_detection_summary());
492-
493-
// Get all available package managers
494-
let available_managers = runtime_detector.detect_all_package_managers();
495-
496-
// Execute audit commands for each available manager
599+
600+
// Build execution order: primary detected manager first, then any lockfile-based managers
601+
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+
// Always consider running Bun audit for JS projects if available,
612+
// as Bun often surfaces advisories even when other managers don't.
613+
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 still empty but it's a JS project, default to npm as a last resort
620+
if managers.is_empty() && runtime_detector.is_js_project() {
621+
managers.push(crate::analyzer::runtime::PackageManager::Npm);
622+
}
623+
624+
// Execute audit commands for each selected manager
497625
let mut all_vulnerabilities = Vec::new();
498-
499-
for manager in available_managers {
626+
627+
for manager in managers {
500628
if let Some(vulns) = self.execute_audit_for_manager(&manager, project_path, dependencies)? {
501629
all_vulnerabilities.extend(vulns);
502630
}
503631
}
504632

505633
Ok(all_vulnerabilities)
506634
}
507-
}
635+
}
636+
637+
// Best-effort tolerant JSON extractor: handles banners/noise by
638+
// 1) parsing whole buffer, 2) slicing between first '{' and last '}',
639+
// 3) scanning lines for a valid JSON object.
640+
fn 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+
}

src/handlers/analyze.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub fn handle_analyze(
3737
let monorepo_analysis = analyze_monorepo(&path)?;
3838

3939
let output = if json {
40+
println!("🔍 Analyzing JSON OUTPUT: {}", path.display());
41+
4042
display_analysis_with_return(&monorepo_analysis, DisplayMode::Json)
4143
} else {
4244
// Determine display mode

0 commit comments

Comments
 (0)