use std::path::PathBuf;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::error::{Result, DumplingError};
use crate::package_manager::PackageJson;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vulnerability {
pub id: String,
pub title: String,
pub description: String,
pub severity: String, pub url: String,
pub package: String,
pub versions: Vec<String>, }
#[derive(Debug, Serialize, Deserialize)]
pub struct VulnerabilityReport {
pub vulnerabilities: Vec<Vulnerability>,
pub summary: VulnerabilitySummary,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct VulnerabilitySummary {
pub total: usize,
pub critical: usize,
pub high: usize,
pub moderate: usize,
pub low: usize,
}
pub struct SecurityScanner {
mock_vulnerabilities: Vec<Vulnerability>,
}
impl SecurityScanner {
pub fn new() -> Self {
let mock_vulnerabilities = vec![
Vulnerability {
id: "CVE-2021-23337".to_string(),
title: "Prototype Pollution".to_string(),
description: "The package lodash before 4.17.21 is vulnerable to Prototype Pollution.".to_string(),
severity: "high".to_string(),
url: "https://nvd.nist.gov/vuln/detail/CVE-2021-23337".to_string(),
package: "lodash".to_string(),
versions: vec!["<4.17.21".to_string()],
},
Vulnerability {
id: "CVE-2022-25883".to_string(),
title: "Regular Expression Denial of Service".to_string(),
description: "The package minimist before 1.2.6 is vulnerable to Regular Expression Denial of Service (ReDoS).".to_string(),
severity: "moderate".to_string(),
url: "https://nvd.nist.gov/vuln/detail/CVE-2022-25883".to_string(),
package: "minimist".to_string(),
versions: vec!["<1.2.6".to_string()],
},
Vulnerability {
id: "CVE-2021-23424".to_string(),
title: "Cross-site Scripting".to_string(),
description: "The package axios before 0.21.1 is vulnerable to Cross-site Scripting (XSS).".to_string(),
severity: "moderate".to_string(),
url: "https://nvd.nist.gov/vuln/detail/CVE-2021-23424".to_string(),
package: "axios".to_string(),
versions: vec!["<0.21.1".to_string()],
},
];
Self { mock_vulnerabilities }
}
pub async fn scan(&self, root: &PathBuf) -> Result<VulnerabilityReport> {
let package_json_path = root.join("package.json");
if !package_json_path.exists() {
return Err(DumplingError::FileNotFound("package.json not found".to_string()));
}
let package = PackageJson::load(&package_json_path).await?;
let dependencies = package.dependencies();
let mut vulnerabilities = Vec::new();
for (name, version) in dependencies {
for vuln in &self.mock_vulnerabilities {
if vuln.package == name && self.is_version_affected(version.as_str(), &vuln.versions) {
vulnerabilities.push(vuln.clone());
}
}
}
let summary = self.calculate_summary(&vulnerabilities);
Ok(VulnerabilityReport {
vulnerabilities,
summary,
})
}
fn is_version_affected(&self, version: &str, affected_versions: &[String]) -> bool {
for affected in affected_versions {
if affected.starts_with('<') {
let threshold = affected.trim_start_matches('<');
if self.compare_versions(version, threshold) < 0 {
return true;
}
}
}
false
}
fn compare_versions(&self, a: &str, b: &str) -> i32 {
let a_parts: Vec<&str> = a.split('.').collect();
let b_parts: Vec<&str> = b.split('.').collect();
let min_len = a_parts.len().min(b_parts.len());
for i in 0..min_len {
if let (Ok(a_num), Ok(b_num)) = (a_parts[i].parse::<u32>(), b_parts[i].parse::<u32>()) {
if a_num > b_num {
return 1;
} else if a_num < b_num {
return -1;
}
}
}
if a_parts.len() > b_parts.len() {
1
} else if a_parts.len() < b_parts.len() {
-1
} else {
0
}
}
fn calculate_summary(&self, vulnerabilities: &[Vulnerability]) -> VulnerabilitySummary {
let mut summary = VulnerabilitySummary {
total: vulnerabilities.len(),
critical: 0,
high: 0,
moderate: 0,
low: 0,
};
for vuln in vulnerabilities {
match vuln.severity.as_str() {
"critical" => summary.critical += 1,
"high" => summary.high += 1,
"moderate" => summary.moderate += 1,
"low" => summary.low += 1,
_ => {}
}
}
summary
}
pub fn generate_report_json(&self, report: &VulnerabilityReport) -> Result<String> {
serde_json::to_string_pretty(report)
.map_err(|e| DumplingError::Build(format!("Failed to generate report: {}", e)))
}
pub fn generate_report_markdown(&self, report: &VulnerabilityReport) -> String {
let mut output = String::new();
output.push_str("# Security Vulnerability Report\n\n");
output.push_str(&format!("## Summary\n\n"));
output.push_str(&format!("- Total vulnerabilities: {}\n", report.summary.total));
output.push_str(&format!("- Critical: {}\n", report.summary.critical));
output.push_str(&format!("- High: {}\n", report.summary.high));
output.push_str(&format!("- Moderate: {}\n", report.summary.moderate));
output.push_str(&format!("- Low: {}\n", report.summary.low));
output.push_str("\n## Vulnerabilities\n\n");
if report.vulnerabilities.is_empty() {
output.push_str("No vulnerabilities found! 🎉\n");
} else {
for vuln in &report.vulnerabilities {
output.push_str(&format!("### {}\n\n", vuln.title));
output.push_str(&format!("- **Package**: {}\n", vuln.package));
output.push_str(&format!("- **Severity**: {}\n", vuln.severity));
output.push_str(&format!("- **ID**: {}\n", vuln.id));
output.push_str(&format!("- **Affected versions**: {}\n", vuln.versions.join(", ")));
output.push_str(&format!("- **Description**: {}\n", vuln.description));
output.push_str(&format!("- **More info**: [{}]({})\n", vuln.id, vuln.url));
output.push_str("\n---\n\n");
}
}
output
}
pub async fn check_licenses(&self, root: &PathBuf) -> Result<LicenseReport> {
let package_json_path = root.join("package.json");
if !package_json_path.exists() {
return Err(DumplingError::FileNotFound("package.json not found".to_string()));
}
let package = PackageJson::load(&package_json_path).await?;
let dependencies = package.dependencies();
let mut licenses = HashMap::new();
for name in dependencies.keys() {
let license = match name.as_str() {
"lodash" => "MIT",
"axios" => "MIT",
"react" => "MIT",
"vue" => "MIT",
"angular" => "MIT",
"express" => "MIT",
"licensecheck" => "GPL-3.0",
_ => "Unknown",
};
licenses.insert(name.clone(), license.to_string());
}
Ok(LicenseReport { licenses })
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LicenseReport {
pub licenses: HashMap<String, String>,
}
pub async fn audit(format: String, check_licenses: bool) -> Result<()> {
let current_dir = std::env::current_dir()?;
let scanner = SecurityScanner::new();
let report = scanner.scan(¤t_dir).await?;
match format.as_str() {
"json" => {
let json = scanner.generate_report_json(&report)?;
println!("{}", json);
}
"markdown" | _ => {
let markdown = scanner.generate_report_markdown(&report);
println!("{}", markdown);
}
}
if check_licenses {
let license_report = scanner.check_licenses(¤t_dir).await?;
let markdown = license_report.to_markdown();
println!("{}", markdown);
}
if !report.vulnerabilities.is_empty() {
std::process::exit(1);
}
Ok(())
}
impl LicenseReport {
pub fn has_problematic_licenses(&self) -> bool {
for license in self.licenses.values() {
if self.is_problematic_license(license) {
return true;
}
}
false
}
fn is_problematic_license(&self, license: &str) -> bool {
license.to_lowercase().contains("gpl") ||
license.to_lowercase().contains("agpl") ||
license.to_lowercase().contains("lgpl")
}
pub fn to_markdown(&self) -> String {
let mut output = String::new();
output.push_str("# License Compliance Report\n\n");
if self.licenses.is_empty() {
output.push_str("No dependencies found.\n");
} else {
output.push_str("## Dependencies and Licenses\n\n");
let mut problematic = Vec::new();
for (name, license) in &self.licenses {
if self.is_problematic_license(license) {
problematic.push((name, license));
} else {
output.push_str(&format!("- {}: {}\n", name, license));
}
}
if !problematic.is_empty() {
output.push_str("\n## ⚠️ Potentially Problematic Licenses\n\n");
output.push_str("The following dependencies use copyleft licenses that may require special consideration:\n\n");
for (name, license) in problematic {
output.push_str(&format!("- {}: **{}**\n", name, license));
}
output.push_str("\nPlease consult with your legal team regarding these licenses.\n");
}
}
output
}
}