use super::AnalysisProvider;
use anyhow::{Context, Result};
use rma_common::{
Confidence, Finding, FindingCategory, Language, OsvEcosystem, OsvProviderConfig, Severity,
SourceLocation,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
struct ImportLocation {
file: PathBuf,
line: usize,
}
#[derive(Debug, Clone, Default)]
struct ImportInfo {
locations: Vec<ImportLocation>,
}
impl ImportInfo {
fn add_location(&mut self, file: PathBuf, line: usize) {
self.locations.push(ImportLocation { file, line });
}
fn hit_count(&self) -> usize {
self.locations.len()
}
fn sample_files(&self, n: usize) -> Vec<String> {
self.locations
.iter()
.take(n)
.map(|loc| format!("{}:{}", loc.file.display(), loc.line))
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageRef {
pub ecosystem: OsvEcosystem,
pub name: String,
pub version: String,
pub scope: DependencyScope,
pub source_file: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DependencyScope {
Runtime,
Dev,
Build,
Optional,
}
impl std::fmt::Display for DependencyScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DependencyScope::Runtime => write!(f, "runtime"),
DependencyScope::Dev => write!(f, "dev"),
DependencyScope::Build => write!(f, "build"),
DependencyScope::Optional => write!(f, "optional"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvVulnerability {
pub id: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub details: Option<String>,
#[serde(default)]
pub severity: Vec<OsvSeverity>,
#[serde(default)]
pub affected: Vec<OsvAffected>,
#[serde(default)]
pub references: Vec<OsvReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvSeverity {
#[serde(rename = "type")]
pub severity_type: String,
pub score: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvAffected {
#[serde(default)]
pub package: Option<OsvPackage>,
#[serde(default)]
pub ranges: Vec<OsvRange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvPackage {
pub ecosystem: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvRange {
#[serde(rename = "type")]
pub range_type: String,
#[serde(default)]
pub events: Vec<OsvEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvEvent {
#[serde(default)]
pub introduced: Option<String>,
#[serde(default)]
pub fixed: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OsvReference {
#[serde(rename = "type")]
pub ref_type: String,
pub url: String,
}
#[derive(Debug, Serialize)]
struct OsvBatchQuery {
queries: Vec<OsvQuery>,
}
#[derive(Debug, Serialize)]
struct OsvQuery {
package: OsvQueryPackage,
version: String,
}
#[derive(Debug, Serialize)]
struct OsvQueryPackage {
ecosystem: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct OsvBatchResponse {
results: Vec<OsvQueryResult>,
}
#[derive(Debug, Deserialize)]
struct OsvQueryResult {
#[serde(default)]
vulns: Vec<OsvVulnerability>,
}
#[derive(Debug, Serialize, Deserialize)]
struct CacheEntry {
vulns: Vec<OsvVulnerability>,
cached_at: u64,
}
pub struct OsvProvider {
config: OsvProviderConfig,
cache_dir: PathBuf,
cache_ttl: Duration,
}
impl Default for OsvProvider {
fn default() -> Self {
Self::new(OsvProviderConfig::default())
}
}
impl OsvProvider {
pub fn new(config: OsvProviderConfig) -> Self {
let cache_dir = config
.cache_dir
.clone()
.unwrap_or_else(|| PathBuf::from(".rma/cache/osv"));
let cache_ttl = parse_duration(&config.cache_ttl).unwrap_or(Duration::from_secs(86400));
Self {
config,
cache_dir,
cache_ttl,
}
}
pub fn scan_directory(&self, path: &Path) -> Result<Vec<Finding>> {
info!("OSV scanning directory: {}", path.display());
let packages = self.extract_dependencies(path)?;
info!("Found {} dependencies", packages.len());
if packages.is_empty() {
return Ok(Vec::new());
}
let vulns = self.query_vulnerabilities(&packages)?;
info!("Found {} vulnerabilities", vulns.len());
let findings = self.create_findings(&packages, &vulns, path)?;
Ok(findings)
}
fn extract_dependencies(&self, path: &Path) -> Result<Vec<PackageRef>> {
let mut packages = Vec::new();
if self
.config
.enabled_ecosystems
.contains(&OsvEcosystem::CratesIo)
{
let cargo_lock = path.join("Cargo.lock");
if cargo_lock.exists() {
packages.extend(self.parse_cargo_lock(&cargo_lock)?);
}
}
if self.config.enabled_ecosystems.contains(&OsvEcosystem::Npm) {
let package_lock = path.join("package-lock.json");
if package_lock.exists() {
packages.extend(self.parse_package_lock(&package_lock)?);
}
}
if self.config.enabled_ecosystems.contains(&OsvEcosystem::Go) {
let go_mod = path.join("go.mod");
let go_sum = path.join("go.sum");
if go_mod.exists() {
packages.extend(self.parse_go_mod(&go_mod, &go_sum)?);
}
}
if self.config.enabled_ecosystems.contains(&OsvEcosystem::PyPI) {
let requirements = path.join("requirements.txt");
if requirements.exists() {
packages.extend(self.parse_requirements_txt(&requirements)?);
}
let poetry_lock = path.join("poetry.lock");
if poetry_lock.exists() {
packages.extend(self.parse_poetry_lock(&poetry_lock)?);
}
}
if self
.config
.enabled_ecosystems
.contains(&OsvEcosystem::Maven)
{
let pom = path.join("pom.xml");
if pom.exists() {
packages.extend(self.parse_pom_xml(&pom)?);
}
let gradle = path.join("build.gradle");
if gradle.exists() {
packages.extend(self.parse_gradle(&gradle)?);
}
let gradle_kts = path.join("build.gradle.kts");
if gradle_kts.exists() {
packages.extend(self.parse_gradle(&gradle_kts)?);
}
}
if !self.config.include_dev_deps {
packages.retain(|p| p.scope != DependencyScope::Dev);
}
Ok(packages)
}
fn parse_cargo_lock(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
let lock: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if let Some(pkgs) = lock.get("package").and_then(|v| v.as_array()) {
for pkg in pkgs {
let name = pkg.get("name").and_then(|v| v.as_str());
let version = pkg.get("version").and_then(|v| v.as_str());
if let (Some(name), Some(version)) = (name, version) {
packages.push(PackageRef {
ecosystem: OsvEcosystem::CratesIo,
name: name.to_string(),
version: version.to_string(),
scope: DependencyScope::Runtime, source_file: path.to_path_buf(),
});
}
}
}
debug!("Parsed {} packages from Cargo.lock", packages.len());
Ok(packages)
}
fn parse_package_lock(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
let lock: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if let Some(deps) = lock.get("packages").and_then(|v| v.as_object()) {
for (key, value) in deps {
if key.is_empty() {
continue;
}
let name = key.strip_prefix("node_modules/").unwrap_or(key);
if let Some(version) = value.get("version").and_then(|v| v.as_str()) {
let is_dev = value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false);
packages.push(PackageRef {
ecosystem: OsvEcosystem::Npm,
name: name.to_string(),
version: version.to_string(),
scope: if is_dev {
DependencyScope::Dev
} else {
DependencyScope::Runtime
},
source_file: path.to_path_buf(),
});
}
}
} else if let Some(deps) = lock.get("dependencies").and_then(|v| v.as_object()) {
fn extract_deps(
deps: &serde_json::Map<String, serde_json::Value>,
packages: &mut Vec<PackageRef>,
path: &Path,
) {
for (name, value) in deps {
if let Some(version) = value.get("version").and_then(|v| v.as_str()) {
let is_dev = value.get("dev").and_then(|v| v.as_bool()).unwrap_or(false);
packages.push(PackageRef {
ecosystem: OsvEcosystem::Npm,
name: name.clone(),
version: version.to_string(),
scope: if is_dev {
DependencyScope::Dev
} else {
DependencyScope::Runtime
},
source_file: path.to_path_buf(),
});
}
if let Some(nested) = value.get("dependencies").and_then(|v| v.as_object()) {
extract_deps(nested, packages, path);
}
}
}
extract_deps(deps, &mut packages, path);
}
debug!("Parsed {} packages from package-lock.json", packages.len());
Ok(packages)
}
fn parse_go_mod(&self, go_mod: &Path, go_sum: &Path) -> Result<Vec<PackageRef>> {
let mut packages = Vec::new();
let content = fs::read_to_string(go_mod)?;
let mut in_require = false;
for line in content.lines() {
let line = line.trim();
if line.starts_with("require (") || line.starts_with("require(") {
in_require = true;
continue;
}
if in_require && line == ")" {
in_require = false;
continue;
}
if let Some(rest) = line.strip_prefix("require ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() >= 2 {
let name = parts[0].trim();
let version = parts[1].trim_start_matches('v');
packages.push(PackageRef {
ecosystem: OsvEcosystem::Go,
name: name.to_string(),
version: version.to_string(),
scope: DependencyScope::Runtime,
source_file: go_mod.to_path_buf(),
});
}
} else if in_require {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && !parts[0].starts_with("//") {
let name = parts[0].trim();
let version = parts[1].trim_start_matches('v');
let version = version.split_whitespace().next().unwrap_or(version);
packages.push(PackageRef {
ecosystem: OsvEcosystem::Go,
name: name.to_string(),
version: version.to_string(),
scope: DependencyScope::Runtime,
source_file: go_mod.to_path_buf(),
});
}
}
}
if go_sum.exists() {
debug!("go.sum exists, using versions from go.mod");
}
debug!("Parsed {} packages from go.mod", packages.len());
Ok(packages)
}
fn parse_requirements_txt(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('-') {
continue;
}
let (name, version) = if let Some(idx) = line.find("==") {
(&line[..idx], &line[idx + 2..])
} else if let Some(idx) = line.find(">=") {
(&line[..idx], &line[idx + 2..])
} else if let Some(idx) = line.find("~=") {
(&line[..idx], &line[idx + 2..])
} else {
continue;
};
let version = version.split('[').next().unwrap_or(version);
let version = version.split('#').next().unwrap_or(version);
let version = version.split(',').next().unwrap_or(version);
if !name.is_empty() && !version.is_empty() {
packages.push(PackageRef {
ecosystem: OsvEcosystem::PyPI,
name: name.trim().to_string(),
version: version.trim().to_string(),
scope: DependencyScope::Runtime,
source_file: path.to_path_buf(),
});
}
}
debug!("Parsed {} packages from requirements.txt", packages.len());
Ok(packages)
}
fn parse_poetry_lock(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
let lock: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
if let Some(pkgs) = lock.get("package").and_then(|v| v.as_array()) {
for pkg in pkgs {
let name = pkg.get("name").and_then(|v| v.as_str());
let version = pkg.get("version").and_then(|v| v.as_str());
let category = pkg
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("main");
if let (Some(name), Some(version)) = (name, version) {
packages.push(PackageRef {
ecosystem: OsvEcosystem::PyPI,
name: name.to_string(),
version: version.to_string(),
scope: if category == "dev" {
DependencyScope::Dev
} else {
DependencyScope::Runtime
},
source_file: path.to_path_buf(),
});
}
}
}
debug!("Parsed {} packages from poetry.lock", packages.len());
Ok(packages)
}
fn parse_pom_xml(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
let dependency_re =
regex::Regex::new(r"<dependency>\s*<groupId>([^<]+)</groupId>\s*<artifactId>([^<]+)</artifactId>\s*<version>([^<]+)</version>")
.unwrap();
for cap in dependency_re.captures_iter(&content) {
let group_id = &cap[1];
let artifact_id = &cap[2];
let version = &cap[3];
if version.starts_with('$') {
continue;
}
packages.push(PackageRef {
ecosystem: OsvEcosystem::Maven,
name: format!("{}:{}", group_id, artifact_id),
version: version.to_string(),
scope: DependencyScope::Runtime,
source_file: path.to_path_buf(),
});
}
debug!("Parsed {} packages from pom.xml", packages.len());
Ok(packages)
}
fn parse_gradle(&self, path: &Path) -> Result<Vec<PackageRef>> {
let content = fs::read_to_string(path)?;
let mut packages = Vec::new();
let dep_re = regex::Regex::new(
r#"(?:implementation|api|compile|runtimeOnly|testImplementation|testRuntimeOnly)\s*[\('"]+([^:]+):([^:]+):([^'")\s]+)"#,
)
.unwrap();
for cap in dep_re.captures_iter(&content) {
let group_id = &cap[1];
let artifact_id = &cap[2];
let version = &cap[3];
if version.starts_with('$') {
continue;
}
packages.push(PackageRef {
ecosystem: OsvEcosystem::Maven,
name: format!("{}:{}", group_id, artifact_id),
version: version.to_string(),
scope: DependencyScope::Runtime,
source_file: path.to_path_buf(),
});
}
debug!("Parsed {} packages from {}", packages.len(), path.display());
Ok(packages)
}
fn query_vulnerabilities(
&self,
packages: &[PackageRef],
) -> Result<HashMap<(String, String, String), Vec<OsvVulnerability>>> {
let mut results: HashMap<(String, String, String), Vec<OsvVulnerability>> = HashMap::new();
let mut uncached_packages = Vec::new();
for pkg in packages {
let cache_key = (
pkg.ecosystem.to_string(),
pkg.name.clone(),
pkg.version.clone(),
);
if let Some(vulns) = self.get_cached(&cache_key) {
results.insert(cache_key, vulns);
} else {
uncached_packages.push(pkg);
}
}
if uncached_packages.is_empty() {
debug!("All packages found in cache");
return Ok(results);
}
if self.config.offline {
warn!(
"Offline mode: skipping {} packages not in cache",
uncached_packages.len()
);
return Ok(results);
}
for chunk in uncached_packages.chunks(1000) {
let batch_results = self.osv_batch_query(chunk)?;
for (pkg, vulns) in chunk.iter().zip(batch_results.into_iter()) {
let cache_key = (
pkg.ecosystem.to_string(),
pkg.name.clone(),
pkg.version.clone(),
);
self.set_cached(&cache_key, &vulns)?;
results.insert(cache_key, vulns);
}
}
Ok(results)
}
fn osv_batch_query(&self, packages: &[&PackageRef]) -> Result<Vec<Vec<OsvVulnerability>>> {
let queries: Vec<OsvQuery> = packages
.iter()
.map(|pkg| OsvQuery {
package: OsvQueryPackage {
ecosystem: pkg.ecosystem.to_string(),
name: pkg.name.clone(),
},
version: pkg.version.clone(),
})
.collect();
let request = OsvBatchQuery { queries };
debug!("Querying OSV API for {} packages", packages.len());
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let response = client
.post("https://api.osv.dev/v1/querybatch")
.json(&request)
.send()
.context("Failed to query OSV API")?;
if !response.status().is_success() {
anyhow::bail!("OSV API returned error: {}", response.status());
}
let batch_response: OsvBatchResponse =
response.json().context("Failed to parse OSV response")?;
let results: Vec<Vec<OsvVulnerability>> = batch_response
.results
.into_iter()
.map(|r| {
r.vulns
.into_iter()
.filter_map(|vuln| {
if vuln.affected.is_empty() || vuln.summary.is_none() {
self.fetch_vulnerability_details(&vuln.id, &client).ok()
} else {
Some(vuln)
}
})
.collect()
})
.collect();
Ok(results)
}
fn fetch_vulnerability_details(
&self,
vuln_id: &str,
client: &reqwest::blocking::Client,
) -> Result<OsvVulnerability> {
let url = format!("https://api.osv.dev/v1/vulns/{}", vuln_id);
debug!("Fetching full details for {}", vuln_id);
let response = client
.get(&url)
.send()
.with_context(|| format!("Failed to fetch vulnerability {}", vuln_id))?;
if !response.status().is_success() {
anyhow::bail!(
"OSV API returned error for {}: {}",
vuln_id,
response.status()
);
}
let vuln: OsvVulnerability = response
.json()
.with_context(|| format!("Failed to parse vulnerability {}", vuln_id))?;
Ok(vuln)
}
fn get_cached(&self, key: &(String, String, String)) -> Option<Vec<OsvVulnerability>> {
let cache_file = self.cache_file_path(key);
if !cache_file.exists() {
return None;
}
let content = fs::read_to_string(&cache_file).ok()?;
let entry: CacheEntry = serde_json::from_str(&content).ok()?;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
if now - entry.cached_at > self.cache_ttl.as_secs() {
let _ = fs::remove_file(&cache_file);
return None;
}
Some(entry.vulns)
}
fn set_cached(&self, key: &(String, String, String), vulns: &[OsvVulnerability]) -> Result<()> {
let cache_file = self.cache_file_path(key);
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent)?;
}
let entry = CacheEntry {
vulns: vulns.to_vec(),
cached_at: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs(),
};
let content = serde_json::to_string(&entry)?;
fs::write(&cache_file, content)?;
Ok(())
}
fn cache_file_path(&self, key: &(String, String, String)) -> PathBuf {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
key.hash(&mut hasher);
let hash = hasher.finish();
self.cache_dir.join(format!("{:x}.json", hash))
}
fn create_findings(
&self,
packages: &[PackageRef],
vulns: &HashMap<(String, String, String), Vec<OsvVulnerability>>,
base_path: &Path,
) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
let imports = self.detect_imports(base_path)?;
for pkg in packages {
let key = (
pkg.ecosystem.to_string(),
pkg.name.clone(),
pkg.version.clone(),
);
if let Some(pkg_vulns) = vulns.get(&key) {
for vuln in pkg_vulns {
if self.config.ignore_list.contains(&vuln.id) {
continue;
}
if vuln
.aliases
.iter()
.any(|a| self.config.ignore_list.contains(a))
{
continue;
}
let severity = self.determine_severity(vuln);
let import_info =
self.find_package_imports(&pkg.name, &pkg.ecosystem, &imports);
let (confidence, reachability, import_hits, import_files_sample) =
if let Some(info) = import_info {
(
Confidence::High,
"imported",
info.hit_count(),
info.sample_files(3),
)
} else {
(Confidence::Medium, "present", 0, Vec::new())
};
let fix_version = self.get_fix_version(vuln);
let summary = vuln.summary.as_deref().unwrap_or("No summary available");
let message = format!(
"{} {} is vulnerable: {} ({}). {}",
pkg.ecosystem,
pkg.name,
summary,
vuln.id,
fix_version
.as_ref()
.map(|v| format!("Fixed in version {}", v))
.unwrap_or_else(|| "No fix available".to_string())
);
let language = match pkg.ecosystem {
OsvEcosystem::CratesIo => Language::Rust,
OsvEcosystem::Npm => Language::JavaScript,
OsvEcosystem::PyPI => Language::Python,
OsvEcosystem::Go => Language::Go,
OsvEcosystem::Maven => Language::Java,
};
let location = SourceLocation::new(pkg.source_file.clone(), 1, 1, 1, 1);
let mut properties = std::collections::HashMap::new();
properties.insert("reachability".to_string(), serde_json::json!(reachability));
properties.insert("import_hits".to_string(), serde_json::json!(import_hits));
if !import_files_sample.is_empty() {
properties.insert(
"import_files_sample".to_string(),
serde_json::json!(import_files_sample),
);
}
let mut finding = Finding {
id: format!("deps/osv/{}:{}:{}", vuln.id, pkg.source_file.display(), 1),
rule_id: format!("deps/osv/{}", vuln.id),
message,
severity,
location,
language,
snippet: Some(format!("{} = \"{}\"", pkg.name, pkg.version)),
suggestion: fix_version.map(|v| format!("Upgrade to version {}", v)),
fix: None,
confidence,
category: FindingCategory::Security,
fingerprint: None,
properties: Some(properties),
};
finding.compute_fingerprint();
findings.push(finding);
}
}
}
Ok(findings)
}
fn find_package_imports<'a>(
&self,
pkg_name: &str,
ecosystem: &OsvEcosystem,
imports: &'a HashMap<String, ImportInfo>,
) -> Option<&'a ImportInfo> {
match ecosystem {
OsvEcosystem::CratesIo => {
let normalized = pkg_name.replace('-', "_");
imports.get(&normalized).or_else(|| imports.get(pkg_name))
}
OsvEcosystem::Npm => {
imports.get(pkg_name)
}
OsvEcosystem::PyPI => {
let normalized = pkg_name.replace('-', "_").to_lowercase();
imports.get(&normalized).or_else(|| {
imports.get(pkg_name)
})
}
OsvEcosystem::Go => {
imports.get(pkg_name)
}
OsvEcosystem::Maven => {
if let Some(group) = pkg_name.split(':').next() {
imports.get(group).or_else(|| {
let parts: Vec<&str> = group.split('.').collect();
if parts.len() >= 2 {
let prefix = parts[..2].join(".");
imports.get(&prefix)
} else {
None
}
})
} else {
None
}
}
}
}
fn determine_severity(&self, vuln: &OsvVulnerability) -> Severity {
if let Some(sev) = self.config.severity_overrides.get(&vuln.id) {
return *sev;
}
for alias in &vuln.aliases {
if let Some(sev) = self.config.severity_overrides.get(alias) {
return *sev;
}
}
for sev in &vuln.severity {
if (sev.severity_type == "CVSS_V3" || sev.severity_type == "CVSS_V2")
&& let Ok(score) = sev.score.parse::<f32>()
{
return if score >= 9.0 {
Severity::Critical
} else if score >= 7.0 {
Severity::Error
} else if score >= 4.0 {
Severity::Warning
} else {
Severity::Info
};
}
}
Severity::Warning
}
fn get_fix_version(&self, vuln: &OsvVulnerability) -> Option<String> {
for affected in &vuln.affected {
for range in &affected.ranges {
for event in &range.events {
if let Some(fixed) = &event.fixed {
return Some(fixed.clone());
}
}
}
}
None
}
fn detect_imports(&self, path: &Path) -> Result<HashMap<String, ImportInfo>> {
let mut imports: HashMap<String, ImportInfo> = HashMap::new();
for entry in walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let file_path = entry.path();
let ext = file_path.extension().and_then(|e| e.to_str());
match ext {
Some("rs") => self.extract_rust_imports(file_path, &mut imports)?,
Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs") => {
self.extract_js_imports(file_path, &mut imports)?
}
Some("py") => self.extract_python_imports(file_path, &mut imports)?,
Some("go") => self.extract_go_imports(file_path, &mut imports)?,
Some("java") => self.extract_java_imports(file_path, &mut imports)?,
_ => {}
}
}
Ok(imports)
}
fn extract_rust_imports(
&self,
path: &Path,
imports: &mut HashMap<String, ImportInfo>,
) -> Result<()> {
let content = fs::read_to_string(path)?;
let use_re =
regex::Regex::new(r"(?m)^[\s]*(?:use|extern\s+crate)\s+([a-zA-Z_][a-zA-Z0-9_]*)")
.unwrap();
for (line_idx, line) in content.lines().enumerate() {
for cap in use_re.captures_iter(line) {
let crate_name = cap[1].to_string();
if !matches!(
crate_name.as_str(),
"std" | "core" | "alloc" | "self" | "super" | "crate"
) {
imports
.entry(crate_name)
.or_default()
.add_location(path.to_path_buf(), line_idx + 1);
}
}
}
Ok(())
}
fn extract_js_imports(
&self,
path: &Path,
imports: &mut HashMap<String, ImportInfo>,
) -> Result<()> {
let content = fs::read_to_string(path)?;
let import_re = regex::Regex::new(
r#"(?:import\s+(?:[^'"]*\s+from\s+)?|require\s*\(\s*)['"]([^'"]+)['"]"#,
)
.unwrap();
for (line_idx, line) in content.lines().enumerate() {
for cap in import_re.captures_iter(line) {
let module = &cap[1];
if module.starts_with('.') {
continue;
}
let pkg_name = Self::normalize_npm_package(module);
imports
.entry(pkg_name)
.or_default()
.add_location(path.to_path_buf(), line_idx + 1);
}
}
Ok(())
}
fn normalize_npm_package(module: &str) -> String {
if module.starts_with('@') {
module.split('/').take(2).collect::<Vec<_>>().join("/")
} else {
module.split('/').next().unwrap_or(module).to_string()
}
}
fn extract_python_imports(
&self,
path: &Path,
imports: &mut HashMap<String, ImportInfo>,
) -> Result<()> {
let content = fs::read_to_string(path)?;
let import_re =
regex::Regex::new(r"(?m)^[\s]*(?:from|import)\s+([a-zA-Z_][a-zA-Z0-9_]*)").unwrap();
for (line_idx, line) in content.lines().enumerate() {
for cap in import_re.captures_iter(line) {
let pkg_name = cap[1].to_string();
imports
.entry(pkg_name)
.or_default()
.add_location(path.to_path_buf(), line_idx + 1);
}
}
Ok(())
}
fn extract_go_imports(
&self,
path: &Path,
imports: &mut HashMap<String, ImportInfo>,
) -> Result<()> {
let content = fs::read_to_string(path)?;
let import_re = regex::Regex::new(r#"["']([^"']+)["']"#).unwrap();
let mut in_import_block = false;
for (line_idx, line) in content.lines().enumerate() {
let line_trimmed = line.trim();
if line_trimmed.starts_with("import (") || line_trimmed == "import(" {
in_import_block = true;
continue;
}
if in_import_block && line_trimmed == ")" {
in_import_block = false;
continue;
}
let should_check = in_import_block || line_trimmed.starts_with("import ");
if should_check {
for cap in import_re.captures_iter(line) {
let module_path = cap[1].to_string();
imports
.entry(module_path)
.or_default()
.add_location(path.to_path_buf(), line_idx + 1);
}
}
}
Ok(())
}
fn extract_java_imports(
&self,
path: &Path,
imports: &mut HashMap<String, ImportInfo>,
) -> Result<()> {
let content = fs::read_to_string(path)?;
let import_re =
regex::Regex::new(r"(?m)^[\s]*import\s+(?:static\s+)?([a-zA-Z_][a-zA-Z0-9_.]+)")
.unwrap();
for (line_idx, line) in content.lines().enumerate() {
for cap in import_re.captures_iter(line) {
let import_path = &cap[1];
let parts: Vec<&str> = import_path.split('.').collect();
if parts.len() >= 2 {
let group = parts[..2.min(parts.len())].join(".");
imports
.entry(group)
.or_default()
.add_location(path.to_path_buf(), line_idx + 1);
}
}
}
Ok(())
}
pub fn cache_path(&self) -> &Path {
&self.cache_dir
}
}
impl AnalysisProvider for OsvProvider {
fn name(&self) -> &'static str {
"osv"
}
fn description(&self) -> &'static str {
"Multi-language dependency vulnerability scanning via OSV.dev"
}
fn supports_language(&self, lang: Language) -> bool {
match lang {
Language::Rust => self
.config
.enabled_ecosystems
.contains(&OsvEcosystem::CratesIo),
Language::JavaScript | Language::TypeScript => {
self.config.enabled_ecosystems.contains(&OsvEcosystem::Npm)
}
Language::Python => self.config.enabled_ecosystems.contains(&OsvEcosystem::PyPI),
Language::Go => self.config.enabled_ecosystems.contains(&OsvEcosystem::Go),
Language::Java => self
.config
.enabled_ecosystems
.contains(&OsvEcosystem::Maven),
Language::Unknown => false,
}
}
fn is_available(&self) -> bool {
true
}
fn version(&self) -> Option<String> {
Some("1.0.0".to_string())
}
fn analyze_file(&self, _path: &Path) -> Result<Vec<Finding>> {
Ok(Vec::new())
}
fn analyze_directory(&self, path: &Path) -> Result<Vec<Finding>> {
self.scan_directory(path)
}
}
fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num_str, unit) = s.split_at(s.len().saturating_sub(1));
let num: u64 = num_str.parse().ok()?;
match unit {
"s" => Some(Duration::from_secs(num)),
"m" => Some(Duration::from_secs(num * 60)),
"h" => Some(Duration::from_secs(num * 3600)),
"d" => Some(Duration::from_secs(num * 86400)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration("24h"), Some(Duration::from_secs(86400)));
assert_eq!(parse_duration("7d"), Some(Duration::from_secs(604800)));
assert_eq!(parse_duration(""), None);
}
#[test]
fn test_parse_cargo_lock() {
let provider = OsvProvider::default();
let cargo_lock = r#"
[[package]]
name = "serde"
version = "1.0.193"
[[package]]
name = "anyhow"
version = "1.0.75"
"#;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("Cargo.lock");
fs::write(&lock_path, cargo_lock).unwrap();
let packages = provider.parse_cargo_lock(&lock_path).unwrap();
assert_eq!(packages.len(), 2);
assert_eq!(packages[0].name, "serde");
assert_eq!(packages[0].version, "1.0.193");
assert_eq!(packages[1].name, "anyhow");
}
#[test]
fn test_parse_package_lock() {
let provider = OsvProvider::default();
let package_lock = r#"{
"name": "test",
"lockfileVersion": 3,
"packages": {
"": {
"name": "test"
},
"node_modules/lodash": {
"version": "4.17.21"
},
"node_modules/express": {
"version": "4.18.2",
"dev": true
}
}
}"#;
let temp_dir = tempfile::tempdir().unwrap();
let lock_path = temp_dir.path().join("package-lock.json");
fs::write(&lock_path, package_lock).unwrap();
let packages = provider.parse_package_lock(&lock_path).unwrap();
assert_eq!(packages.len(), 2);
assert!(
packages
.iter()
.any(|p| p.name == "lodash" && p.scope == DependencyScope::Runtime)
);
assert!(
packages
.iter()
.any(|p| p.name == "express" && p.scope == DependencyScope::Dev)
);
}
#[test]
fn test_parse_go_mod() {
let provider = OsvProvider::default();
let go_mod = r#"
module example.com/myproject
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/stretchr/testify v1.8.4
)
require github.com/go-playground/validator/v10 v10.14.0
"#;
let temp_dir = tempfile::tempdir().unwrap();
let mod_path = temp_dir.path().join("go.mod");
let sum_path = temp_dir.path().join("go.sum");
fs::write(&mod_path, go_mod).unwrap();
let packages = provider.parse_go_mod(&mod_path, &sum_path).unwrap();
assert_eq!(packages.len(), 3);
assert!(
packages
.iter()
.any(|p| p.name == "github.com/gin-gonic/gin" && p.version == "1.9.1")
);
}
#[test]
fn test_provider_creation() {
let provider = OsvProvider::default();
assert!(provider.is_available());
assert!(provider.supports_language(Language::Rust));
assert!(provider.supports_language(Language::JavaScript));
assert!(provider.supports_language(Language::Python));
assert!(provider.supports_language(Language::Go));
assert!(provider.supports_language(Language::Java));
}
#[test]
fn test_npm_import_detection() {
let provider = OsvProvider::default();
let temp_dir = tempfile::tempdir().unwrap();
let js_content = r#"
import lodash from 'lodash';
import { get } from 'lodash/get';
import express from '@express/core';
const axios = require('axios');
const foo = require('@scope/package/subpath');
import './local-file';
"#;
let js_path = temp_dir.path().join("test.js");
fs::write(&js_path, js_content).unwrap();
let imports = provider.detect_imports(temp_dir.path()).unwrap();
assert!(imports.contains_key("lodash"), "Should detect lodash");
assert!(imports.contains_key("axios"), "Should detect axios");
assert!(
imports.contains_key("@express/core"),
"Should detect scoped package"
);
assert!(
imports.contains_key("@scope/package"),
"Should normalize scoped subpath to package"
);
assert!(
!imports.contains_key("./local-file"),
"Should skip relative imports"
);
}
#[test]
fn test_go_import_detection() {
let provider = OsvProvider::default();
let temp_dir = tempfile::tempdir().unwrap();
let go_content = r#"
package main
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
import "net/http"
"#;
let go_path = temp_dir.path().join("main.go");
fs::write(&go_path, go_content).unwrap();
let imports = provider.detect_imports(temp_dir.path()).unwrap();
assert!(imports.contains_key("fmt"), "Should detect fmt");
assert!(
imports.contains_key("github.com/gin-gonic/gin"),
"Should detect gin"
);
assert!(imports.contains_key("net/http"), "Should detect net/http");
}
#[test]
fn test_rust_import_detection() {
let provider = OsvProvider::default();
let temp_dir = tempfile::tempdir().unwrap();
let rs_content = r#"
use serde::Serialize;
use serde_json::Value;
extern crate anyhow;
use std::collections::HashMap;
use super::MyModule;
use crate::local;
"#;
let rs_path = temp_dir.path().join("lib.rs");
fs::write(&rs_path, rs_content).unwrap();
let imports = provider.detect_imports(temp_dir.path()).unwrap();
assert!(imports.contains_key("serde"), "Should detect serde");
assert!(
imports.contains_key("serde_json"),
"Should detect serde_json"
);
assert!(imports.contains_key("anyhow"), "Should detect extern crate");
assert!(!imports.contains_key("std"), "Should skip std");
assert!(!imports.contains_key("super"), "Should skip super");
assert!(!imports.contains_key("crate"), "Should skip crate");
}
#[test]
fn test_import_info_tracks_locations() {
let provider = OsvProvider::default();
let temp_dir = tempfile::tempdir().unwrap();
let js1 = temp_dir.path().join("a.js");
fs::write(&js1, "import lodash from 'lodash';").unwrap();
let js2 = temp_dir.path().join("b.js");
fs::write(&js2, "const _ = require('lodash');").unwrap();
let imports = provider.detect_imports(temp_dir.path()).unwrap();
let lodash_info = imports.get("lodash").expect("Should find lodash");
assert_eq!(lodash_info.hit_count(), 2, "Should have 2 import hits");
let samples = lodash_info.sample_files(3);
assert_eq!(samples.len(), 2, "Should have 2 sample files");
}
#[test]
fn test_normalize_npm_package() {
assert_eq!(
OsvProvider::normalize_npm_package("@scope/pkg"),
"@scope/pkg"
);
assert_eq!(
OsvProvider::normalize_npm_package("@scope/pkg/deep/path"),
"@scope/pkg"
);
assert_eq!(OsvProvider::normalize_npm_package("lodash"), "lodash");
assert_eq!(OsvProvider::normalize_npm_package("lodash/get"), "lodash");
assert_eq!(
OsvProvider::normalize_npm_package("lodash/fp/get"),
"lodash"
);
}
}