use super::MutableLanguageVulnerabilityChecker;
use crate::analyzer::dependency_parser::{DependencyInfo, Language};
use crate::analyzer::runtime::{PackageManager, RuntimeDetector};
use crate::analyzer::tool_management::ToolDetector;
use crate::analyzer::vulnerability::{
VulnerabilityError, VulnerabilityInfo, VulnerabilitySeverity, VulnerableDependency,
};
use log::{info, warn};
use serde_json::Value as JsonValue;
use std::path::Path;
use std::process::Command;
pub struct JavaScriptVulnerabilityChecker {
tool_detector: ToolDetector,
}
impl Default for JavaScriptVulnerabilityChecker {
fn default() -> Self {
Self::new()
}
}
impl JavaScriptVulnerabilityChecker {
pub fn new() -> Self {
Self {
tool_detector: ToolDetector::new(),
}
}
fn execute_audit_for_manager(
&mut self,
manager: &PackageManager,
project_path: &Path,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
match manager {
PackageManager::Bun => self.execute_bun_audit(project_path, dependencies),
PackageManager::Npm => self.execute_npm_audit(project_path, dependencies),
PackageManager::Yarn => self.execute_yarn_audit(project_path, dependencies),
PackageManager::Pnpm => self.execute_pnpm_audit(project_path, dependencies),
PackageManager::Unknown => Ok(None),
}
}
fn execute_bun_audit(
&mut self,
project_path: &Path,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let bun_status = self.tool_detector.detect_tool("bun");
if !bun_status.available {
warn!("bun not found, skipping bun audit");
return Ok(None);
}
info!("Executing bun audit in {}", project_path.display());
let output = Command::new("bun")
.args(["audit", "--json"])
.current_dir(project_path)
.output()
.map_err(|e| {
VulnerabilityError::CommandError(format!("Failed to run bun audit: {}", e))
})?;
if !output.status.success() && !output.stdout.is_empty() {
info!("bun audit completed with findings");
}
if output.stdout.is_empty() {
return Ok(None);
}
let audit_data: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|e| {
VulnerabilityError::ParseError(format!("Failed to parse bun audit output: {}", e))
})?;
self.parse_bun_audit_output(&audit_data, dependencies)
}
fn execute_npm_audit(
&mut self,
project_path: &Path,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let npm_status = self.tool_detector.detect_tool("npm");
if !npm_status.available {
warn!("npm not found, skipping npm audit");
return Ok(None);
}
info!("Executing npm audit in {}", project_path.display());
let output = Command::new("npm")
.args(["audit", "--json"])
.current_dir(project_path)
.output()
.map_err(|e| {
VulnerabilityError::CommandError(format!("Failed to run npm audit: {}", e))
})?;
if !output.status.success() && output.stdout.is_empty() {
return Err(VulnerabilityError::CommandError(format!(
"npm audit failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
)));
}
if output.stdout.is_empty() {
return Ok(None);
}
let audit_data: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|e| {
VulnerabilityError::ParseError(format!("Failed to parse npm audit output: {}", e))
})?;
self.parse_npm_audit_output(&audit_data, dependencies)
}
fn execute_yarn_audit(
&mut self,
project_path: &Path,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let yarn_status = self.tool_detector.detect_tool("yarn");
if !yarn_status.available {
warn!("yarn not found, skipping yarn audit");
return Ok(None);
}
info!("Executing yarn audit in {}", project_path.display());
let candidates: Vec<Vec<&str>> =
vec![vec!["npm", "audit", "--json"], vec!["audit", "--json"]];
for args in candidates {
let output = match Command::new("yarn")
.args(&args)
.current_dir(project_path)
.output()
{
Ok(o) => o,
Err(e) => {
warn!("Failed to run 'yarn {}': {}", args.join(" "), e);
continue;
}
};
if !output.status.success() && output.stdout.is_empty() {
warn!(
"yarn {} failed (code {:?}): {}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
continue;
}
if output.stdout.is_empty() {
continue;
}
if let Some(audit_data) = try_parse_json_tolerant(&output.stdout) {
if audit_data.get("vulnerabilities").is_some()
&& let Ok(res) = self.parse_npm_audit_output(&audit_data, dependencies)
&& res.is_some()
{
return Ok(res);
}
if let Ok(res) = self.parse_yarn_audit_output(&audit_data, dependencies)
&& res.is_some()
{
return Ok(res);
}
} else if let Ok(res) =
self.parse_yarn_streaming_audit_lines(&output.stdout, dependencies)
&& res.is_some()
{
return Ok(res);
}
}
warn!("Unable to parse yarn audit output; skipping Yarn results");
Ok(None)
}
fn execute_pnpm_audit(
&mut self,
project_path: &Path,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let pnpm_status = self.tool_detector.detect_tool("pnpm");
if !pnpm_status.available {
warn!("pnpm not found, skipping pnpm audit");
return Ok(None);
}
info!("Executing pnpm audit in {}", project_path.display());
let output = Command::new("pnpm")
.args(["audit", "--json"])
.current_dir(project_path)
.output()
.map_err(|e| {
VulnerabilityError::CommandError(format!("Failed to run pnpm audit: {}", e))
})?;
if !output.status.success() && output.stdout.is_empty() {
return Err(VulnerabilityError::CommandError(format!(
"pnpm audit failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
)));
}
if output.stdout.is_empty() {
return Ok(None);
}
let audit_data: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|e| {
VulnerabilityError::ParseError(format!("Failed to parse pnpm audit output: {}", e))
})?;
self.parse_pnpm_audit_output(&audit_data, dependencies)
}
fn parse_bun_audit_output(
&self,
audit_data: &serde_json::Value,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
if let Some(obj) = audit_data.as_object() {
for (package_name, vulnerabilities) in obj {
if let Some(vuln_array) = vulnerabilities.as_array() {
let mut package_vulns = Vec::new();
for vulnerability in vuln_array {
let id = vulnerability
.get("id")
.and_then(|i| i.as_u64())
.map(|id| id.to_string())
.unwrap_or("unknown".to_string());
let title = vulnerability
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Unknown vulnerability")
.to_string();
let description = vulnerability
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
let severity = self
.parse_severity(vulnerability.get("severity").and_then(|s| s.as_str()));
let affected_versions = vulnerability
.get("vulnerable_versions")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string();
let cwe = vulnerability
.get("cwe")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let url = vulnerability
.get("url")
.and_then(|u| u.as_str())
.map(|s| s.to_string());
let vuln_info = VulnerabilityInfo {
id,
vuln_type: "security".to_string(), severity,
title,
description,
cve: cwe.clone(), ghsa: url
.clone()
.filter(|u| u.contains("GHSA"))
.map(|u| u.split('/').next_back().unwrap_or(&u).to_string()),
affected_versions,
patched_versions: None, published_date: None, references: url.map(|u| vec![u]).unwrap_or_default(),
};
package_vulns.push(vuln_info);
}
if !package_vulns.is_empty() {
let version = dependencies
.iter()
.find(|d| d.name == *package_name)
.map(|d| d.version.clone())
.unwrap_or_else(|| "transitive".to_string());
vulnerable_deps.push(VulnerableDependency {
name: package_name.clone(),
version,
language: Language::JavaScript,
vulnerabilities: package_vulns,
source_dir: None,
});
}
}
}
}
if vulnerable_deps.is_empty() {
Ok(None)
} else {
Ok(Some(vulnerable_deps))
}
}
fn parse_npm_audit_output(
&self,
audit_data: &serde_json::Value,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
if let Some(vulnerabilities) = audit_data
.get("vulnerabilities")
.and_then(|v| v.as_object())
{
for (package_name, vulnerability_info) in vulnerabilities {
let mut package_vulns = Vec::new();
if let Some(via) = vulnerability_info.get("via").and_then(|v| v.as_array()) {
for advisory in via {
if let Some(advisory_obj) = advisory.as_object() {
if advisory_obj.contains_key("source")
&& !advisory_obj.contains_key("title")
{
continue;
}
let id = advisory_obj
.get("source")
.and_then(|s| s.as_u64())
.map(|id| id.to_string())
.or_else(|| {
advisory_obj.get("url").and_then(|u| u.as_str()).and_then(
|url| {
if url.contains("GHSA") {
url.rsplit('/').next().map(|s| s.to_string())
} else {
None
}
},
)
})
.unwrap_or("unknown".to_string());
let title = advisory_obj
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Unknown vulnerability")
.to_string();
let description = title.clone();
let severity = self.parse_severity(
advisory_obj.get("severity").and_then(|s| s.as_str()),
);
let range = advisory_obj
.get("range")
.and_then(|r| r.as_str())
.unwrap_or("*")
.to_string();
let cwe = advisory_obj
.get("cwe")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let url = advisory_obj
.get("url")
.and_then(|u| u.as_str())
.map(|s| s.to_string());
let vuln_info = VulnerabilityInfo {
id,
vuln_type: "security".to_string(), severity,
title,
description,
cve: cwe.clone(),
ghsa: url
.clone()
.filter(|u| u.contains("GHSA"))
.map(|u| u.split('/').next_back().unwrap_or(&u).to_string()),
affected_versions: range,
patched_versions: None, published_date: None,
references: url.map(|u| vec![u]).unwrap_or_default(),
};
package_vulns.push(vuln_info);
}
}
}
if !package_vulns.is_empty() {
let version = dependencies
.iter()
.find(|d| d.name == *package_name)
.map(|d| d.version.clone())
.unwrap_or_else(|| "transitive".to_string());
vulnerable_deps.push(VulnerableDependency {
name: package_name.clone(),
version,
language: Language::JavaScript,
vulnerabilities: package_vulns,
source_dir: None,
});
}
}
}
if vulnerable_deps.is_empty() {
Ok(None)
} else {
Ok(Some(vulnerable_deps))
}
}
fn parse_yarn_audit_output(
&self,
audit_data: &serde_json::Value,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
if let Some(data) = audit_data.get("data").and_then(|d| d.as_object())
&& let Some(advisories) = data.get("advisories").and_then(|a| a.as_object())
{
for (advisory_id, advisory) in advisories {
if let Some(advisory_obj) = advisory.as_object() {
let (vuln_info, pkg_name) =
self.extract_yarn_advisory(advisory_id, advisory_obj);
if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name)
{
existing.vulnerabilities.push(vuln_info);
} else {
let version = dependencies
.iter()
.find(|d| d.name == pkg_name)
.map(|d| d.version.clone())
.unwrap_or_else(|| "transitive".to_string());
vulnerable_deps.push(VulnerableDependency {
name: pkg_name,
version,
language: Language::JavaScript,
vulnerabilities: vec![vuln_info],
source_dir: None,
});
}
}
}
}
if vulnerable_deps.is_empty() {
Ok(None)
} else {
Ok(Some(vulnerable_deps))
}
}
fn parse_yarn_streaming_audit_lines(
&self,
stdout: &[u8],
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
let mut vulnerable_deps: Vec<VulnerableDependency> = Vec::new();
let text = String::from_utf8_lossy(stdout);
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& json.get("type").and_then(|t| t.as_str()) == Some("auditAdvisory")
&& let Some(advisory_obj) = json
.get("data")
.and_then(|d| d.get("advisory"))
.and_then(|a| a.as_object())
{
let _package_name = advisory_obj
.get("module_name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let (vuln_info, pkg_name) = self.extract_yarn_advisory(
advisory_obj
.get("id")
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_else(|| "unknown".to_string())
.as_str(),
advisory_obj,
);
if let Some(existing) = vulnerable_deps.iter_mut().find(|v| v.name == pkg_name) {
existing.vulnerabilities.push(vuln_info);
} else {
let version = dependencies
.iter()
.find(|d| d.name == pkg_name)
.map(|d| d.version.clone())
.unwrap_or_else(|| "transitive".to_string());
vulnerable_deps.push(VulnerableDependency {
name: pkg_name,
version,
language: Language::JavaScript,
vulnerabilities: vec![vuln_info],
source_dir: None,
});
}
}
}
if vulnerable_deps.is_empty() {
Ok(None)
} else {
Ok(Some(vulnerable_deps))
}
}
fn extract_yarn_advisory(
&self,
advisory_id: impl Into<String>,
advisory_obj: &serde_json::Map<String, serde_json::Value>,
) -> (VulnerabilityInfo, String) {
let package_name = advisory_obj
.get("module_name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let id = advisory_id.into();
let title = advisory_obj
.get("title")
.and_then(|t| t.as_str())
.unwrap_or("Unknown vulnerability")
.to_string();
let description = advisory_obj
.get("overview")
.and_then(|o| o.as_str())
.unwrap_or("")
.to_string();
let severity = self.parse_severity(advisory_obj.get("severity").and_then(|s| s.as_str()));
let vulnerable_versions = advisory_obj
.get("vulnerable_versions")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string();
let cve = advisory_obj
.get("cves")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let url = advisory_obj
.get("url")
.and_then(|u| u.as_str())
.map(|s| s.to_string());
let vuln_info = VulnerabilityInfo {
id,
vuln_type: "security".to_string(),
severity,
title,
description,
cve,
ghsa: url
.clone()
.filter(|u| u.contains("GHSA"))
.map(|u| u.split('/').next_back().unwrap_or(&u).to_string()),
affected_versions: vulnerable_versions,
patched_versions: advisory_obj
.get("patched_versions")
.and_then(|p| p.as_str())
.map(|s| s.to_string()),
published_date: None,
references: url.map(|u| vec![u]).unwrap_or_default(),
};
(vuln_info, package_name)
}
fn parse_pnpm_audit_output(
&self,
audit_data: &serde_json::Value,
dependencies: &[DependencyInfo],
) -> Result<Option<Vec<VulnerableDependency>>, VulnerabilityError> {
if audit_data.get("vulnerabilities").is_some() {
return self.parse_npm_audit_output(audit_data, dependencies);
}
if let Some(advisories) = audit_data.get("advisories").cloned() {
let yarn_like = serde_json::json!({
"data": { "advisories": advisories }
});
return self.parse_yarn_audit_output(&yarn_like, dependencies);
}
if audit_data
.get("audit")
.or_else(|| audit_data.get("metadata"))
.or_else(|| audit_data.get("data"))
.is_some()
&& let Ok(res) = self.parse_npm_audit_output(audit_data, dependencies)
&& res.is_some()
{
return Ok(res);
}
Ok(None)
}
fn parse_severity(&self, severity: Option<&str>) -> VulnerabilitySeverity {
match severity.map(|s| s.to_lowercase()).as_deref() {
Some("critical") => VulnerabilitySeverity::Critical,
Some("high") => VulnerabilitySeverity::High,
Some("moderate") => VulnerabilitySeverity::Medium,
Some("medium") => VulnerabilitySeverity::Medium,
Some("low") => VulnerabilitySeverity::Low,
_ => VulnerabilitySeverity::Medium, }
}
}
impl MutableLanguageVulnerabilityChecker for JavaScriptVulnerabilityChecker {
fn check_vulnerabilities(
&mut self,
dependencies: &[DependencyInfo],
project_path: &Path,
) -> Result<Vec<VulnerableDependency>, VulnerabilityError> {
info!("Checking JavaScript/TypeScript dependencies");
let runtime_detector = RuntimeDetector::new(project_path.to_path_buf());
let detection_result = runtime_detector.detect_js_runtime_and_package_manager();
info!(
"Runtime detection: {}",
runtime_detector.get_detection_summary()
);
let mut managers = Vec::new();
if detection_result.package_manager != crate::analyzer::runtime::PackageManager::Unknown {
managers.push(detection_result.package_manager.clone());
}
for m in runtime_detector.detect_all_package_managers() {
if !managers.contains(&m) {
managers.push(m);
}
}
if !managers.contains(&crate::analyzer::runtime::PackageManager::Bun)
&& runtime_detector.is_js_project()
{
managers.push(crate::analyzer::runtime::PackageManager::Bun);
}
if managers.is_empty() && runtime_detector.is_js_project() {
managers.push(crate::analyzer::runtime::PackageManager::Npm);
}
let mut all_vulnerabilities = Vec::new();
for manager in managers {
if let Some(vulns) =
self.execute_audit_for_manager(&manager, project_path, dependencies)?
{
all_vulnerabilities.extend(vulns);
}
}
let mut deduplicated: Vec<VulnerableDependency> = Vec::new();
for vuln_dep in all_vulnerabilities {
if let Some(existing) = deduplicated.iter_mut().find(|d| d.name == vuln_dep.name) {
for new_vuln in vuln_dep.vulnerabilities {
if !existing.vulnerabilities.iter().any(|v| v.id == new_vuln.id) {
existing.vulnerabilities.push(new_vuln);
}
}
} else {
deduplicated.push(vuln_dep);
}
}
Ok(deduplicated)
}
}
fn try_parse_json_tolerant(buf: &[u8]) -> Option<JsonValue> {
if let Ok(val) = serde_json::from_slice::<JsonValue>(buf) {
return Some(val);
}
let text = String::from_utf8_lossy(buf);
if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}'))
&& start < end
&& let Ok(val) = serde_json::from_str::<JsonValue>(&text[start..=end])
{
return Some(val);
}
for line in text.lines() {
let line = line.trim();
if !line.starts_with('{') || !line.ends_with('}') {
continue;
}
if let Ok(val) = serde_json::from_str::<JsonValue>(line) {
return Some(val);
}
}
None
}