#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum VulnError {
#[error("Failed to read: {0}")]
ReadError(#[from] std::io::Error),
#[error("Failed to parse: {0}")]
ParseError(#[from] toml::de::Error),
#[error("Network error: {0}")]
NetworkError(String),
#[error("API error: {0}")]
ApiError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vulnerability {
pub id: String,
pub package: String,
pub severity: Severity,
pub title: String,
pub description: String,
pub url: String,
pub patched_in: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn from_string(s: &str) -> Self {
match s.to_lowercase().as_str() {
"low" => Severity::Low,
"medium" | "moderate" => Severity::Medium,
"high" => Severity::High,
"critical" => Severity::Critical,
_ => Severity::Low,
}
}
pub fn label(&self) -> &'static str {
match self {
Severity::Low => "LOW",
Severity::Medium => "MEDIUM",
Severity::High => "HIGH",
Severity::Critical => "CRITICAL",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
pub name: String,
pub version: String,
pub ecosystem: Ecosystem,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum Ecosystem {
Npm,
Cargo,
Pip,
Go,
Maven,
NuGet,
}
impl Ecosystem {
pub fn from_lockfile(path: &PathBuf) -> Option<Self> {
let name = path.file_name()?.to_str()?;
match name {
"package-lock.json" => Some(Ecosystem::Npm),
"Cargo.lock" => Some(Ecosystem::Cargo),
"Pipfile.lock" | "requirements.txt" => Some(Ecosystem::Pip),
"go.sum" | "go.mod" => Some(Ecosystem::Go),
"pom.xml" => Some(Ecosystem::Maven),
"packages.lock.json" | "packages.config" => Some(Ecosystem::NuGet),
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Ecosystem::Npm => "npm",
Ecosystem::Cargo => "cargo",
Ecosystem::Pip => "pip",
Ecosystem::Go => "go",
Ecosystem::Maven => "maven",
Ecosystem::NuGet => "nuget",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VulnScanResult {
pub ecosystem: Ecosystem,
pub dependencies: Vec<Dependency>,
pub vulnerabilities: Vec<Vulnerability>,
#[serde(default)]
pub scan_errors: Vec<String>,
pub scanned_at: i64,
}
impl VulnScanResult {
pub fn critical_count(&self) -> usize {
self.vulnerabilities.iter()
.filter(|v| v.severity == Severity::Critical)
.count()
}
pub fn high_count(&self) -> usize {
self.vulnerabilities.iter()
.filter(|v| v.severity == Severity::High)
.count()
}
pub fn has_vulnerabilities(&self) -> bool {
!self.vulnerabilities.is_empty()
}
pub fn is_complete(&self) -> bool {
self.scan_errors.is_empty()
}
pub fn summary(&self) -> String {
let total = self.vulnerabilities.len();
let err_count = self.scan_errors.len();
let err_suffix = if err_count > 0 {
format!(
" ⚠️ {} lookup{} FAILED — scan is INCOMPLETE",
err_count,
if err_count == 1 { "" } else { "s" }
)
} else {
String::new()
};
if total == 0 {
return if err_count == 0 {
"No vulnerabilities found ✅".to_string()
} else {
format!("No vulnerabilities found in successful lookups{}", err_suffix)
};
}
let critical = self.critical_count();
let high = self.high_count();
format!(
"Found {} vulnerabilities ({} critical, {} high){}",
total, critical, high, err_suffix
)
}
}
pub struct VulnScanner {
client: reqwest::Client,
}
impl VulnScanner {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
pub async fn scan_project(&self, project_path: &PathBuf) -> Result<VulnScanResult, VulnError> {
let lockfiles = self.find_lockfiles(project_path)?;
let mut all_vulns = Vec::new();
let mut all_deps = Vec::new();
let mut all_errors: Vec<String> = Vec::new();
let mut ecosystem = Ecosystem::Npm;
for lockfile in lockfiles {
if let Some(eco) = Ecosystem::from_lockfile(&lockfile) {
ecosystem = eco;
let (deps, vulns, errors) = self.scan_lockfile(&lockfile, eco).await?;
all_deps.extend(deps);
all_vulns.extend(vulns);
all_errors.extend(errors);
}
}
Ok(VulnScanResult {
ecosystem,
dependencies: all_deps,
vulnerabilities: all_vulns,
scan_errors: all_errors,
scanned_at: chrono::Utc::now().timestamp(),
})
}
fn find_lockfiles(&self, project_path: &PathBuf) -> Result<Vec<PathBuf>, VulnError> {
let mut lockfiles = Vec::new();
let lock_names = [
"package-lock.json",
"Cargo.lock",
"Pipfile.lock",
"requirements.txt",
"go.sum",
"go.mod",
"pom.xml",
"packages.lock.json",
"packages.config",
"Gemfile.lock",
];
for name in &lock_names {
let path = project_path.join(name);
if path.exists() {
lockfiles.push(path);
}
}
Ok(lockfiles)
}
async fn scan_lockfile(
&self,
lockfile: &PathBuf,
ecosystem: Ecosystem,
) -> Result<(Vec<Dependency>, Vec<Vulnerability>, Vec<String>), VulnError> {
let content = std::fs::read_to_string(lockfile)?;
let name = lockfile
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
let deps = match (ecosystem, name) {
(Ecosystem::Cargo, _) => parse_cargo_lock(&content)?,
(Ecosystem::Npm, _) => parse_package_lock(&content),
(Ecosystem::Pip, "Pipfile.lock") => parse_pipfile_lock(&content),
(Ecosystem::Pip, "requirements.txt") => parse_requirements_txt(&content),
(Ecosystem::Go, "go.sum") => parse_go_sum(&content),
(Ecosystem::Go, "go.mod") => parse_go_mod(&content),
(Ecosystem::Maven, _) => parse_pom_xml(&content),
(Ecosystem::NuGet, "packages.lock.json") => parse_packages_lock_json(&content),
(Ecosystem::NuGet, "packages.config") => parse_packages_config(&content),
_ => return Ok((Vec::new(), Vec::new(), Vec::new())),
};
self.check_deps(&deps, ecosystem.name()).await
}
async fn check_deps(
&self,
deps: &[Dependency],
ecosystem: &str,
) -> Result<(Vec<Dependency>, Vec<Vulnerability>, Vec<String>), VulnError> {
let mut vulns = Vec::new();
let mut errors = Vec::new();
for dep in deps {
match self.check_osv(&dep.name, &dep.version, ecosystem).await {
Ok(found) => vulns.extend(found),
Err(e) => errors.push(format!("{}@{}: {}", dep.name, dep.version, e)),
}
}
Ok((deps.to_vec(), vulns, errors))
}
async fn check_osv(&self, package: &str, version: &str, ecosystem: &str) -> Result<Vec<Vulnerability>, VulnError> {
let query = serde_json::json!({
"package": {
"name": package,
"ecosystem": osv_ecosystem_label(ecosystem)
},
"version": version
});
let response = self.client
.post("https://api.osv.dev/v1/query")
.json(&query)
.send()
.await
.map_err(|e| VulnError::NetworkError(e.to_string()))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(VulnError::ApiError(format!(
"OSV {} for {}@{}: {}",
status,
package,
version,
body.chars().take(200).collect::<String>()
)));
}
let result: serde_json::Value = response.json().await
.map_err(|e| VulnError::NetworkError(format!("JSON parse error: {}", e)))?;
let mut vulns = Vec::new();
if let Some(vulns_arr) = result.get("vulns").and_then(|v| v.as_array()) {
for vuln in vulns_arr {
vulns.push(Vulnerability {
id: vuln.get("id").and_then(|i| i.as_str()).unwrap_or("unknown").to_string(),
package: package.to_string(),
severity: extract_severity(vuln),
title: vuln.get("summary").and_then(|s| s.as_str()).unwrap_or("No title").to_string(),
description: vuln.get("details").and_then(|d| d.as_str()).unwrap_or("").to_string(),
url: vuln.get("id").and_then(|i| i.as_str()).map(|id| format!("https://osv.dev/vulnerability/{}", id)).unwrap_or_default(),
patched_in: vuln.get("fixed_version").and_then(|v| v.as_str()).map(|s| s.to_string()),
});
}
}
Ok(vulns)
}
pub async fn quick_check(&self, package: &str, version: &str, ecosystem: Ecosystem) -> Result<Vec<Vulnerability>, VulnError> {
let eco_name = ecosystem.name();
self.check_osv(package, version, eco_name).await
}
}
impl Default for VulnScanner {
fn default() -> Self {
Self::new()
}
}
fn extract_severity(vuln: &serde_json::Value) -> Severity {
if let Some(label) = vuln
.get("database_specific")
.and_then(|d| d.get("severity"))
.and_then(|s| s.as_str())
{
let parsed = Severity::from_string(label);
if parsed != Severity::Low || matches!(label.to_lowercase().as_str(), "low") {
return parsed;
}
}
if let Some(arr) = vuln.get("severity").and_then(|s| s.as_array()) {
for entry in arr {
let ty = entry.get("type").and_then(|t| t.as_str()).unwrap_or("");
if ty == "CVSS_V3" || ty == "CVSS_V31" {
if let Some(vec) = entry.get("score").and_then(|s| s.as_str()) {
if let Some(score) = cvss_v3_base_score(vec) {
return cvss_score_to_severity(score);
}
}
}
}
}
Severity::Low
}
fn cvss_score_to_severity(score: f32) -> Severity {
if score >= 9.0 {
Severity::Critical
} else if score >= 7.0 {
Severity::High
} else if score >= 4.0 {
Severity::Medium
} else {
Severity::Low
}
}
fn cvss_v3_base_score(vector: &str) -> Option<f32> {
let mut parts = std::collections::HashMap::new();
for piece in vector.split('/') {
if let Some((k, v)) = piece.split_once(':') {
parts.insert(k, v);
}
}
if !parts.get("CVSS").map(|v| v.starts_with("3.")).unwrap_or(false) {
return None;
}
let av = match *parts.get("AV")? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.20,
_ => return None,
};
let ac = match *parts.get("AC")? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
let ui = match *parts.get("UI")? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let scope_changed = match *parts.get("S")? {
"U" => false,
"C" => true,
_ => return None,
};
let pr = match *parts.get("PR")? {
"N" => 0.85,
"L" => if scope_changed { 0.68 } else { 0.62 },
"H" => if scope_changed { 0.50 } else { 0.27 },
_ => return None,
};
let cia = |s: &str| -> Option<f32> {
match s {
"N" => Some(0.0),
"L" => Some(0.22),
"H" => Some(0.56),
_ => None,
}
};
let c = cia(parts.get("C")?)?;
let i = cia(parts.get("I")?)?;
let a = cia(parts.get("A")?)?;
let iss = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a));
let impact = if scope_changed {
7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15)
} else {
6.42 * iss
};
if impact <= 0.0 {
return Some(0.0);
}
let exploitability = 8.22 * av * ac * pr * ui;
let raw = if scope_changed {
(1.08 * (impact + exploitability)).min(10.0)
} else {
(impact + exploitability).min(10.0)
};
Some(cvss_round_up(raw))
}
fn cvss_round_up(x: f32) -> f32 {
let int_input = (x * 100_000.0).round() as i64;
if int_input % 10_000 == 0 {
int_input as f32 / 100_000.0
} else {
((int_input / 10_000) + 1) as f32 / 10.0
}
}
fn osv_ecosystem_label(ecosystem: &str) -> &'static str {
match ecosystem {
"cargo" => "crates.io",
"npm" => "npm",
"pip" => "PyPI",
"go" => "Go",
"maven" => "Maven",
"nuget" => "NuGet",
"rubygems" | "gem" => "RubyGems",
_ => "npm",
}
}
fn parse_cargo_lock(content: &str) -> Result<Vec<Dependency>, VulnError> {
#[derive(Deserialize)]
struct CargoLock {
#[serde(default)]
package: Vec<CargoLockPackage>,
}
#[derive(Deserialize)]
struct CargoLockPackage {
name: String,
version: String,
}
let parsed: CargoLock = toml::from_str(content)?;
Ok(parsed
.package
.into_iter()
.map(|p| Dependency {
name: p.name,
version: p.version,
ecosystem: Ecosystem::Cargo,
})
.collect())
}
fn parse_package_lock(content: &str) -> Vec<Dependency> {
let json: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
if let Some(packages) = json.get("packages").and_then(|p| p.as_object()) {
for (path, info) in packages {
if path.is_empty() {
continue;
}
let name = path
.rsplit("node_modules/")
.next()
.unwrap_or(path)
.to_string();
if name.is_empty() {
continue;
}
let version = info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("");
if version.is_empty() {
continue;
}
out.push(Dependency {
name,
version: version.to_string(),
ecosystem: Ecosystem::Npm,
});
}
return out;
}
if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) {
walk_v1_deps(deps, &mut out);
}
out
}
fn parse_pipfile_lock(content: &str) -> Vec<Dependency> {
let json: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for section in ["default", "develop"] {
let pkgs = match json.get(section).and_then(|v| v.as_object()) {
Some(p) => p,
None => continue,
};
for (name, info) in pkgs {
let v = info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim_start_matches("==")
.trim_start_matches('=');
if v.is_empty() {
continue;
}
out.push(Dependency {
name: name.clone(),
version: v.to_string(),
ecosystem: Ecosystem::Pip,
});
}
}
out
}
fn parse_requirements_txt(content: &str) -> Vec<Dependency> {
let mut out = Vec::new();
for raw_line in content.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() || line.starts_with("-e ") || line.starts_with("--") {
continue;
}
let (sep, len) = if let Some(i) = line.find("==") {
(i, 2)
} else if let Some(i) = line.find("~=") {
(i, 2)
} else if let Some(i) = line.find(">=") {
(i, 2)
} else {
continue;
};
let name = line[..sep]
.trim()
.split(|c: char| c.is_whitespace() || c == '[')
.next()
.unwrap_or("")
.to_string();
let version_part = line[sep + len..]
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
.next()
.unwrap_or("")
.trim();
if name.is_empty() || version_part.is_empty() {
continue;
}
out.push(Dependency {
name,
version: version_part.to_string(),
ecosystem: Ecosystem::Pip,
});
}
out
}
fn parse_go_sum(content: &str) -> Vec<Dependency> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for line in content.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
continue;
}
let name = parts[0];
let version = parts[1].trim_end_matches("/go.mod");
if version.is_empty() || version.contains("h1:") {
continue;
}
let key = format!("{}@{}", name, version);
if seen.insert(key) {
out.push(Dependency {
name: name.to_string(),
version: version.to_string(),
ecosystem: Ecosystem::Go,
});
}
}
out
}
fn parse_go_mod(content: &str) -> Vec<Dependency> {
let mut deps: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut in_require = false;
let mut in_replace = false;
for raw in content.lines() {
let line = raw.split("//").next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if line == "require (" {
in_require = true;
continue;
}
if line == "replace (" {
in_replace = true;
continue;
}
if line == ")" {
in_require = false;
in_replace = false;
continue;
}
if let Some(rest) = line.strip_prefix("require ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() >= 2 {
deps.insert(parts[0].to_string(), parts[1].to_string());
}
} else if in_require {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
deps.insert(parts[0].to_string(), parts[1].to_string());
}
} else if let Some(rest) = line.strip_prefix("replace ") {
apply_replace(rest, &mut deps);
} else if in_replace {
apply_replace(line, &mut deps);
}
}
deps.into_iter()
.map(|(name, version)| Dependency {
name,
version,
ecosystem: Ecosystem::Go,
})
.collect()
}
fn apply_replace(rest: &str, deps: &mut std::collections::HashMap<String, String>) {
let arrow = match rest.find("=>") {
Some(i) => i,
None => return,
};
let lhs = rest[..arrow].trim();
let rhs = rest[arrow + 2..].trim();
let lhs_parts: Vec<&str> = lhs.split_whitespace().collect();
let rhs_parts: Vec<&str> = rhs.split_whitespace().collect();
if lhs_parts.is_empty() || rhs_parts.len() < 2 {
return;
}
deps.insert(lhs_parts[0].to_string(), rhs_parts[1].to_string());
}
fn parse_pom_xml(content: &str) -> Vec<Dependency> {
let properties = collect_pom_properties(content);
let mut out = Vec::new();
let bytes = content.as_bytes();
let mut cursor = 0usize;
while let Some(start_rel) = content[cursor..].find("<dependency>") {
let start = cursor + start_rel + "<dependency>".len();
let end_rel = match content[start..].find("</dependency>") {
Some(i) => i,
None => break,
};
let block = &content[start..start + end_rel];
cursor = start + end_rel + "</dependency>".len();
let group = take_xml_text(block, "groupId").unwrap_or_default();
let artifact = take_xml_text(block, "artifactId").unwrap_or_default();
let version_raw = take_xml_text(block, "version").unwrap_or_default();
let version = resolve_pom_property(&version_raw, &properties);
if artifact.is_empty() || version.is_empty() {
continue;
}
let name = if group.is_empty() {
artifact
} else {
format!("{}:{}", group, artifact)
};
out.push(Dependency {
name,
version,
ecosystem: Ecosystem::Maven,
});
}
let _ = bytes; out
}
fn collect_pom_properties(content: &str) -> std::collections::HashMap<String, String> {
let mut props = std::collections::HashMap::new();
let block_start = match content.find("<properties>") {
Some(i) => i + "<properties>".len(),
None => return props,
};
let block_end = content[block_start..]
.find("</properties>")
.map(|i| block_start + i)
.unwrap_or(content.len());
let block = &content[block_start..block_end];
let mut cursor = 0usize;
while let Some(open_rel) = block[cursor..].find('<') {
let open = cursor + open_rel + 1;
let close_rel = match block[open..].find('>') {
Some(i) => i,
None => break,
};
let tag = &block[open..open + close_rel];
if tag.starts_with('/') {
cursor = open + close_rel;
continue;
}
let value_start = open + close_rel + 1;
let close_tag = format!("</{}>", tag);
let value_end_rel = match block[value_start..].find(&close_tag) {
Some(i) => i,
None => {
cursor = value_start;
continue;
}
};
let value = block[value_start..value_start + value_end_rel].trim();
if !tag.contains(' ') {
props.insert(tag.to_string(), value.to_string());
}
cursor = value_start + value_end_rel + close_tag.len();
}
props
}
fn resolve_pom_property(
raw: &str,
props: &std::collections::HashMap<String, String>,
) -> String {
let trimmed = raw.trim();
if let Some(name) = trimmed.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
if let Some(v) = props.get(name) {
return v.clone();
}
}
trimmed.to_string()
}
fn take_xml_text(block: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let s = block.find(&open)? + open.len();
let e = block[s..].find(&close)?;
Some(block[s..s + e].trim().to_string())
}
fn parse_packages_lock_json(content: &str) -> Vec<Dependency> {
let json: serde_json::Value = match serde_json::from_str(content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
let frameworks = match json.get("dependencies").and_then(|d| d.as_object()) {
Some(o) => o,
None => return out,
};
for (_framework, pkgs) in frameworks {
let pkgs = match pkgs.as_object() {
Some(o) => o,
None => continue,
};
for (name, info) in pkgs {
let version = info
.get("resolved")
.and_then(|r| r.as_str())
.unwrap_or("");
if version.is_empty() {
continue;
}
let key = format!("{}@{}", name, version);
if seen.insert(key) {
out.push(Dependency {
name: name.clone(),
version: version.to_string(),
ecosystem: Ecosystem::NuGet,
});
}
}
}
out
}
fn parse_packages_config(content: &str) -> Vec<Dependency> {
let mut out = Vec::new();
let mut cursor = 0usize;
while let Some(start_rel) = content[cursor..].find("<package ") {
let start = cursor + start_rel;
let end_rel = match content[start..].find("/>") {
Some(i) => i,
None => break,
};
let tag = &content[start..start + end_rel];
cursor = start + end_rel + 2;
let id = attr_value(tag, "id");
let version = attr_value(tag, "version");
if let (Some(id), Some(v)) = (id, version) {
out.push(Dependency {
name: id,
version: v,
ecosystem: Ecosystem::NuGet,
});
}
}
out
}
fn attr_value(tag: &str, name: &str) -> Option<String> {
let key = format!("{}=\"", name);
let s = tag.find(&key)? + key.len();
let e = tag[s..].find('"')?;
Some(tag[s..s + e].to_string())
}
fn walk_v1_deps(map: &serde_json::Map<String, serde_json::Value>, out: &mut Vec<Dependency>) {
for (name, info) in map {
let version = info
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("");
if !version.is_empty() {
out.push(Dependency {
name: name.clone(),
version: version.to_string(),
ecosystem: Ecosystem::Npm,
});
}
if let Some(nested) = info.get("dependencies").and_then(|d| d.as_object()) {
walk_v1_deps(nested, out);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_ordering() {
assert!(Severity::Low < Severity::Medium);
assert!(Severity::Medium < Severity::High);
assert!(Severity::High < Severity::Critical);
}
#[test]
fn test_severity_from_string() {
assert_eq!(Severity::from_string("critical"), Severity::Critical);
assert_eq!(Severity::from_string("HIGH"), Severity::High);
assert_eq!(Severity::from_string("moderate"), Severity::Medium);
assert_eq!(Severity::from_string("unknown"), Severity::Low);
}
#[test]
fn test_severity_label() {
assert_eq!(Severity::Critical.label(), "CRITICAL");
assert_eq!(Severity::Low.label(), "LOW");
}
#[test]
fn test_ecosystem_from_lockfile() {
assert_eq!(Ecosystem::from_lockfile(&PathBuf::from("Cargo.lock")), Some(Ecosystem::Cargo));
assert_eq!(Ecosystem::from_lockfile(&PathBuf::from("package-lock.json")), Some(Ecosystem::Npm));
assert_eq!(Ecosystem::from_lockfile(&PathBuf::from("random.txt")), None);
}
#[test]
fn test_vuln_scan_result_summary() {
let result = VulnScanResult {
ecosystem: Ecosystem::Cargo,
dependencies: vec![],
vulnerabilities: vec![],
scan_errors: vec![],
scanned_at: 0,
};
assert_eq!(result.summary(), "No vulnerabilities found ✅");
assert!(result.is_complete());
let result = VulnScanResult {
ecosystem: Ecosystem::Cargo,
dependencies: vec![],
vulnerabilities: vec![
Vulnerability {
id: "1".to_string(),
package: "test".to_string(),
severity: Severity::Critical,
title: "Test".to_string(),
description: "".to_string(),
url: "".to_string(),
patched_in: None,
},
Vulnerability {
id: "2".to_string(),
package: "test".to_string(),
severity: Severity::High,
title: "Test".to_string(),
description: "".to_string(),
url: "".to_string(),
patched_in: None,
},
],
scan_errors: vec![],
scanned_at: 0,
};
assert_eq!(result.summary(), "Found 2 vulnerabilities (1 critical, 1 high)");
}
#[test]
fn summary_flags_partial_scans_with_warning() {
let mut result = VulnScanResult {
ecosystem: Ecosystem::Cargo,
dependencies: vec![],
vulnerabilities: vec![],
scan_errors: vec!["foo@1.0: 503".to_string()],
scanned_at: 0,
};
assert!(!result.is_complete());
let s = result.summary();
assert!(!s.contains('✅'), "got: {}", s);
assert!(s.contains("INCOMPLETE"), "got: {}", s);
assert!(s.contains("FAILED"), "got: {}", s);
result.vulnerabilities.push(Vulnerability {
id: "x".into(),
package: "p".into(),
severity: Severity::Critical,
title: "t".into(),
description: "".into(),
url: "".into(),
patched_in: None,
});
let s = result.summary();
assert!(s.contains("Found 1 vulnerabilities"), "got: {}", s);
assert!(s.contains("INCOMPLETE"), "got: {}", s);
}
#[test]
fn extract_severity_prefers_database_specific_label() {
let v = serde_json::json!({
"database_specific": { "severity": "HIGH" },
"severity": [{ "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N" }]
});
assert_eq!(extract_severity(&v), Severity::High);
}
#[test]
fn extract_severity_falls_back_to_cvss() {
let v = serde_json::json!({
"severity": [{
"type": "CVSS_V3",
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
}]
});
assert_eq!(extract_severity(&v), Severity::Critical);
}
#[test]
fn extract_severity_handles_missing_data() {
let v = serde_json::json!({});
assert_eq!(extract_severity(&v), Severity::Low);
}
#[test]
fn extract_severity_ignores_unknown_label_falls_through() {
let v = serde_json::json!({
"database_specific": { "severity": "WHATEVER" },
"severity": [{
"type": "CVSS_V3",
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
}]
});
assert_eq!(extract_severity(&v), Severity::Critical);
}
#[test]
fn cvss_score_to_severity_buckets() {
assert_eq!(cvss_score_to_severity(0.0), Severity::Low);
assert_eq!(cvss_score_to_severity(3.9), Severity::Low);
assert_eq!(cvss_score_to_severity(4.0), Severity::Medium);
assert_eq!(cvss_score_to_severity(6.9), Severity::Medium);
assert_eq!(cvss_score_to_severity(7.0), Severity::High);
assert_eq!(cvss_score_to_severity(8.9), Severity::High);
assert_eq!(cvss_score_to_severity(9.0), Severity::Critical);
assert_eq!(cvss_score_to_severity(10.0), Severity::Critical);
}
fn approx_eq(a: f32, b: f32) -> bool {
(a - b).abs() < 0.05
}
#[test]
fn cvss_v3_base_score_known_vectors() {
let s = cvss_v3_base_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").unwrap();
assert!(approx_eq(s, 9.8), "got {}", s);
let s = cvss_v3_base_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N").unwrap();
assert!(approx_eq(s, 5.3), "got {}", s);
let s = cvss_v3_base_score("CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H").unwrap();
assert!(s >= 9.0 && s <= 10.0, "got {}", s);
let s = cvss_v3_base_score("CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:N/I:N/A:N").unwrap();
assert!(approx_eq(s, 0.0), "got {}", s);
}
#[test]
fn cvss_v3_rejects_v2_and_garbage() {
assert!(cvss_v3_base_score("AV:N/AC:L/Au:N/C:P/I:P/A:P").is_none());
assert!(cvss_v3_base_score("not a vector").is_none());
assert!(cvss_v3_base_score("CVSS:3.1/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H").is_none());
}
#[test]
fn cvss_round_up_avoids_fp_drift() {
assert!(approx_eq(cvss_round_up(6.9999999), 7.0));
assert!(approx_eq(cvss_round_up(7.0), 7.0));
assert!(approx_eq(cvss_round_up(7.01), 7.1));
}
#[test]
fn parse_cargo_lock_extracts_packages() {
let lock = r#"
version = 3
[[package]]
name = "anyhow"
version = "1.0.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abcd"
[[package]]
name = "tokio"
version = "1.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
let deps = parse_cargo_lock(lock).expect("parses");
assert_eq!(deps.len(), 2);
assert!(deps.iter().any(|d| d.name == "anyhow" && d.version == "1.0.79"));
assert!(deps.iter().any(|d| d.name == "tokio" && d.version == "1.35.1"));
}
#[test]
fn parse_cargo_lock_handles_empty() {
let deps = parse_cargo_lock("version = 3\n").expect("parses");
assert!(deps.is_empty());
}
#[test]
fn parse_cargo_lock_rejects_garbage() {
assert!(parse_cargo_lock("[[ not toml ::").is_err());
}
#[test]
fn parse_package_lock_v3_extracts_packages() {
let lock = r#"
{
"name": "myapp",
"lockfileVersion": 3,
"packages": {
"": { "name": "myapp", "version": "1.0.0" },
"node_modules/lodash": { "version": "4.17.21" },
"node_modules/express": { "version": "4.18.2" },
"node_modules/express/node_modules/qs": { "version": "6.11.0" }
}
}
"#;
let deps = parse_package_lock(lock);
assert_eq!(deps.len(), 3, "got {:?}", deps);
assert!(deps.iter().any(|d| d.name == "lodash" && d.version == "4.17.21"));
assert!(deps.iter().any(|d| d.name == "qs" && d.version == "6.11.0"));
}
#[test]
fn parse_package_lock_v1_walks_recursive_dependencies() {
let lock = r#"
{
"lockfileVersion": 1,
"dependencies": {
"lodash": {
"version": "4.17.21"
},
"express": {
"version": "4.18.2",
"dependencies": {
"qs": { "version": "6.11.0" }
}
}
}
}
"#;
let deps = parse_package_lock(lock);
assert_eq!(deps.len(), 3);
assert!(deps.iter().any(|d| d.name == "qs" && d.version == "6.11.0"));
}
#[test]
fn parse_package_lock_handles_invalid_json() {
assert!(parse_package_lock("not json").is_empty());
}
#[test]
fn osv_ecosystem_labels() {
assert_eq!(osv_ecosystem_label("cargo"), "crates.io");
assert_eq!(osv_ecosystem_label("pip"), "PyPI");
assert_eq!(osv_ecosystem_label("npm"), "npm");
assert_eq!(osv_ecosystem_label("nuget"), "NuGet");
}
#[test]
fn parse_pipfile_lock_extracts_default_and_dev() {
let lock = r#"
{
"_meta": {"hash": {"sha256": "..."}},
"default": {
"requests": {"version": "==2.31.0", "hashes": []},
"urllib3": {"version": "==2.0.7"}
},
"develop": {
"pytest": {"version": "==7.4.0"}
}
}
"#;
let deps = parse_pipfile_lock(lock);
assert_eq!(deps.len(), 3);
assert!(deps.iter().any(|d| d.name == "requests" && d.version == "2.31.0"));
assert!(deps.iter().any(|d| d.name == "pytest" && d.version == "7.4.0"));
assert!(deps.iter().all(|d| d.ecosystem == Ecosystem::Pip));
}
#[test]
fn parse_requirements_txt_handles_pins_and_comments() {
let req = "requests==2.31.0 # http client\n\
urllib3>=2.0.7\n\
# comment-only line\n\
pytest~=7.4.0\n\
-e ./local-package\n\
--extra-index-url https://example.com\n\
numpy\n"; let deps = parse_requirements_txt(req);
assert_eq!(deps.len(), 3);
assert!(deps.iter().any(|d| d.name == "requests" && d.version == "2.31.0"));
assert!(deps.iter().any(|d| d.name == "urllib3" && d.version == "2.0.7"));
assert!(deps.iter().any(|d| d.name == "pytest" && d.version == "7.4.0"));
assert!(!deps.iter().any(|d| d.name == "numpy"));
}
#[test]
fn parse_go_sum_dedupes_module_and_gomod_lines() {
let sum = "github.com/foo/bar v1.2.3 h1:abc=\n\
github.com/foo/bar v1.2.3/go.mod h1:xyz=\n\
golang.org/x/net v0.10.0 h1:def=\n\
golang.org/x/net v0.10.0/go.mod h1:ghi=\n";
let deps = parse_go_sum(sum);
assert_eq!(deps.len(), 2);
assert!(deps.iter().any(|d| d.name == "github.com/foo/bar" && d.version == "v1.2.3"));
}
#[test]
fn parse_go_mod_handles_block_form_and_replace() {
let m = r#"
module example.com/myapp
go 1.21
require github.com/single v0.1.0
require (
github.com/foo/bar v1.2.3
golang.org/x/net v0.10.0 // indirect
)
replace github.com/foo/bar => github.com/myfork/bar v9.9.9
"#;
let deps = parse_go_mod(m);
let foo = deps.iter().find(|d| d.name == "github.com/foo/bar").unwrap();
assert_eq!(foo.version, "v9.9.9", "replace directive should override version");
assert!(deps.iter().any(|d| d.name == "github.com/single" && d.version == "v0.1.0"));
assert!(deps.iter().any(|d| d.name == "golang.org/x/net" && d.version == "v0.10.0"));
}
#[test]
fn parse_pom_xml_resolves_property_substitution() {
let pom = r#"<?xml version="1.0"?>
<project>
<properties>
<jackson.version>2.15.2</jackson.version>
<slf4j.version>1.7.36</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.6</version>
</dependency>
</dependencies>
</project>"#;
let deps = parse_pom_xml(pom);
assert_eq!(deps.len(), 3);
let jackson = deps.iter().find(|d| d.name.contains("jackson-databind")).unwrap();
assert_eq!(jackson.version, "2.15.2");
assert!(deps.iter().any(|d| d.name == "commons-lang:commons-lang" && d.version == "2.6"));
}
#[test]
fn parse_packages_lock_json_dedupes_across_frameworks() {
let lock = r#"{
"version": 1,
"dependencies": {
"net6.0": {
"Newtonsoft.Json": {"type": "Direct", "resolved": "13.0.1"},
"Serilog": {"type": "Direct", "resolved": "2.12.0"}
},
"net7.0": {
"Newtonsoft.Json": {"type": "Direct", "resolved": "13.0.1"}
}
}
}"#;
let deps = parse_packages_lock_json(lock);
assert_eq!(deps.len(), 2, "duplicates across frameworks should collapse");
assert!(deps.iter().any(|d| d.name == "Newtonsoft.Json" && d.version == "13.0.1"));
}
#[test]
fn parse_packages_config_legacy_format() {
let cfg = r#"<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="EntityFramework" version="6.4.4" targetFramework="net48" />
<package id="Newtonsoft.Json" version="12.0.3" targetFramework="net48" />
</packages>"#;
let deps = parse_packages_config(cfg);
assert_eq!(deps.len(), 2);
assert!(deps.iter().any(|d| d.name == "Newtonsoft.Json" && d.version == "12.0.3"));
assert!(deps.iter().all(|d| d.ecosystem == Ecosystem::NuGet));
}
#[test]
fn from_lockfile_recognizes_new_filenames() {
assert_eq!(
Ecosystem::from_lockfile(&PathBuf::from("requirements.txt")),
Some(Ecosystem::Pip)
);
assert_eq!(
Ecosystem::from_lockfile(&PathBuf::from("go.mod")),
Some(Ecosystem::Go)
);
assert_eq!(
Ecosystem::from_lockfile(&PathBuf::from("packages.lock.json")),
Some(Ecosystem::NuGet)
);
assert_eq!(
Ecosystem::from_lockfile(&PathBuf::from("pom.xml")),
Some(Ecosystem::Maven)
);
}
#[tokio::test]
#[ignore]
async fn osv_live_query_known_vulnerable_package() {
let scanner = VulnScanner::new();
let vulns = scanner
.quick_check("lodash", "4.17.20", Ecosystem::Npm)
.await
.expect("OSV query failed");
assert!(
!vulns.is_empty(),
"expected lodash@4.17.20 to have at least one OSV vulnerability"
);
for v in &vulns {
assert!(!v.id.is_empty(), "vuln has empty id: {:?}", v);
assert!(
v.url.is_empty() || v.url.contains("osv.dev"),
"url should reference osv.dev: {}",
v.url
);
assert_eq!(v.package, "lodash");
}
let any_non_low = vulns.iter().any(|v| v.severity != Severity::Low);
assert!(
any_non_low,
"all vulns parsed as Low — severity extractor likely regressed; got: {:?}",
vulns.iter().map(|v| (&v.id, v.severity)).collect::<Vec<_>>()
);
}
#[tokio::test]
#[ignore]
async fn osv_live_query_known_vulnerable_cargo_crate() {
let scanner = VulnScanner::new();
let vulns = scanner
.quick_check("time", "0.1.45", Ecosystem::Cargo)
.await
.expect("OSV query failed");
assert!(
!vulns.is_empty(),
"expected `time` 0.1.45 to have at least one OSV vulnerability \
(test will fail if OSV stops indexing it; see RUSTSEC-2020-0071)"
);
}
}