From 9b582e33ea71cd34b9102e2e6de44e87fbc75806 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:48:30 +0200 Subject: [PATCH 1/9] feat(core,deps): improve version range overlap and glob ignore logic - Rewrite VersionRange::overlaps_with for precise semver interval intersection - Add robust glob pattern matching for dependency ignore rules using globset - Update tests for edge cases in version overlap and glob ignore patterns Enables accurate vulnerability matching and flexible dependency analysis ignores. --- .gitignore | 4 +- Cargo.lock | 1 + .../src/domain/vulnerability/value_objects.rs | 341 +++++++++++++++++- .../vulnerability_advisor/mod.rs | 60 +-- vulnera-deps/Cargo.toml | 1 + .../src/application/analysis_context.rs | 69 +++- .../src/services/resolution_algorithms.rs | 248 ++++++++++--- 7 files changed, 631 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index e1e96a4f..3ed7923b 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,7 @@ coverage.xml local_settings.py db.sqlite3 db.sqlite3-journal - +.copilot-memory # Flask stuff: instance/ .webassets-cache @@ -152,7 +152,7 @@ criterion/ # Documentation /doc/ - +vulnera-sast/tests/snapshots/* # Cache directories /.vulnera_data curls.txt diff --git a/Cargo.lock b/Cargo.lock index bb3993dc..07e019c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7526,6 +7526,7 @@ dependencies = [ "chrono", "criterion", "futures", + "globset", "petgraph 0.8.3", "proptest", "rstest", diff --git a/vulnera-core/src/domain/vulnerability/value_objects.rs b/vulnera-core/src/domain/vulnerability/value_objects.rs index 7a05c71d..c50eb938 100644 --- a/vulnera-core/src/domain/vulnerability/value_objects.rs +++ b/vulnera-core/src/domain/vulnerability/value_objects.rs @@ -4,6 +4,308 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BoundType { + Inclusive, + Exclusive, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VersionBound { + version: semver::Version, + bound_type: BoundType, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct VersionInterval { + lower: Option, + upper: Option, +} + +impl VersionInterval { + fn any() -> Self { + Self { + lower: None, + upper: None, + } + } + + fn intersect(self, other: Self) -> Option { + let lower = match (self.lower, other.lower) { + (None, right) => right, + (left, None) => left, + (Some(left), Some(right)) => Some(select_max_lower(left, right)), + }; + + let upper = match (self.upper, other.upper) { + (None, right) => right, + (left, None) => left, + (Some(left), Some(right)) => Some(select_min_upper(left, right)), + }; + + let candidate = Self { lower, upper }; + if candidate.is_empty() { + None + } else { + Some(candidate) + } + } + + fn is_empty(&self) -> bool { + match (&self.lower, &self.upper) { + (Some(lower), Some(upper)) => { + if lower.version > upper.version { + return true; + } + + if lower.version < upper.version { + return false; + } + + lower.bound_type == BoundType::Exclusive || upper.bound_type == BoundType::Exclusive + } + _ => false, + } + } +} + +fn select_max_lower(left: VersionBound, right: VersionBound) -> VersionBound { + if left.version > right.version { + return left; + } + + if right.version > left.version { + return right; + } + + VersionBound { + version: left.version, + bound_type: if left.bound_type == BoundType::Exclusive + || right.bound_type == BoundType::Exclusive + { + BoundType::Exclusive + } else { + BoundType::Inclusive + }, + } +} + +fn select_min_upper(left: VersionBound, right: VersionBound) -> VersionBound { + if left.version < right.version { + return left; + } + + if right.version < left.version { + return right; + } + + VersionBound { + version: left.version, + bound_type: if left.bound_type == BoundType::Exclusive + || right.bound_type == BoundType::Exclusive + { + BoundType::Exclusive + } else { + BoundType::Inclusive + }, + } +} + +fn comparator_version(comparator: &semver::Comparator) -> semver::Version { + semver::Version { + major: comparator.major, + minor: comparator.minor.unwrap_or(0), + patch: comparator.patch.unwrap_or(0), + pre: comparator.pre.clone(), + build: semver::BuildMetadata::EMPTY, + } +} + +fn increment_patch(version: &semver::Version) -> semver::Version { + semver::Version { + major: version.major, + minor: version.minor, + patch: version.patch.saturating_add(1), + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + } +} + +fn increment_minor(version: &semver::Version) -> semver::Version { + semver::Version { + major: version.major, + minor: version.minor.saturating_add(1), + patch: 0, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + } +} + +fn increment_major(version: &semver::Version) -> semver::Version { + semver::Version { + major: version.major.saturating_add(1), + minor: 0, + patch: 0, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + } +} + +fn comparator_interval(comparator: &semver::Comparator) -> Option { + let base = comparator_version(comparator); + + match comparator.op { + semver::Op::Exact => Some(VersionInterval { + lower: Some(VersionBound { + version: base.clone(), + bound_type: BoundType::Inclusive, + }), + upper: Some(VersionBound { + version: base, + bound_type: BoundType::Inclusive, + }), + }), + semver::Op::Greater => Some(VersionInterval { + lower: Some(VersionBound { + version: base, + bound_type: BoundType::Exclusive, + }), + upper: None, + }), + semver::Op::GreaterEq => Some(VersionInterval { + lower: Some(VersionBound { + version: base, + bound_type: BoundType::Inclusive, + }), + upper: None, + }), + semver::Op::Less => Some(VersionInterval { + lower: None, + upper: Some(VersionBound { + version: base, + bound_type: BoundType::Exclusive, + }), + }), + semver::Op::LessEq => Some(VersionInterval { + lower: None, + upper: Some(VersionBound { + version: base, + bound_type: BoundType::Inclusive, + }), + }), + semver::Op::Tilde => { + let lower = VersionBound { + version: base.clone(), + bound_type: BoundType::Inclusive, + }; + + let upper_version = if comparator.minor.is_some() { + increment_minor(&base) + } else { + increment_major(&base) + }; + + Some(VersionInterval { + lower: Some(lower), + upper: Some(VersionBound { + version: upper_version, + bound_type: BoundType::Exclusive, + }), + }) + } + semver::Op::Caret => { + let lower = VersionBound { + version: base.clone(), + bound_type: BoundType::Inclusive, + }; + + let upper_version = if base.major > 0 { + increment_major(&base) + } else if base.minor > 0 { + increment_minor(&base) + } else { + increment_patch(&base) + }; + + Some(VersionInterval { + lower: Some(lower), + upper: Some(VersionBound { + version: upper_version, + bound_type: BoundType::Exclusive, + }), + }) + } + semver::Op::Wildcard => { + if comparator.minor.is_none() { + let lower = semver::Version { + major: comparator.major, + minor: 0, + patch: 0, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + }; + let upper = increment_major(&lower); + return Some(VersionInterval { + lower: Some(VersionBound { + version: lower, + bound_type: BoundType::Inclusive, + }), + upper: Some(VersionBound { + version: upper, + bound_type: BoundType::Exclusive, + }), + }); + } + + if comparator.patch.is_none() { + let lower = semver::Version { + major: comparator.major, + minor: comparator.minor.unwrap_or(0), + patch: 0, + pre: semver::Prerelease::EMPTY, + build: semver::BuildMetadata::EMPTY, + }; + let upper = increment_minor(&lower); + return Some(VersionInterval { + lower: Some(VersionBound { + version: lower, + bound_type: BoundType::Inclusive, + }), + upper: Some(VersionBound { + version: upper, + bound_type: BoundType::Exclusive, + }), + }); + } + + Some(VersionInterval { + lower: Some(VersionBound { + version: base.clone(), + bound_type: BoundType::Inclusive, + }), + upper: Some(VersionBound { + version: base, + bound_type: BoundType::Inclusive, + }), + }) + } + _ => None, + } +} + +fn req_interval(req: &semver::VersionReq) -> Option { + if req.comparators.is_empty() { + return Some(VersionInterval::any()); + } + + req.comparators + .iter() + .try_fold(VersionInterval::any(), |acc, comparator| { + let interval = comparator_interval(comparator)?; + acc.intersect(interval) + }) +} + /// Represents a semantic version using the semver crate for robust parsing and comparison. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -426,21 +728,11 @@ impl VersionRange { } /// Check if this range overlaps with another range - /// This is a simplified implementation - for full overlap detection, - /// you'd need more complex logic pub fn overlaps_with(&self, other: &VersionRange) -> bool { - // Simplified: if either accepts any version from a common test set - let test_versions = [ - "0.1.0", "0.9.0", "1.0.0", "1.1.0", "1.5.0", "2.0.0", "10.0.0", - ]; - - test_versions.iter().any(|v| { - if let Ok(version) = Version::parse(v) { - self.contains(&version) && other.contains(&version) - } else { - false - } - }) + match (req_interval(&self.0), req_interval(&other.0)) { + (Some(left), Some(right)) => left.intersect(right).is_some(), + _ => false, + } } } @@ -775,6 +1067,27 @@ mod tests { assert!(!range3.overlaps_with(&range1)); } + #[test] + fn test_version_range_overlap_with_adjacent_bounds() { + let left = VersionRange::parse(">=1.0.0, <2.0.0").unwrap(); + let right = VersionRange::parse(">=2.0.0, <3.0.0").unwrap(); + assert!(!left.overlaps_with(&right)); + + let inclusive_left = VersionRange::parse(">=1.0.0, <=2.0.0").unwrap(); + let inclusive_right = VersionRange::parse("=2.0.0").unwrap(); + assert!(inclusive_left.overlaps_with(&inclusive_right)); + } + + #[test] + fn test_version_range_overlap_with_caret_and_tilde() { + let caret = VersionRange::parse("^1.2.3").unwrap(); + let tilde = VersionRange::parse("~1.5.0").unwrap(); + let outside = VersionRange::parse(">=2.0.0").unwrap(); + + assert!(caret.overlaps_with(&tilde)); + assert!(!caret.overlaps_with(&outside)); + } + #[test] fn test_vulnerability_source_display() { assert_eq!(VulnerabilitySource::OSV.to_string(), "OSV"); diff --git a/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs b/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs index 9cc7be98..43ca2966 100644 --- a/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs +++ b/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs @@ -207,7 +207,7 @@ impl VulneraAdvisorRepository { fn convert_advisory( &self, advisory: Advisory, - queried_package: &Package, + queried_package: Option<&Package>, ) -> Result { let vuln_id = VulnerabilityId::new(advisory.id.clone()).map_err(|e| format!("Invalid ID: {}", e))?; @@ -308,7 +308,18 @@ impl VulneraAdvisorRepository { let default_version = fixed_versions .first() .cloned() - .unwrap_or_else(|| Version::parse("1.0.0").unwrap()); + .or_else(|| version_ranges.first().and_then(|range| { + let probe_versions = ["0.0.0", "0.1.0", "1.0.0", "2.0.0", "10.0.0"]; + for probe in probe_versions { + let parsed = Version::parse(probe).ok()?; + if range.contains(&parsed) { + return Some(parsed); + } + } + None + })) + .or_else(|| Version::parse("0.0.0").ok()) + .ok_or_else(|| "Failed to construct default package version".to_string())?; if let Ok(package) = Package::new(affected.package.name.clone(), default_version, ecosystem) @@ -320,10 +331,12 @@ impl VulneraAdvisorRepository { } } - // Filter to only packages matching the queried one and version - affected_packages.retain(|ap| { - ap.package.matches(queried_package) && ap.is_vulnerable(&queried_package.version) - }); + if let Some(queried_package) = queried_package { + // Filter to only packages matching the queried one and version + affected_packages.retain(|ap| { + ap.package.matches(queried_package) && ap.is_vulnerable(&queried_package.version) + }); + } if affected_packages.is_empty() { return Err("No affected packages match the queried package/version".to_string()); @@ -400,7 +413,7 @@ impl IVulnerabilityRepository for VulneraAdvisorRepository { // Convert advisories to domain vulnerabilities let mut vulnerabilities = Vec::with_capacity(advisories.len()); for advisory in advisories { - match self.convert_advisory(advisory, package) { + match self.convert_advisory(advisory, Some(package)) { Ok(vuln) => vulnerabilities.push(vuln), Err(e) => { warn!( @@ -420,21 +433,26 @@ impl IVulnerabilityRepository for VulneraAdvisorRepository { &self, id: &VulnerabilityId, ) -> Result, VulnerabilityError> { - // vulnera-advisor doesn't have a direct get-by-id method, - // so we need to search through the store or return None for now - // This is a limitation - in practice, most queries go through find_vulnerabilities - debug!( - "get_vulnerability_by_id called for {} - limited support", - id.as_str() - ); + debug!("Looking up vulnerability by ID: {}", id.as_str()); - // Try to get from store directly if it's a known ID format - // For now, return None as this is rarely used in practice - warn!( - "get_vulnerability_by_id not fully supported by vulnera-advisor adapter: {}", - id.as_str() - ); - Ok(None) + let advisory = self + .manager + .store() + .get(id.as_str()) + .await + .map_err(|error| VulnerabilityError::Repository { + message: format!("Failed to query advisory store for {}: {}", id.as_str(), error), + })?; + + let Some(advisory) = advisory else { + return Ok(None); + }; + + self.convert_advisory(advisory, None) + .map(Some) + .map_err(|error| VulnerabilityError::Repository { + message: format!("Failed to convert advisory {}: {}", id.as_str(), error), + }) } } diff --git a/vulnera-deps/Cargo.toml b/vulnera-deps/Cargo.toml index 91457037..ffb6271b 100644 --- a/vulnera-deps/Cargo.toml +++ b/vulnera-deps/Cargo.toml @@ -34,6 +34,7 @@ chrono = { workspace = true } # Version parsing semver = { workspace = true } petgraph = { workspace = true } +globset = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/vulnera-deps/src/application/analysis_context.rs b/vulnera-deps/src/application/analysis_context.rs index 5e3208f5..d6a2b00d 100644 --- a/vulnera-deps/src/application/analysis_context.rs +++ b/vulnera-deps/src/application/analysis_context.rs @@ -5,6 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use globset::{Glob, GlobSetBuilder}; use vulnera_core::domain::vulnerability::value_objects::Ecosystem; /// Configuration for dependency analysis @@ -88,27 +89,42 @@ impl AnalysisContext { /// Check if a path should be ignored based on ignore patterns pub fn should_ignore(&self, path: &Path) -> bool { - let path_str = path.to_string_lossy(); + let normalized = normalize_path(path); + let suffix = normalized + .split_once('/') + .map(|(_, rest)| rest) + .unwrap_or(normalized.as_str()); + for pattern in &self.config.ignore_patterns { - // Simple glob matching (could be enhanced with proper glob library) - if self.matches_pattern(&path_str, pattern) { + if self.matches_pattern(&normalized, suffix, pattern) { return true; } } false } - /// Simple pattern matching (supports ** and *) - fn matches_pattern(&self, path: &str, pattern: &str) -> bool { - // Convert pattern to regex-like matching - let pattern = pattern.replace("**", "___DOUBLE_STAR___"); - let pattern = pattern.replace('*', "___STAR___"); - let pattern = pattern.replace("___DOUBLE_STAR___", ".*"); - let pattern = pattern.replace("___STAR___", "[^/]*"); - - // Simple substring matching for now - // A full implementation would use proper glob matching - path.contains(&pattern.replace(".*", "").replace("[^/]*", "")) || pattern == ".*" + fn matches_pattern(&self, path: &str, suffix: &str, pattern: &str) -> bool { + let mut builder = GlobSetBuilder::new(); + let normalized_pattern = pattern.replace('\\', "/"); + + let glob = match Glob::new(&normalized_pattern) { + Ok(glob) => glob, + Err(_) => return false, + }; + + builder.add(glob); + + let prefixed_pattern = format!("**/{normalized_pattern}"); + if let Ok(prefixed_glob) = Glob::new(&prefixed_pattern) { + builder.add(prefixed_glob); + } + + let set = match builder.build() { + Ok(set) => set, + Err(_) => return false, + }; + + set.is_match(path) || set.is_match(suffix) } /// Update cache entry for a file @@ -136,6 +152,20 @@ impl AnalysisContext { } } +fn normalize_path(path: &Path) -> String { + path.components() + .filter_map(|component| { + let value = component.as_os_str().to_string_lossy(); + if value == "/" || value == "." { + None + } else { + Some(value.to_string()) + } + }) + .collect::>() + .join("/") +} + /// Detect workspace information from a directory pub fn detect_workspace(root: impl AsRef) -> Option { let root = root.as_ref(); @@ -196,6 +226,17 @@ mod tests { assert!(!ctx.should_ignore(Path::new("/tmp/test/src/main.rs"))); } + #[test] + fn test_should_ignore_glob_patterns() { + let mut config = AnalysisConfig::default(); + config.ignore_patterns = vec!["**/*.lock".to_string(), "src/generated/**".to_string()]; + let ctx = AnalysisContext::with_config("/tmp/test", config); + + assert!(ctx.should_ignore(Path::new("/tmp/test/Cargo.lock"))); + assert!(ctx.should_ignore(Path::new("/tmp/test/src/generated/types.rs"))); + assert!(!ctx.should_ignore(Path::new("/tmp/test/src/domain/mod.rs"))); + } + #[test] fn test_update_cache() { let mut ctx = AnalysisContext::new("/tmp/test"); diff --git a/vulnera-deps/src/services/resolution_algorithms.rs b/vulnera-deps/src/services/resolution_algorithms.rs index 6e9dec39..2b4c57af 100644 --- a/vulnera-deps/src/services/resolution_algorithms.rs +++ b/vulnera-deps/src/services/resolution_algorithms.rs @@ -37,69 +37,233 @@ impl BacktrackingResolver { let mut resolved = HashMap::new(); let mut conflicts = Vec::new(); - // Simple resolution: try to satisfy all constraints - // This is a simplified version - a full implementation would use proper backtracking - for (package_id, node) in &graph.nodes { - if let Some(versions) = available_versions.get(package_id) { - let incoming_constraints: Vec = graph - .edges - .iter() - .filter(|edge| edge.to == *package_id) - .map(|edge| edge.constraint.clone()) - .collect(); - - // Find a version that satisfies all constraints - if let Some(version) = Self::find_compatible_version( - package_id, - &node.direct_dependencies, - versions, - graph, - &incoming_constraints, - ) { - resolved.insert(package_id.clone(), version); - } else { - conflicts.push(ResolutionConflict { - package: package_id.clone(), - conflicting_constraints: incoming_constraints, - message: format!("No compatible version found for {}", package_id), - }); - } - } else { - // No versions available for this package + let constraints_by_package = Self::constraints_by_package(graph); + + let mut resolvable_packages = Vec::new(); + for package_id in graph.nodes.keys() { + let constraints = constraints_by_package + .get(package_id) + .cloned() + .unwrap_or_default(); + + let Some(versions) = available_versions.get(package_id) else { conflicts.push(ResolutionConflict { package: package_id.clone(), - conflicting_constraints: Vec::new(), + conflicting_constraints: constraints, message: format!("No versions available for {}", package_id), }); + continue; + }; + + let candidates = Self::compatible_versions(versions, &constraints); + if candidates.is_empty() { + conflicts.push(ResolutionConflict { + package: package_id.clone(), + conflicting_constraints: constraints, + message: format!("No compatible version found for {}", package_id), + }); + continue; + } + + resolvable_packages.push(package_id.clone()); + } + + let search_order = Self::ordered_packages( + &resolvable_packages, + available_versions, + &constraints_by_package, + graph, + ); + + let solved = Self::backtrack( + &search_order, + 0, + available_versions, + &constraints_by_package, + &mut resolved, + &mut conflicts, + ); + + if !solved { + // Keep any partial assignments found, and return collected conflicts. + // Conflicts are already captured during the search. + } + + ResolutionResult { resolved, conflicts } + } + + fn constraints_by_package(graph: &DependencyGraph) -> HashMap> { + let mut constraints: HashMap> = HashMap::new(); + for edge in &graph.edges { + constraints + .entry(edge.to.clone()) + .or_default() + .push(edge.constraint.clone()); + } + constraints + } + + fn ordered_packages( + packages: &[PackageId], + available_versions: &HashMap>, + constraints_by_package: &HashMap>, + graph: &DependencyGraph, + ) -> Vec { + let mut ordered = packages.to_vec(); + ordered.sort_by(|left, right| { + let left_constraints = constraints_by_package + .get(left) + .cloned() + .unwrap_or_default(); + let right_constraints = constraints_by_package + .get(right) + .cloned() + .unwrap_or_default(); + + let left_candidates = available_versions + .get(left) + .map(|v| Self::compatible_versions(v, &left_constraints).len()) + .unwrap_or(0); + let right_candidates = available_versions + .get(right) + .map(|v| Self::compatible_versions(v, &right_constraints).len()) + .unwrap_or(0); + + left_candidates + .cmp(&right_candidates) + .then_with(|| { + let left_degree = graph + .nodes + .get(left) + .map(|node| node.dependents.len() + node.direct_dependencies.len()) + .unwrap_or(0); + let right_degree = graph + .nodes + .get(right) + .map(|node| node.dependents.len() + node.direct_dependencies.len()) + .unwrap_or(0); + + right_degree.cmp(&left_degree) + }) + .then_with(|| left.to_string().cmp(&right.to_string())) + }); + ordered + } + + fn backtrack( + order: &[PackageId], + index: usize, + available_versions: &HashMap>, + constraints_by_package: &HashMap>, + assignments: &mut HashMap, + conflicts: &mut Vec, + ) -> bool { + if index >= order.len() { + return true; + } + + let package_id = &order[index]; + let constraints = constraints_by_package + .get(package_id) + .cloned() + .unwrap_or_default(); + + let Some(versions) = available_versions.get(package_id) else { + conflicts.push(ResolutionConflict { + package: package_id.clone(), + conflicting_constraints: constraints, + message: format!("No versions available for {}", package_id), + }); + return false; + }; + + let candidates = Self::compatible_versions(versions, &constraints); + if candidates.is_empty() { + conflicts.push(ResolutionConflict { + package: package_id.clone(), + conflicting_constraints: constraints, + message: format!("No compatible version found for {}", package_id), + }); + return false; + } + + for candidate in candidates { + assignments.insert(package_id.clone(), candidate.clone()); + + if Self::forward_check(order, index + 1, available_versions, constraints_by_package) { + if Self::backtrack( + order, + index + 1, + available_versions, + constraints_by_package, + assignments, + conflicts, + ) { + return true; + } } + + assignments.remove(package_id); } - ResolutionResult { - resolved, - conflicts, + conflicts.push(ResolutionConflict { + package: package_id.clone(), + conflicting_constraints: constraints, + message: format!( + "Backtracking exhausted all candidate versions for {}", + package_id + ), + }); + false + } + + fn forward_check( + order: &[PackageId], + next_index: usize, + available_versions: &HashMap>, + constraints_by_package: &HashMap>, + ) -> bool { + for package_id in &order[next_index..] { + let constraints = constraints_by_package + .get(package_id) + .cloned() + .unwrap_or_default(); + + let Some(versions) = available_versions.get(package_id) else { + return false; + }; + + if Self::compatible_versions(versions, &constraints).is_empty() { + return false; + } } + + true } - /// Find a compatible version for a package - fn find_compatible_version( - _package_id: &PackageId, - _dependencies: &[PackageId], + fn compatible_versions( versions: &[Version], - _graph: &DependencyGraph, constraints: &[VersionConstraint], - ) -> Option { + ) -> Vec { let combined_constraint = constraints .iter() .cloned() .try_fold(VersionConstraint::Any, |acc, current| { acc.intersect(¤t) - })?; + }); + + let Some(combined_constraint) = combined_constraint else { + return Vec::new(); + }; - versions + let mut candidates: Vec = versions .iter() .filter(|version| combined_constraint.satisfies(version)) - .max() .cloned() + .collect(); + candidates.sort(); + candidates.reverse(); + candidates } } From 7b4723e951c166a810d58361b9765209c53f22a4 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:02:07 +0200 Subject: [PATCH 2/9] Improve dependency target resolution in lockfile parsers - Resolve dependency edges to actual package versions in npm, Ruby, PHP, Python uv, and Rust lockfile parsers - Use pending dependency collection and second-pass resolution to link edges to concrete targets where possible - Infer versions for unresolved dependencies using version requirements or placeholders - Preserve dependency edges for git/path dependencies in Cargo parser for accurate graph analysis - Add helpers in dependency resolver for package keying, registry error mapping, and best version selection This enables more accurate and complete dependency graphs for multi-ecosystem analysis and vulnerability detection. --- .../src/infrastructure/parsers/npm.rs | 115 +++--- .../src/infrastructure/parsers/php.rs | 76 +++- .../src/infrastructure/parsers/python.rs | 71 +++- .../src/infrastructure/parsers/python_uv.rs | 42 ++- .../src/infrastructure/parsers/ruby.rs | 48 ++- .../src/infrastructure/parsers/rust.rs | 12 +- .../src/services/dependency_resolver.rs | 356 ++++++++++++++++-- 7 files changed, 578 insertions(+), 142 deletions(-) diff --git a/vulnera-core/src/infrastructure/parsers/npm.rs b/vulnera-core/src/infrastructure/parsers/npm.rs index 9ced75ca..768eefe8 100644 --- a/vulnera-core/src/infrastructure/parsers/npm.rs +++ b/vulnera-core/src/infrastructure/parsers/npm.rs @@ -222,6 +222,33 @@ impl PackageLockParser { Self } + fn resolve_dependency_target( + packages: &[Package], + dep_name: &str, + dep_req: &str, + ) -> Option { + let matching: Vec<&Package> = packages.iter().filter(|pkg| pkg.name == dep_name).collect(); + if matching.is_empty() { + return None; + } + + if let Ok(req) = semver::VersionReq::parse(dep_req) { + let best = matching + .iter() + .filter(|pkg| req.matches(&pkg.version.0)) + .max_by(|left, right| left.version.cmp(&right.version)); + + if let Some(package) = best { + return Some((*package).clone()); + } + } + + matching + .iter() + .max_by(|left, right| left.version.cmp(&right.version)) + .map(|package| (*package).clone()) + } + /// Extract packages and dependencies from lockfile /// /// # Arguments @@ -233,6 +260,7 @@ impl PackageLockParser { ) -> Result { let mut packages = Vec::new(); let mut dependencies = Vec::new(); + let mut pending_dependencies: Vec<(Package, String, String)> = Vec::new(); if let Some(deps_obj) = deps.as_object() { for (key, dep_info) in deps_obj { @@ -290,26 +318,11 @@ impl PackageLockParser { if let Some(reqs) = dep_info.get(key).and_then(|r| r.as_object()) { for (dep_name, dep_ver_val) in reqs { if let Some(dep_req) = dep_ver_val.as_str() { - // This is a logical dependency edge - // We create a dependency edge. The 'to' package version is not fully known here - // without resolving it against the whole tree, but we can create a placeholder - // or just use the requirement. - // For now, we'll use 0.0.0 as a placeholder for the 'to' package version if it's a range, - // effectively saying "depends on package X with requirement Y". - - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = Package::new( + pending_dependencies.push(( + package.clone(), dep_name.clone(), - dep_pkg_version, - Ecosystem::Npm, - ) { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.to_string(), - false, - )); - } + dep_req.to_string(), + )); } } } @@ -323,20 +336,11 @@ impl PackageLockParser { // Instead of checking only the first value, iterate over all values. for (dep_name, dep_ver_val) in deps_obj { if let Some(dep_req) = dep_ver_val.as_str() { - // This is a logical dependency edge - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = Package::new( + pending_dependencies.push(( + package.clone(), dep_name.clone(), - dep_pkg_version, - Ecosystem::Npm, - ) { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.to_string(), - false, - )); - } + dep_req.to_string(), + )); } } } @@ -363,6 +367,12 @@ impl PackageLockParser { } } + for (from, dep_name, dep_req) in pending_dependencies { + if let Some(target) = Self::resolve_dependency_target(&packages, &dep_name, &dep_req) { + dependencies.push(Dependency::new(from, target, dep_req, false)); + } + } + Ok(ParseResult { packages, dependencies, @@ -424,6 +434,7 @@ impl YarnLockParser { fn parse_yarn_lock(&self, content: &str) -> Result { let mut packages = Vec::new(); let mut dependencies = Vec::new(); + let mut pending_dependencies: Vec<(Package, String, String)> = Vec::new(); let mut current_package_names: Vec = Vec::new(); let mut current_version: Option = None; @@ -463,19 +474,11 @@ impl YarnLockParser { // Add dependencies for (dep_name, dep_req) in ¤t_dependencies { - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = Package::new( + pending_dependencies.push(( + package.clone(), dep_name.clone(), - dep_pkg_version, - Ecosystem::Npm, - ) { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.clone(), - false, - )); - } + dep_req.clone(), + )); } } } @@ -534,23 +537,25 @@ impl YarnLockParser { { packages.push(package.clone()); for (dep_name, dep_req) in ¤t_dependencies { - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = - Package::new(dep_name.clone(), dep_pkg_version, Ecosystem::Npm) - { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.clone(), - false, - )); - } + pending_dependencies.push(( + package.clone(), + dep_name.clone(), + dep_req.clone(), + )); } } } } } + for (from, dep_name, dep_req) in pending_dependencies { + if let Some(target) = + PackageLockParser::resolve_dependency_target(&packages, &dep_name, &dep_req) + { + dependencies.push(Dependency::new(from, target, dep_req, false)); + } + } + Ok(ParseResult { packages, dependencies, diff --git a/vulnera-core/src/infrastructure/parsers/php.rs b/vulnera-core/src/infrastructure/parsers/php.rs index 9c985e28..de6e9fce 100644 --- a/vulnera-core/src/infrastructure/parsers/php.rs +++ b/vulnera-core/src/infrastructure/parsers/php.rs @@ -8,6 +8,7 @@ use crate::domain::vulnerability::{ }; use async_trait::async_trait; use serde_json::Value; +use std::collections::HashMap; /// Parser for composer.json files pub struct ComposerParser; @@ -103,7 +104,7 @@ impl ComposerParser { cleaned }; - // Handle stability flags (remove -dev, -alpha, etc. for now) + // Handle stability flags (remove -dev, -alpha, etc.) let cleaned = if let Some(dash_pos) = cleaned.find('-') { let base_part = &cleaned[..dash_pos]; // Only keep the base if it looks like a version @@ -186,10 +187,34 @@ impl ComposerLockParser { Self } + fn infer_dependency_version(requirement: &str) -> Option { + let req = requirement.trim(); + if req.is_empty() || req == "*" { + return None; + } + + let cleaned = req + .trim_start_matches('^') + .trim_start_matches('~') + .trim_start_matches("<=") + .trim_start_matches(">=") + .trim_start_matches('<') + .trim_start_matches('>') + .trim_start_matches('=') + .split(['|', ',']) + .next() + .unwrap_or(req) + .trim(); + + let cleaned = cleaned.strip_prefix('v').unwrap_or(cleaned); + Version::parse(cleaned).ok() + } + /// Extract packages and dependencies from composer.lock fn extract_lock_data(&self, json: &Value, section: &str) -> Result { let mut packages = Vec::new(); let mut dependencies = Vec::new(); + let mut pending_dependencies: Vec<(Package, String, String)> = Vec::new(); if let Some(packages_array) = json.get(section).and_then(|p| p.as_array()) { for package_info in packages_array { @@ -232,20 +257,11 @@ impl ComposerLockParser { } if let Some(dep_req) = dep_req_val.as_str() { - // Create a placeholder package for the dependency target - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = Package::new( + pending_dependencies.push(( + package.clone(), dep_name.to_string(), - dep_pkg_version, - Ecosystem::Packagist, - ) { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.to_string(), - false, - )); - } + dep_req.to_string(), + )); } } } @@ -253,6 +269,38 @@ impl ComposerLockParser { } } + let mut package_by_name: HashMap = HashMap::new(); + for package in &packages { + package_by_name + .entry(package.name.clone()) + .and_modify(|current| { + if package.version > current.version { + *current = package.clone(); + } + }) + .or_insert_with(|| package.clone()); + } + + for (from, dep_name, dep_req) in pending_dependencies { + if let Some(existing_target) = package_by_name.get(&dep_name) { + dependencies.push(Dependency::new( + from, + existing_target.clone(), + dep_req, + false, + )); + continue; + } + + if let Some(inferred_version) = Self::infer_dependency_version(&dep_req) { + if let Ok(inferred_target) = + Package::new(dep_name, inferred_version, Ecosystem::Packagist) + { + dependencies.push(Dependency::new(from, inferred_target, dep_req, false)); + } + } + } + Ok(ParseResult { packages, dependencies, diff --git a/vulnera-core/src/infrastructure/parsers/python.rs b/vulnera-core/src/infrastructure/parsers/python.rs index a7194b26..30b6081d 100644 --- a/vulnera-core/src/infrastructure/parsers/python.rs +++ b/vulnera-core/src/infrastructure/parsers/python.rs @@ -33,8 +33,19 @@ impl RequirementsTxtParser { return Ok(None); } - // Skip URLs and VCS requirements for now - if line.starts_with("http") || line.starts_with("git+") || line.starts_with("-e ") { + if line.starts_with("-e ") || line.starts_with("git+") || line.contains(" @ http") { + if let Some((name, version_hint)) = self.parse_url_or_vcs_requirement(line) { + let normalized = self.normalize_python_version(&version_hint)?; + let version = + Version::parse(&normalized).map_err(|_| ParseError::Version { + version: version_hint.clone(), + })?; + + let package = Package::new(name, version, Ecosystem::PyPI) + .map_err(|e| ParseError::MissingField { field: e })?; + return Ok(Some(package)); + } + return Ok(None); } @@ -72,6 +83,62 @@ impl RequirementsTxtParser { Ok(Some(package)) } + fn parse_url_or_vcs_requirement(&self, line: &str) -> Option<(String, String)> { + static RE_EGG_NAME: Lazy = + Lazy::new(|| Regex::new(r"#egg=([A-Za-z0-9_.\-]+)").unwrap()); + static RE_WHEEL_NAME_VERSION: Lazy = Lazy::new(|| { + Regex::new(r"([A-Za-z0-9_.\-]+)-([0-9]+(?:\.[0-9]+){0,2}(?:[ab]|rc)?[0-9]*)") + .unwrap() + }); + + let requirement = line.trim_start_matches("-e ").trim(); + + if let Some((name, url)) = requirement.split_once(" @ ") { + let package_name = name.trim(); + if package_name.is_empty() { + return None; + } + + let version_hint = self + .extract_version_hint_from_url(url) + .unwrap_or_else(|| "0.0.0".to_string()); + return Some((package_name.to_string(), version_hint)); + } + + if let Some(captures) = RE_EGG_NAME.captures(requirement) { + let package_name = captures.get(1)?.as_str().to_string(); + let version_hint = self + .extract_version_hint_from_url(requirement) + .unwrap_or_else(|| "0.0.0".to_string()); + return Some((package_name, version_hint)); + } + + let filename = requirement.rsplit('/').next().unwrap_or(requirement); + if let Some(captures) = RE_WHEEL_NAME_VERSION.captures(filename) { + let name = captures.get(1)?.as_str().replace('_', "-"); + let version = captures.get(2)?.as_str().to_string(); + return Some((name, version)); + } + + None + } + + fn extract_version_hint_from_url(&self, url: &str) -> Option { + let token = url + .split('@') + .next_back()? + .split(['#', '?']) + .next()? + .trim(); + + if token.is_empty() || token.contains('/') { + return None; + } + + let candidate = token.strip_prefix('v').unwrap_or(token); + self.normalize_python_version(candidate).ok() + } + /// Clean Python version specifier fn clean_version_spec(&self, version_spec: &str) -> Result { let version_spec = version_spec.trim(); diff --git a/vulnera-core/src/infrastructure/parsers/python_uv.rs b/vulnera-core/src/infrastructure/parsers/python_uv.rs index b988f1d5..97733d4d 100644 --- a/vulnera-core/src/infrastructure/parsers/python_uv.rs +++ b/vulnera-core/src/infrastructure/parsers/python_uv.rs @@ -10,6 +10,7 @@ use crate::domain::vulnerability::{ value_objects::{Ecosystem, Version}, }; use async_trait::async_trait; +use std::collections::HashMap; /// Parser for uv.lock files /// @@ -33,6 +34,7 @@ impl UvLockParser { let mut packages = Vec::new(); let mut dependencies = Vec::new(); let mut seen_packages = std::collections::HashSet::new(); + let mut pending_dependencies: Vec<(Package, String, String)> = Vec::new(); // UV lockfiles have a [[package]] array if let Some(packages_array) = toml_value.get("package").and_then(|p| p.as_array()) { @@ -72,7 +74,7 @@ impl UvLockParser { packages.push(package.clone()); - // Extract dependencies + // Collect dependencies for second-pass target resolution if let Some(deps) = package_table.get("dependencies").and_then(|d| d.as_array()) { for dep_val in deps { @@ -80,23 +82,11 @@ impl UvLockParser { // dep_str is like "certifi>=2021" or "charset-normalizer<4,>=2" // We need to split name and requirement let (dep_name, dep_req) = self.parse_dependency_string(dep_str); - - // Create a placeholder package for the dependency target - // We use 0.0.0 as version since we don't know the resolved version here directly - // (though we could find it by looking up other packages, but that requires 2 passes) - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = Package::new( + pending_dependencies.push(( + package.clone(), dep_name.to_string(), - dep_pkg_version, - Ecosystem::PyPI, - ) { - dependencies.push(Dependency::new( - package.clone(), - dep_pkg, - dep_req.to_string(), - false, - )); - } + dep_req.to_string(), + )); } } } @@ -104,6 +94,24 @@ impl UvLockParser { } } + let mut package_by_name: HashMap = HashMap::new(); + for package in &packages { + package_by_name + .entry(package.name.clone()) + .and_modify(|current| { + if package.version > current.version { + *current = package.clone(); + } + }) + .or_insert_with(|| package.clone()); + } + + for (from, dep_name, dep_req) in pending_dependencies { + if let Some(to) = package_by_name.get(&dep_name) { + dependencies.push(Dependency::new(from, to.clone(), dep_req, false)); + } + } + Ok(ParseResult { packages, dependencies, diff --git a/vulnera-core/src/infrastructure/parsers/ruby.rs b/vulnera-core/src/infrastructure/parsers/ruby.rs index ec9ae689..c035bc6a 100644 --- a/vulnera-core/src/infrastructure/parsers/ruby.rs +++ b/vulnera-core/src/infrastructure/parsers/ruby.rs @@ -19,6 +19,7 @@ use crate::domain::vulnerability::{ use async_trait::async_trait; use once_cell::sync::Lazy; use regex::Regex; +use std::collections::HashMap; static RE_BASE_VERSION: Lazy = Lazy::new(|| Regex::new(r"(?i)\b(\d+(?:\.\d+){0,3})\b").unwrap()); @@ -200,6 +201,7 @@ impl GemfileLockParser { fn parse_gemfile_lock_content(&self, content: &str) -> Result { let mut packages = Vec::new(); let mut dependencies = Vec::new(); + let mut pending_dependencies: Vec<(Package, String, String)> = Vec::new(); // We only parse the "GEM -> specs" section. Lines look like: // " some_gem (1.2.3)" @@ -275,18 +277,40 @@ impl GemfileLockParser { let dep_req = caps.get(2).map(|m| m.as_str()).unwrap_or("*").trim(); if !dep_name.is_empty() { - // Create a placeholder package for the dependency target - let dep_pkg_version = Version::parse("0.0.0").unwrap(); - if let Ok(dep_pkg) = - Package::new(dep_name.to_string(), dep_pkg_version, Ecosystem::RubyGems) - { - dependencies.push(Dependency::new( - pkg.clone(), - dep_pkg, - dep_req.to_string(), - false, - )); - } + pending_dependencies.push(( + pkg.clone(), + dep_name.to_string(), + dep_req.to_string(), + )); + } + } + } + } + + let mut package_by_name: HashMap = HashMap::new(); + for package in &packages { + package_by_name + .entry(package.name.clone()) + .and_modify(|current| { + if package.version > current.version { + *current = package.clone(); + } + }) + .or_insert_with(|| package.clone()); + } + + for (from, dep_name, dep_req) in pending_dependencies { + if let Some(target) = package_by_name.get(&dep_name) { + dependencies.push(Dependency::new(from, target.clone(), dep_req, false)); + continue; + } + + if let Some(base_version) = extract_base_version(&dep_req) { + if let Ok(version) = parse_version_lenient(&base_version) { + if let Ok(inferred_target) = + Package::new(dep_name, version, Ecosystem::RubyGems) + { + dependencies.push(Dependency::new(from, inferred_target, dep_req, false)); } } } diff --git a/vulnera-core/src/infrastructure/parsers/rust.rs b/vulnera-core/src/infrastructure/parsers/rust.rs index 9c29f387..104d0a30 100644 --- a/vulnera-core/src/infrastructure/parsers/rust.rs +++ b/vulnera-core/src/infrastructure/parsers/rust.rs @@ -41,8 +41,9 @@ impl CargoParser { if let Some(version) = t.get("version").and_then(|v| v.as_str()) { version.to_string() } else if t.get("git").is_some() || t.get("path").is_some() { - // Skip git and path dependencies for now - continue; + // Include git/path dependencies as unresolved-version entries + // so dependency edges are preserved in analysis graphs. + "0.0.0".to_string() } else { "0.0.0".to_string() } @@ -251,17 +252,16 @@ impl CargoLockParser { // If version is specified, use it. If not, we have to guess or find the only one. // Cargo.lock usually specifies version if ambiguous. - let target_pkg = if parts.len() >= 2 { + let target_pkg: Option = if parts.len() >= 2 { let dep_version = parts[1]; package_map .get(&(dep_name.to_string(), dep_version.to_string())) + .cloned() } else { - // Find any package with this name (assuming unique or taking first) - // In a real implementation we should handle this better, but for now this is a reasonable fallback package_map .iter() .find(|((n, _), _)| n == dep_name) - .map(|(_, p)| p) + .map(|(_, p)| p.clone()) }; if let Some(target) = target_pkg { diff --git a/vulnera-deps/src/services/dependency_resolver.rs b/vulnera-deps/src/services/dependency_resolver.rs index 060f14f9..da5ea752 100644 --- a/vulnera-deps/src/services/dependency_resolver.rs +++ b/vulnera-deps/src/services/dependency_resolver.rs @@ -4,13 +4,14 @@ //! and building complete dependency graphs from manifest and lockfiles. use async_trait::async_trait; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; -use tracing::warn; +use tracing::{debug, warn}; use vulnera_core::application::errors::ApplicationError; use vulnera_core::domain::vulnerability::entities::Package; use vulnera_core::domain::vulnerability::value_objects::Ecosystem; use vulnera_core::infrastructure::parsers::ParserFactory; -use vulnera_core::infrastructure::registries::PackageRegistryClient; +use vulnera_core::infrastructure::registries::{PackageRegistryClient, RegistryError, VersionInfo}; use crate::domain::{ DependencyEdge, DependencyGraph, PackageId, PackageMetadata, PackageNode, @@ -47,6 +48,49 @@ impl DependencyResolverServiceImpl { _parser_factory: parser_factory, } } + + fn package_key(package: &Package) -> String { + format!( + "{}:{}@{}", + package.ecosystem.canonical_name(), + package.name, + package.version + ) + } + + fn map_registry_error(error: RegistryError, resource: &str) -> ApplicationError { + match error { + RegistryError::RateLimited => ApplicationError::RateLimited { + message: format!("Registry rate-limited request for {resource}"), + }, + RegistryError::NotFound => ApplicationError::NotFound { + resource: "package".to_string(), + id: resource.to_string(), + }, + RegistryError::UnsupportedEcosystem(ecosystem) => ApplicationError::InvalidEcosystem { + ecosystem: ecosystem.to_string(), + }, + RegistryError::Parse(message) => ApplicationError::Configuration { message }, + RegistryError::Http { message, .. } | RegistryError::Other(message) => { + ApplicationError::Configuration { + message: format!("Registry request failed for {resource}: {message}"), + } + } + } + } + + fn select_best_version( + versions: Vec, + requirement: &str, + ) -> Option { + let constraint = VersionConstraint::parse(requirement).unwrap_or(VersionConstraint::Any); + + versions + .into_iter() + .map(|v| v.version) + .filter(|version| constraint.satisfies(version)) + .max() + } } #[async_trait] @@ -105,45 +149,103 @@ impl DependencyResolverService for DependencyResolverServiceImpl { package: &Package, registry: Arc, ) -> Result, ApplicationError> { - // Note: Most package registries don't expose dependency information directly - // through their APIs. To properly resolve transitive dependencies, we would need to: - // 1. Fetch the package manifest/metadata for the specific version - // 2. Parse dependencies from the manifest (package.json, Cargo.toml, etc.) - // 3. Recursively resolve those dependencies - // - // This is ecosystem-specific and complex. For now, we return an empty vector. - // In practice, transitive dependencies are best resolved from lockfiles - // (package-lock.json, Cargo.lock, etc.) which contain the complete resolved tree. - // - // Future enhancement: Implement ecosystem-specific manifest fetching and parsing - // for registries that support it (e.g., npm registry API, crates.io API) - - tracing::debug!( - "Transitive dependency resolution requested for {}:{} (not yet implemented - use lockfiles for complete dependency trees)", - package.name, - package.version + debug!( + "Resolving transitive dependencies for {}:{}", + package.name, package.version ); - // Attempt to verify the package exists in the registry - // This at least validates that the package is available - match registry - .list_versions(package.ecosystem.clone(), &package.name) - .await - { - Ok(_versions) => { - // Package exists, but we can't get dependencies without fetching manifests - Ok(Vec::new()) + let mut queue = VecDeque::new(); + queue.push_back(package.clone()); + + let mut visited = HashSet::new(); + let mut discovered: HashMap = HashMap::new(); + + while let Some(current) = queue.pop_front() { + let current_key = Self::package_key(¤t); + if !visited.insert(current_key) { + continue; } - Err(e) => { - tracing::warn!( - "Failed to verify package {} in registry: {}", - package.name, - e - ); - // Return empty rather than error, as this is a best-effort operation - Ok(Vec::new()) + + let metadata = match registry + .fetch_metadata(current.ecosystem.clone(), ¤t.name, ¤t.version) + .await + { + Ok(metadata) => metadata, + Err(error) => { + if current.name == package.name && current.version == package.version { + return Err(Self::map_registry_error( + error, + &format!("{}@{}", current.name, current.version), + )); + } + + warn!( + package = %current.identifier(), + "Failed to fetch metadata for transitive package: {}", + error + ); + continue; + } + }; + + for dependency in metadata.dependencies { + if dependency.is_dev || dependency.is_optional { + continue; + } + + let versions = match registry + .list_versions(current.ecosystem.clone(), &dependency.name) + .await + { + Ok(versions) => versions, + Err(error) => { + warn!( + dependency = %dependency.name, + requirement = %dependency.requirement, + "Failed to list versions for dependency: {}", + error + ); + continue; + } + }; + + let Some(version) = Self::select_best_version(versions, &dependency.requirement) + else { + warn!( + dependency = %dependency.name, + requirement = %dependency.requirement, + "No compatible version found for dependency requirement" + ); + continue; + }; + + let transitive_package = match Package::new( + dependency.name.clone(), + version, + current.ecosystem.clone(), + ) { + Ok(package) => package, + Err(error) => { + warn!( + dependency = %dependency.name, + "Failed to build transitive package model: {}", + error + ); + continue; + } + }; + + let key = Self::package_key(&transitive_package); + if discovered.contains_key(&key) { + continue; + } + + discovered.insert(key, transitive_package.clone()); + queue.push_back(transitive_package); } } + + Ok(discovered.into_values().collect()) } } @@ -317,6 +419,89 @@ pub async fn build_graph_from_manifest( #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + use vulnera_core::domain::vulnerability::value_objects::Version; + use vulnera_core::infrastructure::registries::{ + RegistryDependency, RegistryPackageMetadata, VersionInfo, + }; + + #[derive(Default)] + struct MockRegistryClient { + versions: Mutex>>, + metadata: Mutex>, + } + + impl MockRegistryClient { + fn key(ecosystem: Ecosystem, name: &str) -> String { + format!("{}:{}", ecosystem.canonical_name(), name) + } + + fn metadata_key(ecosystem: Ecosystem, name: &str, version: &str) -> String { + format!("{}:{}@{}", ecosystem.canonical_name(), name, version) + } + + fn add_versions(&self, ecosystem: Ecosystem, name: &str, versions: Vec<&str>) { + let infos = versions + .into_iter() + .map(|v| VersionInfo::new(Version::parse(v).unwrap(), false, None)) + .collect(); + self.versions + .lock() + .unwrap() + .insert(Self::key(ecosystem, name), infos); + } + + fn add_metadata( + &self, + ecosystem: Ecosystem, + name: &str, + version: &str, + dependencies: Vec, + ) { + let metadata = RegistryPackageMetadata { + name: name.to_string(), + version: Version::parse(version).unwrap(), + dependencies, + project_url: None, + license: None, + }; + self.metadata.lock().unwrap().insert( + Self::metadata_key(ecosystem, name, version), + metadata, + ); + } + } + + #[async_trait] + impl PackageRegistryClient for MockRegistryClient { + async fn list_versions( + &self, + ecosystem: Ecosystem, + name: &str, + ) -> Result, RegistryError> { + Ok(self + .versions + .lock() + .unwrap() + .get(&Self::key(ecosystem, name)) + .cloned() + .unwrap_or_default()) + } + + async fn fetch_metadata( + &self, + ecosystem: Ecosystem, + name: &str, + version: &Version, + ) -> Result { + self.metadata + .lock() + .unwrap() + .get(&Self::metadata_key(ecosystem, name, &version.to_string())) + .cloned() + .ok_or(RegistryError::NotFound) + } + } #[tokio::test] async fn test_build_graph_from_manifest() { @@ -341,4 +526,103 @@ mod tests { assert_eq!(graph.package_count(), 1); assert_eq!(graph.root_packages.len(), 1); } + + #[tokio::test] + async fn test_resolve_transitive_resolves_nested_dependencies() { + let parser_factory = Arc::new(ParserFactory::new()); + let resolver = DependencyResolverServiceImpl::new(parser_factory); + + let root = Package::new( + "root".to_string(), + Version::parse("1.0.0").unwrap(), + Ecosystem::Npm, + ) + .unwrap(); + + let registry = Arc::new(MockRegistryClient::default()); + + registry.add_versions(Ecosystem::Npm, "dep-a", vec!["1.0.0", "1.2.0"]); + registry.add_versions(Ecosystem::Npm, "dep-b", vec!["2.0.0"]); + + registry.add_metadata( + Ecosystem::Npm, + "root", + "1.0.0", + vec![RegistryDependency { + name: "dep-a".to_string(), + requirement: ">=1.1.0".to_string(), + is_dev: false, + is_optional: false, + }], + ); + + registry.add_metadata( + Ecosystem::Npm, + "dep-a", + "1.2.0", + vec![RegistryDependency { + name: "dep-b".to_string(), + requirement: "^2.0.0".to_string(), + is_dev: false, + is_optional: false, + }], + ); + + registry.add_metadata(Ecosystem::Npm, "dep-b", "2.0.0", vec![]); + + let resolved = resolver + .resolve_transitive(&root, registry) + .await + .expect("transitive resolution should succeed"); + + assert!(resolved.iter().any(|pkg| pkg.name == "dep-a" && pkg.version == Version::parse("1.2.0").unwrap())); + assert!(resolved.iter().any(|pkg| pkg.name == "dep-b" && pkg.version == Version::parse("2.0.0").unwrap())); + } + + #[tokio::test] + async fn test_resolve_transitive_skips_dev_and_optional_dependencies() { + let parser_factory = Arc::new(ParserFactory::new()); + let resolver = DependencyResolverServiceImpl::new(parser_factory); + + let root = Package::new( + "root".to_string(), + Version::parse("1.0.0").unwrap(), + Ecosystem::Npm, + ) + .unwrap(); + + let registry = Arc::new(MockRegistryClient::default()); + registry.add_versions(Ecosystem::Npm, "prod-dep", vec!["1.0.0"]); + registry.add_versions(Ecosystem::Npm, "dev-dep", vec!["1.0.0"]); + + registry.add_metadata( + Ecosystem::Npm, + "root", + "1.0.0", + vec![ + RegistryDependency { + name: "prod-dep".to_string(), + requirement: "*".to_string(), + is_dev: false, + is_optional: false, + }, + RegistryDependency { + name: "dev-dep".to_string(), + requirement: "*".to_string(), + is_dev: true, + is_optional: false, + }, + ], + ); + + registry.add_metadata(Ecosystem::Npm, "prod-dep", "1.0.0", vec![]); + + let resolved = resolver + .resolve_transitive(&root, registry) + .await + .expect("transitive resolution should succeed"); + + assert!(resolved.iter().any(|pkg| pkg.name == "prod-dep")); + assert!(!resolved.iter().any(|pkg| pkg.name == "dev-dep")); + } } From 7c2e9b7a16b995904b4dff3e850932525c1eefd8 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:09:23 +0200 Subject: [PATCH 3/9] feat: add component ref resolution to openapi parser - Resolve parameter, request body, response, and header references from components - Improve parsing of referenced objects for OpenAPI/Swagger specs feat: expose db_size and info_stats in dragonfly cache - Add methods to query total keys and Redis INFO STATS metrics feat: implement ecosystem cache invalidation and statistics - Invalidate cache entries for a given ecosystem using key patterns - Provide detailed cache statistics from Dragonfly metrics - Replace stubbed cache stats with real values from Redis This improves OpenAPI reference handling and enables cache introspection and targeted invalidation. --- .../infrastructure/parser/openapi_parser.rs | 393 ++++++++++++++++-- .../infrastructure/cache/dragonfly_cache.rs | 45 ++ .../src/infrastructure/cache/service.rs | 68 ++- 3 files changed, 449 insertions(+), 57 deletions(-) diff --git a/vulnera-api/src/infrastructure/parser/openapi_parser.rs b/vulnera-api/src/infrastructure/parser/openapi_parser.rs index ac8f95cf..4420c5a7 100644 --- a/vulnera-api/src/infrastructure/parser/openapi_parser.rs +++ b/vulnera-api/src/infrastructure/parser/openapi_parser.rs @@ -9,6 +9,15 @@ use tracing::{debug, error, info, warn}; /// Parser for OpenAPI/Swagger specifications pub struct OpenApiParser; +#[derive(Debug, Clone, Default)] +struct ComponentRefMaps { + parameters: std::collections::HashMap, + request_bodies: std::collections::HashMap, + responses: std::collections::HashMap, + headers: std::collections::HashMap, + security_schemes: std::collections::HashMap, +} + impl OpenApiParser { /// Parse an OpenAPI/Swagger specification from a file pub fn parse_file(file_path: &Path) -> Result { @@ -67,6 +76,7 @@ impl OpenApiParser { // Extract component schemas for reference resolution let schema_map = Self::extract_schemas_from_json(&raw_spec); + let component_refs = Self::extract_component_refs_from_json(&raw_spec); // Parse using oas3 crate for the rest of the spec let spec = if content.trim_start().starts_with('{') { @@ -113,6 +123,7 @@ impl OpenApiParser { path_securities, oauth_token_urls, schema_map, + component_refs, ) } @@ -129,6 +140,7 @@ impl OpenApiParser { std::collections::HashMap, >, schema_map: crate::infrastructure::parser::SchemaMap, + component_refs: ComponentRefMaps, ) -> Result { debug!( version = %spec.openapi, @@ -147,9 +159,14 @@ impl OpenApiParser { .unwrap_or(&std::collections::BTreeMap::new()), &path_securities, &mut schema_resolver, + &component_refs, ); let security_schemes = - Self::parse_security_schemes_with_oauth_urls(&spec.components, &oauth_token_urls); + Self::parse_security_schemes_with_oauth_urls( + &spec.components, + &oauth_token_urls, + &component_refs, + ); Ok(OpenApiSpec { version: spec.openapi, @@ -166,6 +183,7 @@ impl OpenApiParser { std::collections::HashMap>, >, schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> Vec { let mut api_paths = Vec::new(); @@ -191,6 +209,7 @@ impl OpenApiParser { get, &op_security, schema_resolver, + component_refs, )); } if let Some(ref post) = path_item.post { @@ -204,6 +223,7 @@ impl OpenApiParser { post, &op_security, schema_resolver, + component_refs, )); } if let Some(ref put) = path_item.put { @@ -217,6 +237,7 @@ impl OpenApiParser { put, &op_security, schema_resolver, + component_refs, )); } if let Some(ref delete) = path_item.delete { @@ -230,6 +251,7 @@ impl OpenApiParser { delete, &op_security, schema_resolver, + component_refs, )); } if let Some(ref patch) = path_item.patch { @@ -243,6 +265,7 @@ impl OpenApiParser { patch, &op_security, schema_resolver, + component_refs, )); } if let Some(ref head) = path_item.head { @@ -256,6 +279,7 @@ impl OpenApiParser { head, &op_security, schema_resolver, + component_refs, )); } if let Some(ref options) = path_item.options { @@ -269,6 +293,7 @@ impl OpenApiParser { options, &op_security, schema_resolver, + component_refs, )); } if let Some(ref trace) = path_item.trace { @@ -282,6 +307,7 @@ impl OpenApiParser { trace, &op_security, schema_resolver, + component_refs, )); } @@ -299,19 +325,21 @@ impl OpenApiParser { operation: &oas3::spec::Operation, security: &[crate::domain::value_objects::SecurityRequirement], schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> ApiOperation { // Security requirements are now passed in from the raw JSON/YAML parsing // Operation-level security overrides path-level security (handled in parse_paths_with_security) - let parameters = Self::parse_parameters(&operation.parameters, schema_resolver); + let parameters = Self::parse_parameters(&operation.parameters, schema_resolver, component_refs); let request_body = - Self::parse_request_body(operation.request_body.as_ref(), schema_resolver); + Self::parse_request_body(operation.request_body.as_ref(), schema_resolver, component_refs); let responses = Self::parse_responses( operation .responses .as_ref() .unwrap_or(&std::collections::BTreeMap::new()), schema_resolver, + component_refs, ); ApiOperation { @@ -326,6 +354,7 @@ impl OpenApiParser { fn parse_parameters( parameters: &[oas3::spec::ObjectOrReference], schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> Vec { let mut api_params = Vec::new(); @@ -343,20 +372,20 @@ impl OpenApiParser { name: param.name.clone(), location, required: param.required.unwrap_or(false), - schema: param.schema.as_ref().and_then(|s| match s { - oas3::spec::ObjectOrReference::Object(_) => { - Some(Self::parse_schema(s, schema_resolver)) - } - oas3::spec::ObjectOrReference::Ref { .. } => { - warn!("Skipping schema reference in parameter"); - None - } - }), + schema: param + .schema + .as_ref() + .map(|s| Self::parse_schema(s, schema_resolver)), }); } - oas3::spec::ObjectOrReference::Ref { .. } => { - // Skip references for now - could be resolved later - warn!("Skipping parameter reference"); + oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { + if let Some(param_json) = Self::resolve_component_ref(ref_path, "parameters", component_refs) { + if let Some(param) = Self::parse_parameter_from_json(param_json, schema_resolver) { + api_params.push(param); + } + } else { + warn!(ref_path = %ref_path, "Failed to resolve parameter reference"); + } } } } @@ -367,6 +396,7 @@ impl OpenApiParser { fn parse_request_body( rb_ref: Option<&oas3::spec::ObjectOrReference>, schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> Option { match rb_ref { Some(oas3::spec::ObjectOrReference::Object(rb)) => { @@ -376,10 +406,13 @@ impl OpenApiParser { content, }) } - Some(oas3::spec::ObjectOrReference::Ref { .. }) => { - // We could resolve request body refs here too if we extracted them - warn!("Skipping request body reference"); - None + Some(oas3::spec::ObjectOrReference::Ref { ref_path, .. }) => { + if let Some(rb_json) = Self::resolve_component_ref(ref_path, "requestBodies", component_refs) { + Self::parse_request_body_from_json(rb_json, schema_resolver) + } else { + warn!(ref_path = %ref_path, "Failed to resolve request body reference"); + None + } } None => None, } @@ -391,6 +424,7 @@ impl OpenApiParser { oas3::spec::ObjectOrReference, >, schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> Vec { let mut api_responses = Vec::new(); @@ -399,7 +433,7 @@ impl OpenApiParser { oas3::spec::ObjectOrReference::Object(response) => { let content = Self::parse_content(&Some(response.content.clone()), schema_resolver); - let headers = Self::parse_response_headers(&response.headers, schema_resolver); + let headers = Self::parse_response_headers(&response.headers, schema_resolver, component_refs); api_responses.push(ApiResponse { status_code: status_code.clone(), @@ -407,9 +441,19 @@ impl OpenApiParser { headers, }); } - oas3::spec::ObjectOrReference::Ref { .. } => { - // Skip references for now - could be resolved later - warn!(status_code = %status_code, "Skipping response reference"); + oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { + if let Some(response_json) = Self::resolve_component_ref(ref_path, "responses", component_refs) { + if let Some(response) = Self::parse_response_from_json( + status_code, + response_json, + schema_resolver, + component_refs, + ) { + api_responses.push(response); + } + } else { + warn!(status_code = %status_code, ref_path = %ref_path, "Failed to resolve response reference"); + } } } } @@ -452,6 +496,7 @@ impl OpenApiParser { oas3::spec::ObjectOrReference, >, schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, ) -> Vec { let mut api_headers = Vec::new(); @@ -461,23 +506,20 @@ impl OpenApiParser { let schema = header .schema .as_ref() - .and_then(|schema_ref| match schema_ref { - oas3::spec::ObjectOrReference::Object(_) => { - Some(Self::parse_schema(schema_ref, schema_resolver)) - } - oas3::spec::ObjectOrReference::Ref { .. } => { - warn!("Skipping schema reference in header"); - None - } - }); + .map(|schema_ref| Self::parse_schema(schema_ref, schema_resolver)); api_headers.push(ApiHeader { name: name.clone(), schema, }); } - oas3::spec::ObjectOrReference::Ref { .. } => { - // Skip references for now - warn!(header_name = %name, "Skipping header reference"); + oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { + if let Some(header_json) = Self::resolve_component_ref(ref_path, "headers", component_refs) { + if let Some(header) = Self::parse_header_from_json(name, header_json, schema_resolver) { + api_headers.push(header); + } + } else { + warn!(header_name = %name, ref_path = %ref_path, "Failed to resolve header reference"); + } } } } @@ -534,9 +576,17 @@ impl OpenApiParser { .map(|s| Self::parse_schema(s, schema_resolver)) .collect(); - // enum values access is unclear from oas3 docs without internet, skipping for now - // will rely on schema_resolver for deep inspection - let enum_values = None; + let enum_values = if obj_schema.enum_values.is_empty() { + None + } else { + Some( + obj_schema + .enum_values + .iter() + .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string())) + .collect(), + ) + }; // numeric constraints let minimum = obj_schema.minimum.as_ref().and_then(|n| n.as_f64()); @@ -546,12 +596,31 @@ impl OpenApiParser { // Simple metadata let read_only = obj_schema.read_only.unwrap_or(false); let write_only = obj_schema.write_only.unwrap_or(false); - // example/default might be complex in oas3, skipping for now to rely on defaults or simple mapping if easy + let example = obj_schema.example.clone(); + let default = obj_schema.default.clone(); + + let additional_properties = match obj_schema.additional_properties.as_ref() { + Some(oas3::spec::Schema::Boolean(oas3::spec::BooleanSchema(false))) => { + AdditionalProperties::Denied + } + Some(oas3::spec::Schema::Boolean(oas3::spec::BooleanSchema(true))) => { + AdditionalProperties::Allowed + } + Some(oas3::spec::Schema::Object(object_schema)) => { + AdditionalProperties::Schema(Box::new(Self::parse_schema( + object_schema.as_ref(), + schema_resolver, + ))) + } + None => AdditionalProperties::Allowed, + }; ApiSchema { schema_type, format: obj_schema.format.clone(), properties, + summary: obj_schema.title.clone(), + description: obj_schema.description.clone(), required: obj_schema.required.clone(), pattern, minimum, @@ -562,8 +631,11 @@ impl OpenApiParser { max_items, enum_values, multiple_of, + example, + default, read_only, write_only, + additional_properties, all_of, one_of, any_of, @@ -582,6 +654,7 @@ impl OpenApiParser { String, std::collections::HashMap, >, + component_refs: &ComponentRefMaps, ) -> Vec { let mut schemes = Vec::new(); @@ -635,9 +708,62 @@ impl OpenApiParser { scheme_type, }); } - oas3::spec::ObjectOrReference::Ref { .. } => { - // Skip references for now - warn!(scheme_name = %name, "Skipping security scheme reference"); + oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { + if let Some(scheme_json) = + Self::resolve_component_ref(ref_path, "securitySchemes", component_refs) + { + if let Ok(scheme) = + serde_json::from_value::(scheme_json.clone()) + { + let scheme_type = match &scheme { + oas3::spec::SecurityScheme::ApiKey { + location, + name: key_name, + description: _, + } => SecuritySchemeType::ApiKey { + location: format!("{:?}", location), + name: key_name.clone(), + }, + oas3::spec::SecurityScheme::Http { + scheme: http_scheme, + bearer_format, + description: _, + } => SecuritySchemeType::Http { + scheme: http_scheme.clone(), + bearer_format: bearer_format.clone(), + }, + oas3::spec::SecurityScheme::OAuth2 { + flows, + description: _, + } => { + let scheme_token_urls = + oauth_token_urls.get(name).cloned().unwrap_or_default(); + let oauth_flows = + Self::parse_oauth_flows_with_urls(flows, &scheme_token_urls); + SecuritySchemeType::OAuth2 { flows: oauth_flows } + } + oas3::spec::SecurityScheme::OpenIdConnect { + open_id_connect_url, + description: _, + } => SecuritySchemeType::OpenIdConnect { + url: open_id_connect_url.clone(), + }, + oas3::spec::SecurityScheme::MutualTls { description: _ } => { + warn!("MutualTLS security scheme is not supported, skipping"); + continue; + } + }; + + schemes.push(SecurityScheme { + name: name.clone(), + scheme_type, + }); + } else { + warn!(scheme_name = %name, ref_path = %ref_path, "Failed to deserialize security scheme reference"); + } + } else { + warn!(scheme_name = %name, ref_path = %ref_path, "Failed to resolve security scheme reference"); + } } } } @@ -900,6 +1026,189 @@ impl OpenApiParser { schemas } + + fn extract_component_refs_from_json(spec: &JsonValue) -> ComponentRefMaps { + let mut refs = ComponentRefMaps::default(); + + if let Some(components) = spec.get("components").and_then(|c| c.as_object()) { + if let Some(parameters) = components.get("parameters").and_then(|v| v.as_object()) { + refs.parameters = parameters + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + } + + if let Some(request_bodies) = + components.get("requestBodies").and_then(|v| v.as_object()) + { + refs.request_bodies = request_bodies + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + } + + if let Some(responses) = components.get("responses").and_then(|v| v.as_object()) { + refs.responses = responses + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + } + + if let Some(headers) = components.get("headers").and_then(|v| v.as_object()) { + refs.headers = headers + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + } + + if let Some(security_schemes) = + components.get("securitySchemes").and_then(|v| v.as_object()) + { + refs.security_schemes = security_schemes + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + } + } + + refs + } + + fn resolve_component_ref<'a>( + ref_path: &str, + expected_component: &str, + component_refs: &'a ComponentRefMaps, + ) -> Option<&'a JsonValue> { + let parts: Vec<&str> = ref_path.trim_start_matches("#/").split('/').collect(); + if parts.len() != 3 || parts[0] != "components" || parts[1] != expected_component { + return None; + } + + let key = parts[2]; + match expected_component { + "parameters" => component_refs.parameters.get(key), + "requestBodies" => component_refs.request_bodies.get(key), + "responses" => component_refs.responses.get(key), + "headers" => component_refs.headers.get(key), + "securitySchemes" => component_refs.security_schemes.get(key), + _ => None, + } + } + + fn parse_parameter_from_json( + param_json: &JsonValue, + schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + ) -> Option { + let name = param_json.get("name").and_then(|v| v.as_str())?.to_string(); + let location = match param_json.get("in").and_then(|v| v.as_str())? { + "query" => ParameterLocation::Query, + "header" => ParameterLocation::Header, + "path" => ParameterLocation::Path, + "cookie" => ParameterLocation::Cookie, + _ => return None, + }; + + let required = param_json + .get("required") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let schema = param_json + .get("schema") + .map(|schema_json| schema_resolver.parse_schema_from_json(schema_json)); + + Some(ApiParameter { + name, + location, + required, + schema, + }) + } + + fn parse_request_body_from_json( + rb_json: &JsonValue, + schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + ) -> Option { + let required = rb_json + .get("required") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let content = Self::parse_content_from_json(rb_json.get("content"), schema_resolver); + + Some(ApiRequestBody { required, content }) + } + + fn parse_response_from_json( + status_code: &str, + response_json: &JsonValue, + schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + component_refs: &ComponentRefMaps, + ) -> Option { + let content = Self::parse_content_from_json(response_json.get("content"), schema_resolver); + let headers = response_json + .get("headers") + .and_then(|v| v.as_object()) + .map(|headers_obj| { + headers_obj + .iter() + .filter_map(|(name, header_json)| { + if let Some(ref_path) = header_json.get("$ref").and_then(|v| v.as_str()) { + if let Some(resolved) = + Self::resolve_component_ref(ref_path, "headers", component_refs) + { + return Self::parse_header_from_json(name, resolved, schema_resolver); + } + return None; + } + Self::parse_header_from_json(name, header_json, schema_resolver) + }) + .collect::>() + }) + .unwrap_or_default(); + + Some(ApiResponse { + status_code: status_code.to_string(), + content, + headers, + }) + } + + fn parse_header_from_json( + name: &str, + header_json: &JsonValue, + schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + ) -> Option { + let schema = header_json + .get("schema") + .map(|schema_json| schema_resolver.parse_schema_from_json(schema_json)); + Some(ApiHeader { + name: name.to_string(), + schema, + }) + } + + fn parse_content_from_json( + content_json: Option<&JsonValue>, + schema_resolver: &mut crate::infrastructure::parser::SchemaRefResolver, + ) -> Vec { + let mut api_content = Vec::new(); + let Some(content_map) = content_json.and_then(|v| v.as_object()) else { + return api_content; + }; + + for (media_type, media_value) in content_map { + let schema = media_value + .get("schema") + .map(|schema_json| schema_resolver.parse_schema_from_json(schema_json)); + + api_content.push(ApiContent { + media_type: media_type.clone(), + schema, + }); + } + + api_content + } } /// Parse error with context diff --git a/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs b/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs index 8c1770ed..9bd71562 100644 --- a/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs +++ b/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs @@ -333,6 +333,51 @@ impl DragonflyCache { ); Ok(total_deleted) } + + /// Return the total number of keys in the selected database. + pub async fn db_size(&self) -> Result { + let mut conn = (*self.connection_manager).clone(); + let size: u64 = redis::cmd("DBSIZE") + .query_async::(&mut conn) + .await + .map_err(|e| { + error!("Failed to query DBSIZE: {}", e); + ApplicationError::Cache(CacheError::Io(std::io::Error::other(format!( + "Redis DBSIZE error: {}", + e + )))) + })?; + Ok(size) + } + + /// Return parsed Redis INFO STATS metrics as key/value pairs. + pub async fn info_stats(&self) -> Result, ApplicationError> { + let mut conn = (*self.connection_manager).clone(); + let raw: String = redis::cmd("INFO") + .arg("STATS") + .query_async::(&mut conn) + .await + .map_err(|e| { + error!("Failed to query INFO STATS: {}", e); + ApplicationError::Cache(CacheError::Io(std::io::Error::other(format!( + "Redis INFO STATS error: {}", + e + )))) + })?; + + let mut metrics = std::collections::HashMap::new(); + for line in raw.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((key, value)) = line.split_once(':') { + metrics.insert(key.trim().to_string(), value.trim().to_string()); + } + } + + Ok(metrics) + } } #[async_trait] diff --git a/vulnera-core/src/infrastructure/cache/service.rs b/vulnera-core/src/infrastructure/cache/service.rs index bb69882d..eaed75e0 100644 --- a/vulnera-core/src/infrastructure/cache/service.rs +++ b/vulnera-core/src/infrastructure/cache/service.rs @@ -241,28 +241,66 @@ impl CacheServiceImpl { "Invalidating all cache entries for ecosystem: {}", ecosystem ); + let ecosystem_name = ecosystem.canonical_name(); + + let patterns = [ + format!("vuln:{}:*", ecosystem_name), + format!("analysis:{}:*", ecosystem_name), + format!("packages:{}:*", ecosystem_name), + format!("registry_versions:{}:*", ecosystem_name), + ]; + + let mut total_deleted: u64 = 0; + for pattern in patterns { + let deleted = self.cache_repository.delete_by_pattern(&pattern).await?; + total_deleted += deleted as u64; + debug!( + pattern = %pattern, + deleted = deleted, + "Deleted ecosystem cache keys" + ); + } - // Dragonfly cache doesn't support filesystem-based invalidation - // This would require SCAN operations which are expensive - // For now, return 0 and log a warning - warn!( - "Ecosystem cache invalidation not fully supported for Dragonfly cache. Use individual key invalidation instead." + info!( + ecosystem = %ecosystem, + deleted = total_deleted, + "Completed ecosystem cache invalidation" ); - Ok(0) + Ok(total_deleted) } /// Get cache statistics pub async fn get_cache_statistics(&self) -> Result { - // Dragonfly cache doesn't provide detailed statistics in the same way - // Return default statistics + let metrics = self.cache_repository.info_stats().await?; + let total_entries = self.cache_repository.db_size().await?; + + let parse_u64 = |name: &str| -> u64 { + metrics + .get(name) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0) + }; + + let hits = parse_u64("keyspace_hits"); + let misses = parse_u64("keyspace_misses"); + let expired_entries = parse_u64("expired_keys"); + let cleanup_runs = parse_u64("expire_cycle_cpu_milliseconds"); + let total_size_bytes = parse_u64("used_memory"); + let denominator = hits + misses; + let hit_rate = if denominator > 0 { + hits as f64 / denominator as f64 + } else { + 0.0 + }; + Ok(CacheStatistics { - hits: 0, - misses: 0, - hit_rate: 0.0, - total_entries: 0, - total_size_bytes: 0, - expired_entries: 0, - cleanup_runs: 0, + hits, + misses, + hit_rate, + total_entries, + total_size_bytes, + expired_entries, + cleanup_runs, }) } From 221f34b86fd4164dbcb22e5d65e0d43b97c8d182 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:21:31 +0200 Subject: [PATCH 4/9] Add subscription tier to quota and org responses; improve Gradle version parsing - Include SubscriptionTier in QuotaUsage and propagate to API responses - Resolve organization tier dynamically in organization endpoints - Enhance Gradle version parser to handle ranges, selectors, and property refs - Detect JavaScript frameworks from package.json dependencies - Enforce API key TTL > 0 in config validation - Simplify rate limit middleware auth extraction to use EarlyAuthInfo only --- .../src/application/analytics/use_cases.rs | 2 + vulnera-core/src/config/validation.rs | 7 ++ .../src/infrastructure/parsers/java.rs | 53 +++++++++++--- .../src/infrastructure/project_detection.rs | 71 +++++++++++++++++-- .../src/presentation/auth/controller.rs | 9 +-- .../src/presentation/controllers/analytics.rs | 12 +++- .../presentation/controllers/organization.rs | 37 ++++++++-- .../src/presentation/middleware/mod.rs | 37 +--------- .../src/presentation/routes.rs | 1 + 9 files changed, 167 insertions(+), 62 deletions(-) diff --git a/vulnera-core/src/application/analytics/use_cases.rs b/vulnera-core/src/application/analytics/use_cases.rs index 2a909dac..f42908e2 100644 --- a/vulnera-core/src/application/analytics/use_cases.rs +++ b/vulnera-core/src/application/analytics/use_cases.rs @@ -32,6 +32,7 @@ pub struct DashboardOverview { /// Quota usage summary #[derive(Debug, Clone)] pub struct QuotaUsage { + pub tier: Option, pub scans_used: u32, pub scans_limit: Option, pub scans_percentage: f64, @@ -324,6 +325,7 @@ fn calculate_quota_usage( || api_calls_limit.map(|l| api_calls_used > l).unwrap_or(false); QuotaUsage { + tier: limits.as_ref().map(|l| l.tier), scans_used, scans_limit, scans_percentage, diff --git a/vulnera-core/src/config/validation.rs b/vulnera-core/src/config/validation.rs index 4bbfbc7f..dc12f573 100644 --- a/vulnera-core/src/config/validation.rs +++ b/vulnera-core/src/config/validation.rs @@ -296,6 +296,13 @@ impl Validate for AuthConfig { )); } + // Validate API key TTL days when configured + if self.api_key_ttl_days.is_some_and(|days| days == 0) { + return Err(ValidationError::auth( + "API key TTL days must be greater than 0 when configured".to_string(), + )); + } + // Validate CSRF token length (16-64 bytes for reasonable entropy) if self.csrf_token_bytes < 16 || self.csrf_token_bytes > 64 { return Err(ValidationError::auth( diff --git a/vulnera-core/src/infrastructure/parsers/java.rs b/vulnera-core/src/infrastructure/parsers/java.rs index df10e557..251726a8 100644 --- a/vulnera-core/src/infrastructure/parsers/java.rs +++ b/vulnera-core/src/infrastructure/parsers/java.rs @@ -341,15 +341,46 @@ impl GradleParser { } // Handle Gradle version catalogs and property references - if version_str.starts_with("$") { - return Ok("0.0.0".to_string()); // Default for unresolved properties + if version_str.starts_with("$") || version_str.contains("${") { + return Ok("1.0.0".to_string()); } - // Handle version ranges (simplified) - if version_str.contains('+') { - // Handle dynamic versions like "1.+" -> "1.0.0" - let base_version = version_str.replace('+', "0"); - return Ok(base_version); + // Handle dynamic selectors used by Gradle/Maven metadata + if matches!( + version_str.to_ascii_lowercase().as_str(), + "latest.release" | "latest.integration" | "release" | "latest" + ) { + return Ok("1.0.0".to_string()); + } + + // Handle version ranges like [1.2,2.0), (1.0,], [1.0,) + if (version_str.starts_with('[') || version_str.starts_with('(')) + && (version_str.ends_with(']') || version_str.ends_with(')')) + && version_str.contains(',') + { + let inner = &version_str[1..version_str.len() - 1]; + let mut parts = inner.split(',').map(str::trim); + let lower = parts.next().unwrap_or_default(); + let upper = parts.next().unwrap_or_default(); + + let chosen = if !lower.is_empty() { lower } else { upper }; + if !chosen.is_empty() { + return Ok(chosen.to_string()); + } + } + + // Handle dynamic versions like "1.+", "1.2.+" + if version_str.ends_with('+') { + let mut parts: Vec<&str> = version_str.trim_end_matches('+').split('.').collect(); + if matches!(parts.last(), Some(last) if last.is_empty()) { + parts.pop(); + } + + while parts.len() < 3 { + parts.push("0"); + } + + return Ok(parts[..3].join(".")); } // Handle classifier suffixes like "-jre", "-android", etc. @@ -482,9 +513,13 @@ dependencies { assert_eq!(parser.clean_gradle_version("5.3.21").unwrap(), "5.3.21"); assert_eq!( parser.clean_gradle_version("$springVersion").unwrap(), - "0.0.0" + "1.0.0" ); - assert_eq!(parser.clean_gradle_version("1.+").unwrap(), "1.0"); + assert_eq!(parser.clean_gradle_version("1.+").unwrap(), "1.0.0"); + assert_eq!(parser.clean_gradle_version("1.2.+").unwrap(), "1.2.0"); + assert_eq!(parser.clean_gradle_version("[1.2,2.0)").unwrap(), "1.2"); + assert_eq!(parser.clean_gradle_version("(,2.0]").unwrap(), "2.0"); + assert_eq!(parser.clean_gradle_version("latest.release").unwrap(), "1.0.0"); } #[test] diff --git a/vulnera-orchestrator/src/infrastructure/project_detection.rs b/vulnera-orchestrator/src/infrastructure/project_detection.rs index 3b50cb12..f1c18503 100644 --- a/vulnera-orchestrator/src/infrastructure/project_detection.rs +++ b/vulnera-orchestrator/src/infrastructure/project_detection.rs @@ -1,6 +1,7 @@ //! Project detection implementations use async_trait::async_trait; +use serde_json::Value; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -62,9 +63,7 @@ impl FileSystemProjectDetector { languages.insert("javascript".to_string()); dependency_files.push(file_path.clone()); detected_config_files.push(file_path); - - // Check for frameworks in package.json content if possible - // For now, we just mark it as a config file + frameworks.extend(detect_frameworks_from_package_json(entry.path())); } "package-lock.json" | "yarn.lock" => { languages.insert("javascript".to_string()); @@ -210,6 +209,54 @@ impl FileSystemProjectDetector { } } +fn detect_frameworks_from_package_json(path: &Path) -> HashSet { + let mut frameworks = HashSet::new(); + + let Ok(content) = std::fs::read_to_string(path) else { + return frameworks; + }; + + let Ok(json) = serde_json::from_str::(&content) else { + return frameworks; + }; + + let dependency_sections = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]; + + let has_dependency = |name: &str| { + dependency_sections.iter().any(|section| { + json.get(section) + .and_then(Value::as_object) + .is_some_and(|deps| deps.contains_key(name)) + }) + }; + + if has_dependency("react") || has_dependency("react-dom") { + frameworks.insert("react".to_string()); + } + if has_dependency("next") { + frameworks.insert("nextjs".to_string()); + } + if has_dependency("vue") { + frameworks.insert("vue".to_string()); + } + if has_dependency("nuxt") { + frameworks.insert("nuxt".to_string()); + } + if has_dependency("@angular/core") { + frameworks.insert("angular".to_string()); + } + if has_dependency("svelte") || has_dependency("@sveltejs/kit") { + frameworks.insert("svelte".to_string()); + } + + frameworks +} + #[async_trait] impl ProjectDetector for FileSystemProjectDetector { async fn detect_project( @@ -275,6 +322,15 @@ mod tests { writeln!(file, "dummy content").unwrap(); } + fn create_file_with_content(dir: &Path, name: &str, content: &str) { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + let mut file = File::create(path).unwrap(); + write!(file, "{}", content).unwrap(); + } + fn create_test_detector() -> FileSystemProjectDetector { let git_service = Arc::new(GitService::new(Default::default()).unwrap()); let s3_service = Arc::new(S3Service::new()); @@ -322,7 +378,11 @@ mod tests { #[test] fn test_detect_react_project() { let temp_dir = TempDir::new().unwrap(); - create_dummy_file(temp_dir.path(), "package.json"); + create_file_with_content( + temp_dir.path(), + "package.json", + r#"{"dependencies":{"react":"^18.3.0","react-dom":"^18.3.0"}}"#, + ); create_dummy_file(temp_dir.path(), "src/App.tsx"); let detector = create_test_detector(); @@ -330,8 +390,7 @@ mod tests { assert!(metadata.languages.contains(&"typescript".to_string())); assert!(metadata.languages.contains(&"javascript".to_string())); - // React detection currently relies on filename containing "react", which is weak. - // But let's verify TS detection at least. + assert!(metadata.frameworks.contains(&"react".to_string())); } #[test] diff --git a/vulnera-orchestrator/src/presentation/auth/controller.rs b/vulnera-orchestrator/src/presentation/auth/controller.rs index 0f538a39..b16ba9ae 100644 --- a/vulnera-orchestrator/src/presentation/auth/controller.rs +++ b/vulnera-orchestrator/src/presentation/auth/controller.rs @@ -35,6 +35,7 @@ pub struct AuthAppState { pub csrf_service: Arc, pub token_ttl_hours: u64, pub refresh_token_ttl_hours: u64, + pub api_key_ttl_days: Option, // Token blacklist for logout pub token_blacklist: Option>, pub blacklist_tokens_on_logout: bool, @@ -534,11 +535,11 @@ pub async fn create_api_key( State(state): State, axum::Json(request): axum::Json, ) -> Result, (StatusCode, Json)> { - // Get config for API key TTL - // For now, calculate expires_at from config + // Compute default API key expiration from configured auth policy let expires_at = request.expires_at.or_else(|| { - // Default: 1 year from now - Some(Utc::now() + Duration::days(365)) + state + .api_key_ttl_days + .map(|days| Utc::now() + Duration::days(days as i64)) }); // Get dependencies from state diff --git a/vulnera-orchestrator/src/presentation/controllers/analytics.rs b/vulnera-orchestrator/src/presentation/controllers/analytics.rs index 0a2ab113..ca5e03b4 100644 --- a/vulnera-orchestrator/src/presentation/controllers/analytics.rs +++ b/vulnera-orchestrator/src/presentation/controllers/analytics.rs @@ -10,7 +10,7 @@ use serde::Deserialize; use tracing::{error, instrument}; use uuid::Uuid; -use vulnera_core::domain::organization::value_objects::OrganizationId; +use vulnera_core::domain::organization::value_objects::{OrganizationId, SubscriptionTier}; use crate::presentation::auth::Auth; use crate::presentation::controllers::OrchestratorState; @@ -266,8 +266,14 @@ pub async fn get_quota( let is_over_limit = scans.is_exceeded || api_calls.is_exceeded || members.is_exceeded; - // Determine tier (default to Free for now) - let tier = "Free".to_string(); + // Determine tier from subscription limits; default to Free when limits are not initialized + let tier = match quota.tier.unwrap_or(SubscriptionTier::Free) { + SubscriptionTier::Free => "Free", + SubscriptionTier::Starter => "Starter", + SubscriptionTier::Professional => "Professional", + SubscriptionTier::Enterprise => "Enterprise", + } + .to_string(); Ok(Json(QuotaUsageResponse { organization_id: id, diff --git a/vulnera-orchestrator/src/presentation/controllers/organization.rs b/vulnera-orchestrator/src/presentation/controllers/organization.rs index 7891ea3d..6a8c9deb 100644 --- a/vulnera-orchestrator/src/presentation/controllers/organization.rs +++ b/vulnera-orchestrator/src/presentation/controllers/organization.rs @@ -9,7 +9,7 @@ use tracing::{error, info, instrument}; use uuid::Uuid; use vulnera_core::domain::auth::value_objects::UserId; -use vulnera_core::domain::organization::value_objects::OrganizationId; +use vulnera_core::domain::organization::value_objects::{OrganizationId, SubscriptionTier}; use crate::presentation::auth::Auth; use crate::presentation::controllers::OrchestratorState; @@ -63,6 +63,7 @@ pub async fn create_organization( // Get member count (just the owner at creation) let member_count = 1; + let tier = resolve_org_tier(&state, organization.id).await; Ok(( StatusCode::CREATED, @@ -72,7 +73,7 @@ pub async fn create_organization( description: organization.description.clone(), owner_id: organization.owner_id.as_uuid(), member_count, - tier: "Free".to_string(), + tier, created_at: organization.created_at, updated_at: organization.updated_at, }), @@ -120,13 +121,15 @@ pub async fn list_organizations( .await .unwrap_or(0) as usize; + let tier = resolve_org_tier(&state, org.id).await; + org_responses.push(OrganizationResponse { id: org.id.as_uuid(), name: org.name.as_str().to_string(), description: org.description.clone(), owner_id: org.owner_id.as_uuid(), member_count, - tier: "Free".to_string(), + tier, created_at: org.created_at, updated_at: org.updated_at, }); @@ -180,6 +183,7 @@ pub async fn get_organization( let organization = details.organization; let member_count = details.members.len(); + let tier = resolve_org_tier(&state, organization.id).await; Ok(Json(OrganizationResponse { id: organization.id.as_uuid(), @@ -187,7 +191,7 @@ pub async fn get_organization( description: organization.description.clone(), owner_id: organization.owner_id.as_uuid(), member_count, - tier: "Free".to_string(), + tier, created_at: organization.created_at, updated_at: organization.updated_at, })) @@ -241,6 +245,7 @@ pub async fn update_organization( .count_members(&org_id) .await .unwrap_or(0) as usize; + let tier = resolve_org_tier(&state, org_id).await; info!(org_id = %id, "Organization updated successfully"); @@ -250,7 +255,7 @@ pub async fn update_organization( description: organization.description.clone(), owner_id: organization.owner_id.as_uuid(), member_count, - tier: "Free".to_string(), + tier, created_at: organization.created_at, updated_at: organization.updated_at, })) @@ -600,6 +605,7 @@ pub async fn transfer_ownership( .count_members(&org_id) .await .unwrap_or(0) as usize; + let tier = resolve_org_tier(&state, org_id).await; info!(new_owner = %request.new_owner_id, "Ownership transferred successfully"); @@ -609,12 +615,31 @@ pub async fn transfer_ownership( description: organization.description.clone(), owner_id: organization.owner_id.as_uuid(), member_count, - tier: "Free".to_string(), + tier, created_at: organization.created_at, updated_at: organization.updated_at, })) } +async fn resolve_org_tier(state: &OrchestratorState, org_id: OrganizationId) -> String { + let tier = state + .analytics + .check_quota_use_case + .get_quota_status(org_id) + .await + .ok() + .and_then(|quota| quota.tier) + .unwrap_or(SubscriptionTier::Free); + + match tier { + SubscriptionTier::Free => "Free", + SubscriptionTier::Starter => "Starter", + SubscriptionTier::Professional => "Professional", + SubscriptionTier::Enterprise => "Enterprise", + } + .to_string() +} + /// GET /api/v1/organizations/{id}/stats - Get organization statistics #[utoipa::path( get, diff --git a/vulnera-orchestrator/src/presentation/middleware/mod.rs b/vulnera-orchestrator/src/presentation/middleware/mod.rs index b78a11ba..b9742e1d 100644 --- a/vulnera-orchestrator/src/presentation/middleware/mod.rs +++ b/vulnera-orchestrator/src/presentation/middleware/mod.rs @@ -522,8 +522,7 @@ pub async fn rate_limit_middleware( // Extract request metadata let ip = extract_ip(&request); - // Get authentication info from request extensions (set by auth extractors) - // For now, we check headers directly since extensions might not be set yet + // Get authentication info from request extensions populated by early_auth_middleware let (user_id, api_key_id, is_org_member) = extract_auth_info(&request); // Determine request cost @@ -666,12 +665,10 @@ pub async fn auth_rate_limit_middleware( } } -/// Extract authentication info from request headers and extensions +/// Extract authentication info from request extensions /// Returns (user_id, api_key_id, is_org_member) fn extract_auth_info(request: &Request) -> (Option, Option, bool) { - use crate::presentation::auth::extractors::{ApiKeyAuth, Auth, AuthUser}; - - // First check for EarlyAuthInfo (set by early_auth_middleware) + // EarlyAuthInfo is set by early_auth_middleware before rate limiting. if let Some(early_auth) = request.extensions().get::() { if early_auth.user_id.is_some() { return ( @@ -682,34 +679,6 @@ fn extract_auth_info(request: &Request) -> (Option, Option, bool) { } } - // Fall back to checking handler extractors (in case middleware didn't run) - // First check for the unified Auth extractor (has most info) - if let Some(auth) = request.extensions().get::() { - return ( - Some(auth.user_id.into()), - auth.api_key_id.map(|id| id.into()), - auth.organization_id.is_some(), // Use actual org membership - ); - } - - // Check for API key auth in extensions - if let Some(api_key_auth) = request.extensions().get::() { - return ( - Some(api_key_auth.user_id.into()), - Some(api_key_auth.api_key_id.into()), - false, // API key users have individual limits, org bonus handled separately - ); - } - - // Check for cookie-based auth in extensions - if let Some(auth_user) = request.extensions().get::() { - return ( - Some(auth_user.user_id.into()), - None, // Cookie auth doesn't use API keys - false, // Cookie auth doesn't carry org info currently - ); - } - // Anonymous user (None, None, false) } diff --git a/vulnera-orchestrator/src/presentation/routes.rs b/vulnera-orchestrator/src/presentation/routes.rs index 1126642c..1e6215d4 100644 --- a/vulnera-orchestrator/src/presentation/routes.rs +++ b/vulnera-orchestrator/src/presentation/routes.rs @@ -313,6 +313,7 @@ pub fn create_router(orchestrator_state: OrchestratorState, config: Arc) auth_state: orchestrator_state.auth.auth_state.clone(), token_ttl_hours: config.auth.token_ttl_hours, refresh_token_ttl_hours: config.auth.refresh_token_ttl_hours, + api_key_ttl_days: config.auth.api_key_ttl_days, token_blacklist: Some(orchestrator_state.auth.token_blacklist.clone()), blacklist_tokens_on_logout: config.auth.blacklist_tokens_on_logout, csrf_service: csrf_service.clone(), From a5466c0f3c08994c13fed3a83557bbe4315a7713 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:47:25 +0200 Subject: [PATCH 5/9] Add array item schema support to OpenAPI analyzers - Add `items` field to `ApiSchema` and parse it in `OpenApiParser` - Update `InputValidationAnalyzer` to recurse into array item schemas - Refactor `ResourceRestrictionAnalyzer` to detect arrays via recursive schema walk - Enhance `SecurityHeadersAnalyzer` to check for wildcard CORS origins in header schemas - Add unit test for array item recursion in input validation analyzer --- vulnera-api/src/domain/value_objects.rs | 3 +- .../analyzers/data_exposure_analyzer.rs | 58 ++--- .../analyzers/input_validation_analyzer.rs | 14 +- .../resource_restriction_analyzer.rs | 39 ++- .../analyzers/security_headers_analyzer.rs | 67 ++++- .../infrastructure/parser/openapi_parser.rs | 10 + .../unit/analyzers/test_enhanced_analyzers.rs | 128 +++++++++- .../src/infrastructure/parsers/java.rs | 149 ++++++++++- .../src/infrastructure/parsers/npm.rs | 131 ++++++---- .../src/infrastructure/parsers/yarn_pest.rs | 235 ++++++++++++++++-- 10 files changed, 693 insertions(+), 141 deletions(-) diff --git a/vulnera-api/src/domain/value_objects.rs b/vulnera-api/src/domain/value_objects.rs index ea623d5d..4bdd135d 100644 --- a/vulnera-api/src/domain/value_objects.rs +++ b/vulnera-api/src/domain/value_objects.rs @@ -55,7 +55,7 @@ pub enum ApiVulnerabilityType { IneffectiveScopeHierarchy, } -/// OpenAPI specification model (simplified) +/// OpenAPI specification model for analyzer pipelines #[derive(Debug, Clone)] pub struct OpenApiSpec { pub version: String, @@ -149,6 +149,7 @@ pub struct ApiSchema { pub multiple_of: Option, // Number must be multiple of this pub min_items: Option, // Minimum array items pub max_items: Option, // Maximum array items + pub items: Option>, // Array item schema // Logical constraints (composition) pub one_of: Vec, diff --git a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs index d2980300..9bec2ca5 100644 --- a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs @@ -2,11 +2,28 @@ use crate::domain::entities::{ApiFinding, ApiLocation, FindingSeverity}; use crate::domain::value_objects::{ApiVulnerabilityType, OpenApiSpec, ParameterLocation}; -use tracing::error; +use regex::Regex; +use std::sync::OnceLock; /// Analyzer for sensitive data exposure pub struct DataExposureAnalyzer; +fn jwt_pattern() -> &'static Regex { + static JWT_PATTERN: OnceLock = OnceLock::new(); + JWT_PATTERN.get_or_init(|| { + Regex::new(r"eyJ[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+") + .expect("JWT regex pattern must be valid") + }) +} + +fn private_key_pattern() -> &'static Regex { + static PRIVATE_KEY_PATTERN: OnceLock = OnceLock::new(); + PRIVATE_KEY_PATTERN.get_or_init(|| { + Regex::new(r"-----BEGIN [A-Z]+ PRIVATE KEY-----") + .expect("private key regex pattern must be valid") + }) +} + impl DataExposureAnalyzer { pub fn analyze(spec: &OpenApiSpec) -> Vec { let mut findings = Vec::new(); @@ -21,31 +38,8 @@ impl DataExposureAnalyzer { "refresh_token", ]; - // Compile regexes for secret detection - // Note: Using lazy_static here would be better performance-wise if this analyzer is instantiated often, - // but for now local compilation is fine or better yet, move to lazy_static in module scope if possible. - // Given existing structure, we'll compile locally or use a static block if we refactor. - let jwt_pattern = - match regex::Regex::new(r"eyJ[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+") { - Ok(pattern) => pattern, - Err(error) => { - error!( - "Failed to compile JWT regex in data exposure analyzer: {}", - error - ); - return findings; - } - }; - let private_key_pattern = match regex::Regex::new(r"-----BEGIN [A-Z]+ PRIVATE KEY-----") { - Ok(pattern) => pattern, - Err(error) => { - error!( - "Failed to compile private key regex in data exposure analyzer: {}", - error - ); - return findings; - } - }; + let jwt_pattern = jwt_pattern(); + let private_key_pattern = private_key_pattern(); for path in &spec.paths { for operation in &path.operations { @@ -130,8 +124,8 @@ impl DataExposureAnalyzer { &operation.method, "request_body", &mut findings, - &jwt_pattern, - &private_key_pattern, + jwt_pattern, + private_key_pattern, ); } } @@ -147,8 +141,8 @@ impl DataExposureAnalyzer { &operation.method, &format!("response:{}", response.status_code), &mut findings, - &jwt_pattern, - &private_key_pattern, + jwt_pattern, + private_key_pattern, ); } } @@ -232,8 +226,8 @@ impl DataExposureAnalyzer { method, &format!("{}.{}", context, prop.name), findings, - jwt_pattern, - private_key_pattern, + jwt_pattern, + private_key_pattern, ); } } diff --git a/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs index 97462ed6..fa46f4d8 100644 --- a/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs @@ -237,10 +237,16 @@ impl InputValidationAnalyzer { method: Some(method.to_string()), }); } - // NOTE: Recursive analysis for array items logic would need item schema extraction, - // but ApiSchema doesn't have 'items' field in value_objects.rs yet (omitted in initial implementation plan?) - // Checked value_objects.rs: 'items' is missing! - // I should add it later, but for now properties recursion is good for objects. + + if let Some(item_schema) = &schema.items { + Self::analyze_schema( + item_schema, + path, + method, + &format!("{}[]", context), + findings, + ); + } } } } diff --git a/vulnera-api/src/infrastructure/analyzers/resource_restriction_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/resource_restriction_analyzer.rs index 6becee47..29b6372b 100644 --- a/vulnera-api/src/infrastructure/analyzers/resource_restriction_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/resource_restriction_analyzer.rs @@ -8,6 +8,34 @@ use crate::domain::value_objects::{ApiVulnerabilityType, OpenApiSpec}; pub struct ResourceRestrictionAnalyzer; impl ResourceRestrictionAnalyzer { + fn schema_contains_array(schema: &crate::domain::value_objects::ApiSchema) -> bool { + if schema.schema_type.as_deref() == Some("array") { + return true; + } + + if schema + .properties + .iter() + .any(|prop| Self::schema_contains_array(&prop.schema)) + { + return true; + } + + if schema.one_of.iter().any(Self::schema_contains_array) + || schema.any_of.iter().any(Self::schema_contains_array) + || schema.all_of.iter().any(Self::schema_contains_array) + { + return true; + } + + match &schema.additional_properties { + crate::domain::value_objects::AdditionalProperties::Schema(nested) => { + Self::schema_contains_array(nested) + } + _ => false, + } + } + pub fn analyze(spec: &OpenApiSpec) -> Vec { let mut findings = Vec::new(); @@ -24,20 +52,11 @@ impl ResourceRestrictionAnalyzer { if response.status_code.starts_with('2') { for content in &response.content { if let Some(schema) = &content.schema { - if schema.schema_type.as_deref() == Some("array") { + if Self::schema_contains_array(schema) { returns_array = true; } - // or object wrapping array (e.g. { data: [...] }) - simplified check for now - // Check properties for array - for prop in &schema.properties { - if prop.schema.schema_type.as_deref() == Some("array") { - returns_array = true; - } - } } } - - // Check for rate limit headers in 2xx or 429 response // Check for rate limit headers in 2xx or 429 response if response .headers diff --git a/vulnera-api/src/infrastructure/analyzers/security_headers_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/security_headers_analyzer.rs index 24f4c715..3cf1e463 100644 --- a/vulnera-api/src/infrastructure/analyzers/security_headers_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/security_headers_analyzer.rs @@ -7,6 +7,31 @@ use crate::domain::value_objects::{ApiVulnerabilityType, OpenApiSpec}; pub struct SecurityHeadersAnalyzer; impl SecurityHeadersAnalyzer { + fn schema_has_wildcard_origin(schema: &crate::domain::value_objects::ApiSchema) -> bool { + if schema + .default + .as_ref() + .and_then(|v| v.as_str()) + .is_some_and(|v| v.trim() == "*") + { + return true; + } + + if schema + .example + .as_ref() + .and_then(|v| v.as_str()) + .is_some_and(|v| v.trim() == "*") + { + return true; + } + + schema + .enum_values + .as_ref() + .is_some_and(|vals| vals.iter().any(|v| v.trim() == "*")) + } + pub fn analyze(spec: &OpenApiSpec) -> Vec { let mut findings = Vec::new(); @@ -60,14 +85,33 @@ impl SecurityHeadersAnalyzer { } } - // Check for CORS headers and validate configuration - // Note: CORS header values are in the header schema, not the header name - // This is a simplified check - in a full implementation, we'd parse the header schema - if found_headers + if let Some(cors_header) = response + .headers .iter() - .any(|h| h.eq_ignore_ascii_case("Access-Control-Allow-Origin")) + .find(|h| h.name.eq_ignore_ascii_case("Access-Control-Allow-Origin")) { - // Flag CORS header presence for review (can't check value from spec alone) + let (severity, description) = if cors_header + .schema + .as_ref() + .is_some_and(Self::schema_has_wildcard_origin) + { + ( + FindingSeverity::High, + format!( + "Endpoint {} {} allows wildcard CORS origin '*'", + operation.method, path.path + ), + ) + } else { + ( + FindingSeverity::Low, + format!( + "Endpoint {} {} defines CORS origin header; verify allowed origins are restricted", + operation.method, path.path + ), + ) + }; + findings.push(ApiFinding { id: format!("cors-review-{}-{}", path.path, operation.method), vulnerability_type: ApiVulnerabilityType::InsecureCors, @@ -77,12 +121,11 @@ impl SecurityHeadersAnalyzer { path: Some(path.path.clone()), operation: Some(operation.method.clone()), }, - severity: FindingSeverity::Low, - description: format!( - "Endpoint {} {} has CORS header - verify it's not allowing all origins (*)", - operation.method, path.path - ), - recommendation: "Ensure CORS is restricted to specific trusted origins, not '*'".to_string(), + severity, + description, + recommendation: + "Ensure CORS is restricted to specific trusted origins, not '*'" + .to_string(), path: Some(path.path.clone()), method: Some(operation.method.clone()), }); diff --git a/vulnera-api/src/infrastructure/parser/openapi_parser.rs b/vulnera-api/src/infrastructure/parser/openapi_parser.rs index 4420c5a7..152d7c06 100644 --- a/vulnera-api/src/infrastructure/parser/openapi_parser.rs +++ b/vulnera-api/src/infrastructure/parser/openapi_parser.rs @@ -558,6 +558,15 @@ impl OpenApiParser { let max_length = obj_schema.max_length.map(|v| v as u32); let min_items = obj_schema.min_items.map(|v| v as u32); let max_items = obj_schema.max_items.map(|v| v as u32); + let items = obj_schema.items.as_ref().map(|item_schema| { + let parsed = match item_schema.as_ref() { + oas3::spec::Schema::Object(object_schema) => { + Self::parse_schema(object_schema.as_ref(), schema_resolver) + } + oas3::spec::Schema::Boolean(_) => ApiSchema::default(), + }; + Box::new(parsed) + }); // Map logical constraints let all_of: Vec = obj_schema @@ -629,6 +638,7 @@ impl OpenApiParser { max_length, min_items, max_items, + items, enum_values, multiple_of, example, diff --git a/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs b/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs index 3c064737..cb7a7f58 100644 --- a/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs +++ b/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs @@ -6,7 +6,7 @@ use vulnera_api::domain::value_objects::{ }; use vulnera_api::infrastructure::analyzers::{ AuthorizationAnalyzer, DataExposureAnalyzer, InputValidationAnalyzer, - ResourceRestrictionAnalyzer, SecurityMisconfigAnalyzer, + ResourceRestrictionAnalyzer, SecurityHeadersAnalyzer, SecurityMisconfigAnalyzer, }; // --- Helpers --- @@ -122,6 +122,52 @@ fn test_detects_mass_assignment() { ); } +#[test] +fn test_input_validation_recurses_into_array_item_schemas() { + let spec = OpenApiSpec { + version: "3.0.0".to_string(), + paths: vec![ApiPath { + path: "/bulk-users".to_string(), + operations: vec![ApiOperation { + method: "POST".to_string(), + security: vec![], + parameters: vec![], + request_body: Some(ApiRequestBody { + required: true, + content: vec![ApiContent { + media_type: "application/json".to_string(), + schema: Some(ApiSchema { + schema_type: Some("array".to_string()), + max_items: Some(100), + items: Some(Box::new(ApiSchema { + schema_type: Some("object".to_string()), + properties: vec![ApiProperty { + name: "username".to_string(), + schema: ApiSchema { + schema_type: Some("string".to_string()), + ..Default::default() + }, + }], + ..Default::default() + })), + ..Default::default() + }), + }], + }), + responses: vec![], + }], + }], + security_schemes: vec![], + global_security: vec![], + }; + + let findings = InputValidationAnalyzer::analyze(&spec); + assert!(findings.iter().any(|f| { + f.vulnerability_type == ApiVulnerabilityType::UnboundedInput + && f.description.contains("body[].username") + })); +} + // --- Authorization Tests --- #[test] @@ -232,6 +278,50 @@ fn test_detects_missing_pagination() { ); } +#[test] +fn test_detects_missing_pagination_for_wrapped_array_response() { + let spec = OpenApiSpec { + version: "3.0.0".to_string(), + paths: vec![ApiPath { + path: "/wrapped-items".to_string(), + operations: vec![ApiOperation { + method: "GET".to_string(), + security: vec![], + parameters: vec![], + request_body: None, + responses: vec![ApiResponse { + status_code: "200".to_string(), + content: vec![ApiContent { + media_type: "application/json".to_string(), + schema: Some(ApiSchema { + schema_type: Some("object".to_string()), + properties: vec![ApiProperty { + name: "data".to_string(), + schema: ApiSchema { + schema_type: Some("array".to_string()), + ..Default::default() + }, + }], + ..Default::default() + }), + }], + headers: vec![], + }], + }], + }], + security_schemes: vec![], + global_security: vec![], + }; + + let findings = ResourceRestrictionAnalyzer::analyze(&spec); + assert!( + findings + .iter() + .any(|f| f.id.contains("missing-pagination") + && f.vulnerability_type == ApiVulnerabilityType::ResourceExhaustion) + ); +} + // --- Security Misconfig Tests --- #[test] @@ -270,3 +360,39 @@ fn test_detects_cors_wildcard() { .any(|f| f.vulnerability_type == ApiVulnerabilityType::CorsWildcard) ); } + +#[test] +fn test_security_headers_detects_insecure_cors_wildcard_from_schema() { + let spec = OpenApiSpec { + version: "3.0.0".to_string(), + paths: vec![ApiPath { + path: "/cors".to_string(), + operations: vec![ApiOperation { + method: "GET".to_string(), + security: vec![], + parameters: vec![], + request_body: None, + responses: vec![ApiResponse { + status_code: "200".to_string(), + content: vec![], + headers: vec![ApiHeader { + name: "Access-Control-Allow-Origin".to_string(), + schema: Some(ApiSchema { + schema_type: Some("string".to_string()), + default: Some(serde_json::Value::String("*".to_string())), + ..Default::default() + }), + }], + }], + }], + }], + security_schemes: vec![], + global_security: vec![], + }; + + let findings = SecurityHeadersAnalyzer::analyze(&spec); + assert!(findings.iter().any(|f| { + f.vulnerability_type == ApiVulnerabilityType::InsecureCors + && f.severity == vulnera_api::domain::entities::FindingSeverity::High + })); +} diff --git a/vulnera-core/src/infrastructure/parsers/java.rs b/vulnera-core/src/infrastructure/parsers/java.rs index 251726a8..76ec3568 100644 --- a/vulnera-core/src/infrastructure/parsers/java.rs +++ b/vulnera-core/src/infrastructure/parsers/java.rs @@ -11,6 +11,7 @@ use once_cell::sync::Lazy; use quick_xml::Reader; use quick_xml::events::Event; use regex::Regex; +use std::collections::HashMap; /// Parser for Maven pom.xml files pub struct MavenParser; @@ -31,6 +32,7 @@ impl MavenParser { &self, content: &str, root_package: &Package, + properties: &HashMap, ) -> Result { let mut packages = Vec::new(); let mut dependencies = Vec::new(); @@ -66,8 +68,10 @@ impl MavenParser { if let (Some(g), Some(a)) = (group_id.as_ref(), artifact_id.as_ref()) { let pkg_name = format!("{}:{}", g, a); // Clean version - let cleaned = self - .clean_maven_version(version_str.as_deref().unwrap_or("0.0.0"))?; + let cleaned = self.clean_maven_version( + version_str.as_deref().unwrap_or("0.0.0"), + properties, + )?; let version = Version::parse(&cleaned).map_err(|_| ParseError::Version { version: version_str.clone().unwrap_or_default(), @@ -76,7 +80,6 @@ impl MavenParser { .map_err(|e| ParseError::MissingField { field: e })?; packages.push(package.clone()); - // Create dependency edge from root dependencies.push( crate::domain::vulnerability::entities::Dependency::new( root_package.clone(), @@ -128,7 +131,11 @@ impl MavenParser { } /// Extract root package information from pom.xml - fn extract_root_package(&self, content: &str) -> Result { + fn extract_root_package( + &self, + content: &str, + properties: &HashMap, + ) -> Result { let mut reader = Reader::from_str(content); let mut buf = Vec::new(); let mut depth = 0; @@ -200,15 +207,72 @@ impl MavenParser { let v_str = version .or(parent_version) .unwrap_or_else(|| "0.0.0".to_string()); - let cleaned_v = self.clean_maven_version(&v_str)?; + let cleaned_v = self.clean_maven_version(&v_str, properties)?; let v = Version::parse(&cleaned_v).unwrap_or_else(|_| Version::new(0, 0, 0)); Package::new(format!("{}:{}", g, a), v, Ecosystem::Maven) .map_err(|e| ParseError::MissingField { field: e }) } + fn extract_maven_properties( + &self, + content: &str, + ) -> Result, ParseError> { + let mut properties = HashMap::new(); + let mut reader = Reader::from_str(content); + let mut buf = Vec::new(); + + let mut stack: Vec = Vec::new(); + let mut current_property: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); + stack.push(name.clone()); + if stack.len() == 3 && stack[0] == "project" && stack[1] == "properties" { + current_property = Some(name); + } + } + Ok(Event::Text(t)) => { + if let Some(property) = current_property.as_ref() { + let value = reader + .decoder() + .decode(t.as_ref()) + .unwrap_or_default() + .trim() + .to_string(); + if !value.is_empty() { + properties.insert(property.clone(), value); + } + } + } + Ok(Event::End(_)) => { + if stack.len() == 3 && stack[0] == "project" && stack[1] == "properties" { + current_property = None; + } + stack.pop(); + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(ParseError::MissingField { + field: format!("XML parse error: {}", e), + }); + } + _ => {} + } + buf.clear(); + } + + Ok(properties) + } + /// Clean Maven version string - fn clean_maven_version(&self, version_str: &str) -> Result { + fn clean_maven_version( + &self, + version_str: &str, + properties: &HashMap, + ) -> Result { let version_str = version_str.trim(); if version_str.is_empty() { @@ -218,6 +282,12 @@ impl MavenParser { // Handle Maven property placeholders with common defaults if version_str.starts_with("${") && version_str.ends_with('}') { let property = &version_str[2..version_str.len() - 1]; + if let Some(value) = properties.get(property) { + let resolved = value.trim(); + if !resolved.is_empty() { + return Ok(resolved.to_string()); + } + } return Ok(match property { "project.version" | "version" => "1.0.0".to_string(), "java.version" => "11".to_string(), @@ -257,8 +327,9 @@ impl PackageFileParser for MavenParser { } async fn parse_file(&self, content: &str) -> Result { - let root_package = self.extract_root_package(content)?; - self.extract_maven_dependencies(content, &root_package) + let properties = self.extract_maven_properties(content)?; + let root_package = self.extract_root_package(content, &properties)?; + self.extract_maven_dependencies(content, &root_package, &properties) } fn ecosystem(&self) -> Ecosystem { @@ -487,23 +558,75 @@ dependencies { assert_eq!(guava_pkg.version, Version::parse("31.1").unwrap()); // -jre suffix handled } + #[tokio::test] + async fn test_maven_parser_resolves_properties_from_pom() { + let parser = MavenParser::new(); + let content = r#" + + 4.0.0 + com.example + demo + 1.0.0 + + 6.1.5 + + + + org.springframework + spring-core + ${spring.version} + + + + "#; + + let result = parser.parse_file(content).await.unwrap(); + assert!(result + .packages + .iter() + .any(|p| p.name == "org.springframework:spring-core" && p.version.to_string() == "6.1.5")); + } + #[test] fn test_clean_maven_version() { let parser = MavenParser::new(); + let properties = HashMap::new(); - assert_eq!(parser.clean_maven_version("5.3.21").unwrap(), "5.3.21"); + assert_eq!( + parser.clean_maven_version("5.3.21", &properties).unwrap(), + "5.3.21" + ); // Updated expectation: ${spring.version} now returns "5.3.0" as a reasonable default assert_eq!( - parser.clean_maven_version("${spring.version}").unwrap(), + parser + .clean_maven_version("${spring.version}", &properties) + .unwrap(), "5.3.0" ); - assert_eq!(parser.clean_maven_version("[1.0,2.0)").unwrap(), "1.0"); - assert_eq!(parser.clean_maven_version("(1.0,2.0]").unwrap(), "1.0"); + assert_eq!( + parser.clean_maven_version("[1.0,2.0)", &properties).unwrap(), + "1.0" + ); + assert_eq!( + parser.clean_maven_version("(1.0,2.0]", &properties).unwrap(), + "1.0" + ); // Test that unknown properties default to "1.0.0" instead of "0.0.0" assert_eq!( - parser.clean_maven_version("${unknown.property}").unwrap(), + parser + .clean_maven_version("${unknown.property}", &properties) + .unwrap(), "1.0.0" ); + + let mut resolved = HashMap::new(); + resolved.insert("custom.version".to_string(), "9.8.7".to_string()); + assert_eq!( + parser + .clean_maven_version("${custom.version}", &resolved) + .unwrap(), + "9.8.7" + ); } #[test] diff --git a/vulnera-core/src/infrastructure/parsers/npm.rs b/vulnera-core/src/infrastructure/parsers/npm.rs index 768eefe8..581c32a6 100644 --- a/vulnera-core/src/infrastructure/parsers/npm.rs +++ b/vulnera-core/src/infrastructure/parsers/npm.rs @@ -306,44 +306,10 @@ impl PackageLockParser { packages.push(package.clone()); - // Extract dependencies from 'requires' (v1) or 'dependencies' (lockfileVersion 2/3 packages) - // Note: In v1 'dependencies' is the nested tree, 'requires' is the logical deps. - // In lockfileVersion 2/3 'packages' entries, 'dependencies' is the logical deps. - // We check both 'requires' and 'dependencies' here but treat them as logical deps if they are simple key-value pairs - // However, 'dependencies' in v1 is nested objects, so we need to be careful. - // A simple heuristic: if the value is a string, it's a version requirement (logical dep). - // If it's an object, it's a nested dependency (physical tree). - - let mut extract_edges_from = |key: &str| { - if let Some(reqs) = dep_info.get(key).and_then(|r| r.as_object()) { - for (dep_name, dep_ver_val) in reqs { - if let Some(dep_req) = dep_ver_val.as_str() { - pending_dependencies.push(( - package.clone(), - dep_name.clone(), - dep_req.to_string(), - )); - } - } - } - }; - - extract_edges_from("requires"); - // For lockfileVersion 2/3 'packages' entries, 'dependencies' lists logical deps as strings - // But we need to distinguish from v1 'dependencies' which are objects. - if let Some(deps_val) = dep_info.get("dependencies") { - if let Some(deps_obj) = deps_val.as_object() { - // Instead of checking only the first value, iterate over all values. - for (dep_name, dep_ver_val) in deps_obj { - if let Some(dep_req) = dep_ver_val.as_str() { - pending_dependencies.push(( - package.clone(), - dep_name.clone(), - dep_req.to_string(), - )); - } - } - } + for (dep_name, dep_req) in + Self::extract_logical_dependency_specs(dep_info, is_packages_section) + { + pending_dependencies.push((package.clone(), dep_name, dep_req)); } } @@ -351,15 +317,12 @@ impl PackageLockParser { // Skip for lockfileVersion 2/3 as they use a flat packages structure instead of nested dependencies if !is_packages_section { if let Some(nested_deps) = dep_info.get("dependencies") { - // Check if values are objects (nested deps) + // Recurse when v1 dependencies map contains nested package objects. if let Some(deps_obj) = nested_deps.as_object() { - if let Some((_, val)) = deps_obj.iter().next() { - if val.is_object() { - let nested_result = - Self::extract_lockfile_data(nested_deps, false)?; - packages.extend(nested_result.packages); - dependencies.extend(nested_result.dependencies); - } + if deps_obj.values().any(Value::is_object) { + let nested_result = Self::extract_lockfile_data(nested_deps, false)?; + packages.extend(nested_result.packages); + dependencies.extend(nested_result.dependencies); } } } @@ -378,6 +341,51 @@ impl PackageLockParser { dependencies, }) } + + fn extract_string_dependency_map(dep_info: &Value, key: &str) -> Vec<(String, String)> { + dep_info + .get(key) + .and_then(Value::as_object) + .map(|obj| { + obj.iter() + .filter_map(|(dep_name, dep_val)| { + dep_val + .as_str() + .map(|req| (dep_name.clone(), req.to_string())) + }) + .collect() + }) + .unwrap_or_default() + } + + fn extract_logical_dependency_specs( + dep_info: &Value, + is_packages_section: bool, + ) -> Vec<(String, String)> { + let mut out = Vec::new(); + + if is_packages_section { + // npm lockfile v2/v3 package entries use string maps for logical dependency specs. + for key in [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", + ] { + out.extend(Self::extract_string_dependency_map(dep_info, key)); + } + return out; + } + + // npm lockfile v1: `requires` is the canonical logical dependency map. + let requires = Self::extract_string_dependency_map(dep_info, "requires"); + if !requires.is_empty() { + return requires; + } + + // Compatibility fallback for malformed/non-standard lockfiles. + Self::extract_string_dependency_map(dep_info, "dependencies") + } } #[async_trait] @@ -941,4 +949,35 @@ lodash@~4.17.21: .expect("Express should depend on accepts"); assert_eq!(express_dep.requirement, "~1.3.7"); } + + #[tokio::test] + async fn test_package_lock_v1_prefers_requires_for_logical_edges() { + let parser = PackageLockParser::new(); + let content = r#" + { + "name": "app", + "lockfileVersion": 1, + "dependencies": { + "express": { + "version": "4.17.1", + "requires": { + "accepts": "~1.3.7" + }, + "dependencies": { + "accepts": { + "version": "1.3.7" + } + } + } + } + } + "#; + + let result = parser.parse_file(content).await.unwrap(); + + assert!(result + .dependencies + .iter() + .any(|d| d.from.name == "express" && d.to.name == "accepts" && d.requirement == "~1.3.7")); + } } diff --git a/vulnera-core/src/infrastructure/parsers/yarn_pest.rs b/vulnera-core/src/infrastructure/parsers/yarn_pest.rs index 2be45777..80ec7159 100644 --- a/vulnera-core/src/infrastructure/parsers/yarn_pest.rs +++ b/vulnera-core/src/infrastructure/parsers/yarn_pest.rs @@ -5,8 +5,8 @@ use pest_derive::Parser; use crate::application::errors::ParseError; use crate::domain::vulnerability::{ - entities::Package, - value_objects::{Ecosystem, Version}, + entities::{Dependency, Package}, + value_objects::{Ecosystem, Version, VersionRange}, }; use super::traits::{PackageFileParser, ParseResult}; @@ -65,6 +65,13 @@ struct YarnLockPest; /// - Handles Yarn v1 lockfiles only (Yarn v2+ uses different format) pub struct YarnPestParser; +#[derive(Debug, Clone)] +struct ParsedEntry { + names: Vec, + version: Option, + dependency_specs: Vec<(String, String)>, +} + impl Default for YarnPestParser { fn default() -> Self { Self::new() @@ -145,13 +152,14 @@ impl YarnPestParser { }) } - fn process_entry(entry: Pair<'_, Rule>) -> (Vec, Option) { + fn process_entry(entry: Pair<'_, Rule>) -> ParsedEntry { // Capture raw entry text up front for fallbacks let entry_text = entry.as_str().to_string(); let mut names: Vec = Vec::new(); let mut version: Option = None; let mut header_text: Option = None; + let mut dependency_specs: Vec<(String, String)> = Vec::new(); for p in entry.clone().into_inner() { match p.as_rule() { @@ -217,27 +225,38 @@ impl YarnPestParser { | Rule::bundled_dependencies_block | Rule::dependencies_block | Rule::optional_dependencies_block => { - // Process dependency blocks (peerDependencies, bundledDependencies, etc.) - // Extract dependency names for potential future use or validation - // For now, we just recognize these blocks - they don't affect package extraction - // but improve error recovery by not failing on unknown blocks + // Process dependency blocks and extract dependency specs. for dep_item in p.into_inner() { match dep_item.as_rule() { Rule::dep_kv_line => { - // Extract dependency name from dep_key + let mut dep_name: Option = None; + let mut dep_req: Option = None; for dep_part in dep_item.into_inner() { - if dep_part.as_rule() == Rule::dep_key { - // Dependency key captured - could be used for validation - // Currently we just acknowledge it for error recovery - let _dep_name = dep_part.as_str(); + match dep_part.as_rule() { + Rule::dep_key => { + dep_name = Some(Self::dequote(dep_part.as_str())); + } + Rule::dep_value + | Rule::dep_range + | Rule::quoted_string + | Rule::bare_fragment => { + dep_req = Some(Self::dequote(dep_part.as_str())); + } + _ => {} } } + + if let Some(name) = dep_name { + let requirement = dep_req.unwrap_or_else(|| "*".to_string()); + dependency_specs.push((name, requirement)); + } } Rule::dep_name_line => { - // For bundledDependencies, sometimes just names are listed + // bundledDependencies can list names without versions. for dep_part in dep_item.into_inner() { if dep_part.as_rule() == Rule::dep_key { - let _dep_name = dep_part.as_str(); + dependency_specs + .push((Self::dequote(dep_part.as_str()), "*".to_string())); } } } @@ -276,6 +295,10 @@ impl YarnPestParser { version = Self::fallback_extract_version_from_entry(&entry_text); } + if dependency_specs.is_empty() { + dependency_specs = Self::fallback_extract_dependency_specs_from_entry(&entry_text); + } + // Deduplicate names while preserving order let mut unique = Vec::new(); for n in names { @@ -284,7 +307,72 @@ impl YarnPestParser { } } - (unique, version) + ParsedEntry { + names: unique, + version, + dependency_specs, + } + } + + fn fallback_extract_dependency_specs_from_entry(entry_text: &str) -> Vec<(String, String)> { + let mut specs = Vec::new(); + let mut in_dep_block = false; + + for line in entry_text.lines() { + let trimmed_end = line.trim_end(); + + let block_header = trimmed_end.trim_start(); + let is_dependency_header = block_header == "dependencies:" + || block_header == "optionalDependencies:" + || block_header == "peerDependencies:" + || block_header == "bundledDependencies:"; + + if is_dependency_header { + in_dep_block = true; + continue; + } + + if !in_dep_block { + continue; + } + + if trimmed_end.trim().is_empty() { + continue; + } + + if !line.starts_with(" ") { + in_dep_block = false; + continue; + } + + let dep_line = line.trim(); + if dep_line.is_empty() { + continue; + } + + if let Some(name_only) = dep_line.strip_prefix("- ") { + let dep_name = Self::dequote(name_only.trim()); + if !dep_name.is_empty() { + specs.push((dep_name, "*".to_string())); + } + continue; + } + + let mut parts = dep_line.split_whitespace(); + if let Some(dep_name_raw) = parts.next() { + let dep_name = Self::dequote(dep_name_raw); + let requirement = parts + .next() + .map(Self::dequote) + .unwrap_or_else(|| "*".to_string()); + + if !dep_name.is_empty() { + specs.push((dep_name, requirement)); + } + } + } + + specs } // Fallback: extract names from a header line like: // "lodash@^4.17.21", "@babel/core@^7.22.0": @@ -393,6 +481,7 @@ impl PackageFileParser for YarnPestParser { let pairs = self.parse_file_pairs(content)?; let mut packages: Vec = Vec::new(); + let mut parsed_entries: Vec = Vec::new(); let mut seen = std::collections::HashSet::new(); // Walk the parse tree to find entries and extract names + versions @@ -401,15 +490,15 @@ impl PackageFileParser for YarnPestParser { Rule::file => { for inner in top.into_inner() { if inner.as_rule() == Rule::entry { - let (names, version_opt) = Self::process_entry(inner); - if let Some(ver_str) = version_opt { + let parsed = Self::process_entry(inner); + if let Some(ver_str) = parsed.version.clone() { // Parse a semantic-ish version; fall back to "0.0.0" if invalid let version = Version::parse(&ver_str).unwrap_or_else(|_| { Version::parse("0.0.0") .unwrap_or_else(|_| Version::new(0, 0, 0)) }); - for name in names { + for name in &parsed.names { if seen.insert((name.clone(), version.to_string())) { if let Ok(pkg) = Package::new( name.clone(), @@ -421,18 +510,19 @@ impl PackageFileParser for YarnPestParser { } } } + parsed_entries.push(parsed); } } } Rule::entry => { // In case the top node is directly an entry - let (names, version_opt) = Self::process_entry(top); - if let Some(ver_str) = version_opt { + let parsed = Self::process_entry(top); + if let Some(ver_str) = parsed.version.clone() { let version = Version::parse(&ver_str).unwrap_or_else(|_| { Version::parse("0.0.0").unwrap_or_else(|_| Version::new(0, 0, 0)) }); - for name in names { + for name in &parsed.names { if seen.insert((name.clone(), version.to_string())) { if let Ok(pkg) = Package::new(name.clone(), version.clone(), Ecosystem::Npm) @@ -442,6 +532,7 @@ impl PackageFileParser for YarnPestParser { } } } + parsed_entries.push(parsed); } _ => {} } @@ -454,9 +545,76 @@ impl PackageFileParser for YarnPestParser { dependencies: Vec::new(), }); } + + let mut package_index: std::collections::HashMap> = + std::collections::HashMap::new(); + for package in &packages { + package_index + .entry(package.name.clone()) + .or_default() + .push(package.clone()); + } + + let mut dependencies: Vec = Vec::new(); + let mut seen_dependencies: std::collections::HashSet<(String, String, String)> = + std::collections::HashSet::new(); + + for entry in parsed_entries { + let Some(from_version_str) = entry.version else { + continue; + }; + + let Ok(from_version) = Version::parse(&from_version_str) else { + continue; + }; + + for from_name in &entry.names { + let Ok(from_package) = Package::new(from_name.clone(), from_version.clone(), Ecosystem::Npm) + else { + continue; + }; + + for (dep_name, requirement) in &entry.dependency_specs { + let Some(candidates) = package_index.get(dep_name) else { + continue; + }; + + let matched = if requirement == "*" { + candidates.iter().max_by(|a, b| a.version.cmp(&b.version)) + } else { + let Ok(range) = VersionRange::parse(requirement) else { + continue; + }; + candidates + .iter() + .filter(|candidate| range.contains(&candidate.version)) + .max_by(|a, b| a.version.cmp(&b.version)) + }; + + let Some(to_package) = matched else { + continue; + }; + + let key = ( + from_package.name.clone(), + to_package.name.clone(), + requirement.clone(), + ); + if seen_dependencies.insert(key) { + dependencies.push(Dependency::new( + from_package.clone(), + to_package.clone(), + requirement.clone(), + false, + )); + } + } + } + } + Ok(ParseResult { packages, - dependencies: Vec::new(), + dependencies, }) } @@ -526,6 +684,39 @@ lodash@^4.17.21: ); } + #[tokio::test] + async fn test_yarn_lock_extracts_dependency_edges() { + let content = r#" +foo@^1.0.0: + version "1.0.0" + dependencies: + bar "^2.0.0" + +bar@^2.0.0: + version "2.1.0" +"#; + + let parser = YarnPestParser::new(); + let result = parser.parse_file(content).await.unwrap(); + + assert!(result + .dependencies + .iter() + .any(|d| d.from.name == "foo" && d.to.name == "bar" && d.requirement == "^2.0.0"), + "dependencies={:?}, packages={:?}", + result + .dependencies + .iter() + .map(|d| format!("{} -> {} ({})", d.from.name, d.to.name, d.requirement)) + .collect::>(), + result + .packages + .iter() + .map(|p| format!("{}@{}", p.name, p.version)) + .collect::>() + ); + } + #[test] fn test_extract_name_from_key_spec() { // Simple names From da984212c8614c6e43d85c3245c4ab54eea5fd27 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:51:51 +0200 Subject: [PATCH 6/9] refactor: simplify condition in backtracking resolver and improve regex usage in data exposure analyzer --- .../analyzers/data_exposure_analyzer.rs | 4 +- .../infrastructure/parser/openapi_parser.rs | 1 - .../src/infrastructure/parsers/java.rs | 106 +++++++++++------- .../src/services/resolution_algorithms.rs | 5 +- 4 files changed, 71 insertions(+), 45 deletions(-) diff --git a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs index 9bec2ca5..1c2c6ad1 100644 --- a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs @@ -108,8 +108,8 @@ impl DataExposureAnalyzer { &operation.method, &format!("param:{}", param.name), &mut findings, - &jwt_pattern, - &private_key_pattern, + jwt_pattern, + private_key_pattern, ); } } diff --git a/vulnera-api/src/infrastructure/parser/openapi_parser.rs b/vulnera-api/src/infrastructure/parser/openapi_parser.rs index 152d7c06..9578a4b3 100644 --- a/vulnera-api/src/infrastructure/parser/openapi_parser.rs +++ b/vulnera-api/src/infrastructure/parser/openapi_parser.rs @@ -649,7 +649,6 @@ impl OpenApiParser { all_of, one_of, any_of, - ..Default::default() } } oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { diff --git a/vulnera-core/src/infrastructure/parsers/java.rs b/vulnera-core/src/infrastructure/parsers/java.rs index 76ec3568..e07ce1e5 100644 --- a/vulnera-core/src/infrastructure/parsers/java.rs +++ b/vulnera-core/src/infrastructure/parsers/java.rs @@ -68,10 +68,22 @@ impl MavenParser { if let (Some(g), Some(a)) = (group_id.as_ref(), artifact_id.as_ref()) { let pkg_name = format!("{}:{}", g, a); // Clean version - let cleaned = self.clean_maven_version( + let cleaned = match self.clean_maven_version( version_str.as_deref().unwrap_or("0.0.0"), properties, - )?; + ) { + Ok(cleaned) => cleaned, + Err(_) => { + tracing::warn!( + package = %pkg_name, + version = %version_str.clone().unwrap_or_default(), + "Skipping Maven dependency with unresolved version property reference" + ); + in_dependency = false; + current_tag = None; + continue; + } + }; let version = Version::parse(&cleaned).map_err(|_| ParseError::Version { version: version_str.clone().unwrap_or_default(), @@ -235,17 +247,36 @@ impl MavenParser { } } Ok(Event::Text(t)) => { + let value = reader + .decoder() + .decode(t.as_ref()) + .unwrap_or_default() + .trim() + .to_string(); + if let Some(property) = current_property.as_ref() { - let value = reader - .decoder() - .decode(t.as_ref()) - .unwrap_or_default() - .trim() - .to_string(); if !value.is_empty() { - properties.insert(property.clone(), value); + properties.insert(property.clone(), value.clone()); } } + + if stack.len() == 2 + && stack[0] == "project" + && stack[1] == "version" + && !value.is_empty() + { + properties.insert("project.version".to_string(), value.clone()); + properties.insert("version".to_string(), value.clone()); + } + + if stack.len() == 3 + && stack[0] == "project" + && stack[1] == "parent" + && stack[2] == "version" + && !value.is_empty() + { + properties.insert("project.parent.version".to_string(), value); + } } Ok(Event::End(_)) => { if stack.len() == 3 && stack[0] == "project" && stack[1] == "properties" { @@ -279,7 +310,7 @@ impl MavenParser { return Ok("0.0.0".to_string()); } - // Handle Maven property placeholders with common defaults + // Handle Maven property references via explicit property resolution if version_str.starts_with("${") && version_str.ends_with('}') { let property = &version_str[2..version_str.len() - 1]; if let Some(value) = properties.get(property) { @@ -288,21 +319,26 @@ impl MavenParser { return Ok(resolved.to_string()); } } - return Ok(match property { - "project.version" | "version" => "1.0.0".to_string(), - "java.version" => "11".to_string(), - "maven.compiler.source" | "maven.compiler.target" => "11".to_string(), - "spring.version" => "5.3.0".to_string(), - "junit.version" => "5.8.0".to_string(), - "slf4j.version" => "1.7.36".to_string(), - "jackson.version" => "2.13.0".to_string(), - _ => { - tracing::debug!( - "Unresolved Maven property: {}, using default 1.0.0", - property - ); - "1.0.0".to_string() - } + + if let Some(value) = properties + .get("project.version") + .or_else(|| properties.get("version")) + .filter(|value| !value.trim().is_empty()) + && matches!(property, "project.version" | "version") + { + return Ok(value.trim().to_string()); + } + + if let Some(value) = properties + .get("project.parent.version") + .filter(|value| !value.trim().is_empty()) + && property == "project.parent.version" + { + return Ok(value.trim().to_string()); + } + + return Err(ParseError::Version { + version: version_str.to_string(), }); } @@ -596,13 +632,9 @@ dependencies { parser.clean_maven_version("5.3.21", &properties).unwrap(), "5.3.21" ); - // Updated expectation: ${spring.version} now returns "5.3.0" as a reasonable default - assert_eq!( - parser - .clean_maven_version("${spring.version}", &properties) - .unwrap(), - "5.3.0" - ); + assert!(parser + .clean_maven_version("${spring.version}", &properties) + .is_err()); assert_eq!( parser.clean_maven_version("[1.0,2.0)", &properties).unwrap(), "1.0" @@ -611,13 +643,9 @@ dependencies { parser.clean_maven_version("(1.0,2.0]", &properties).unwrap(), "1.0" ); - // Test that unknown properties default to "1.0.0" instead of "0.0.0" - assert_eq!( - parser - .clean_maven_version("${unknown.property}", &properties) - .unwrap(), - "1.0.0" - ); + assert!(parser + .clean_maven_version("${unknown.property}", &properties) + .is_err()); let mut resolved = HashMap::new(); resolved.insert("custom.version".to_string(), "9.8.7".to_string()); diff --git a/vulnera-deps/src/services/resolution_algorithms.rs b/vulnera-deps/src/services/resolution_algorithms.rs index 2b4c57af..c79a061c 100644 --- a/vulnera-deps/src/services/resolution_algorithms.rs +++ b/vulnera-deps/src/services/resolution_algorithms.rs @@ -190,8 +190,8 @@ impl BacktrackingResolver { for candidate in candidates { assignments.insert(package_id.clone(), candidate.clone()); - if Self::forward_check(order, index + 1, available_versions, constraints_by_package) { - if Self::backtrack( + if Self::forward_check(order, index + 1, available_versions, constraints_by_package) + && Self::backtrack( order, index + 1, available_versions, @@ -201,7 +201,6 @@ impl BacktrackingResolver { ) { return true; } - } assignments.remove(package_id); } From 642643bf8672ee3265965c18a47db592ae29541c Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:23:09 +0200 Subject: [PATCH 7/9] docs: Document sandbox backend and failure mode config options - Update README and configuration reference to clarify default backend (landlock), available options, and new failure mode (best_effort, fail_closed) - Add explanation of backend selection, fallback behavior, and strict mode for sandbox setup feat: Add typed sandbox backend and failure mode config - Introduce SandboxBackendPreference and SandboxFailureMode enums in config - Replace string backend config with strongly-typed variant - Add failure_mode option to SandboxConfig with default best_effort - Update default backend to landlock for safer defaults feat: Support strict fail-closed sandbox mode in executor - Add strict_mode flag to SandboxExecutor and propagate from config - Pass backend and strict flags to worker process - Worker aborts execution if sandbox setup fails in strict mode - WorkerResult includes backend, applied status, and setup error feat: Add SandboxPolicyProfile for module-specific isolation - Introduce SandboxPolicyProfile enum for read-only and dependency resolution profiles - Add for_profile and with_profile methods to SandboxPolicy for easier policy composition - Update tests for new policy profiles and backend config test: Update sandbox tests for typed backend and failure mode - Adjust module_tests to use SandboxBackendPreference and verify new defaults chore: Update documentation and lib.rs for new sandbox defaults - Clarify default backend and fallback behavior in crate docs - Export SandboxPolicyProfile from lib.rs --- README.md | 3 +- docs/src/reference/configuration.md | 19 +- vulnera-core/src/config/mod.rs | 40 +++- .../src/application/use_cases.rs | 179 +++++++++++++++--- vulnera-sandbox/src/application/executor.rs | 84 +++++++- vulnera-sandbox/src/bin/worker.rs | 103 ++++++++-- vulnera-sandbox/src/domain/limits.rs | 4 +- vulnera-sandbox/src/domain/policy.rs | 52 +++++ vulnera-sandbox/src/lib.rs | 10 +- vulnera-sandbox/tests/module_tests.rs | 10 +- 10 files changed, 438 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 0ac7d17e..89e40f94 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ GOOGLE_AI_KEY='your-api-key' # for Google AI # Sandbox (secure module execution) VULNERA__SANDBOX__ENABLED=true -VULNERA__SANDBOX__BACKEND='auto' # landlock, process, or auto +VULNERA__SANDBOX__BACKEND='landlock' # landlock, auto, process, noop +VULNERA__SANDBOX__FAILURE_MODE='best_effort' # best_effort or fail_closed # Optional VULNERA__CACHE__DRAGONFLY_URL='redis://127.0.0.1:6379' diff --git a/docs/src/reference/configuration.md b/docs/src/reference/configuration.md index 2c655780..63178d3b 100644 --- a/docs/src/reference/configuration.md +++ b/docs/src/reference/configuration.md @@ -91,7 +91,8 @@ The sandbox provides secure isolation for SAST and secrets detection modules. | Variable | Description | Default | |----------|-------------|---------| | `VULNERA__SANDBOX__ENABLED` | Enable sandboxing | `true` | -| `VULNERA__SANDBOX__BACKEND` | Sandbox backend (see below) | `auto` | +| `VULNERA__SANDBOX__BACKEND` | Sandbox backend (see below) | `landlock` | +| `VULNERA__SANDBOX__FAILURE_MODE` | Sandbox setup behavior | `best_effort` | | `VULNERA__SANDBOX__EXECUTION_TIMEOUT_SECS` | Execution timeout | `30` | | `VULNERA__SANDBOX__MEMORY_LIMIT_MB` | Memory limit (process backend) | `256` | @@ -99,12 +100,19 @@ The sandbox provides secure isolation for SAST and secrets detection modules. | Backend | Description | Requirements | |---------|-------------|--------------| -| `auto` | Auto-detect best backend | Recommended | | `landlock` | Kernel-level isolation | Linux 5.13+ | +| `auto` | Auto-detect best backend | Linux/non-Linux | | `process` | Fork-based isolation | Any Linux | -| `none` | Disable sandboxing | Not recommended | +| `noop` | Disable sandboxing | Not recommended | -**Landlock** provides near-zero overhead security using Linux kernel capabilities. Falls back to **process** on older kernels. +**Landlock** provides near-zero overhead security using Linux kernel capabilities. + +Failure modes: + +| Mode | Behavior | +|------|----------| +| `best_effort` | Continue analysis if sandbox setup degrades | +| `fail_closed` | Abort module execution if sandbox setup fails | --- @@ -141,7 +149,8 @@ VULNERA__LLM__RESILIENCE__ENABLED=true # Sandbox VULNERA__SANDBOX__ENABLED=true -VULNERA__SANDBOX__BACKEND='auto' +VULNERA__SANDBOX__BACKEND='landlock' +VULNERA__SANDBOX__FAILURE_MODE='best_effort' # Server VULNERA__SERVER__ENABLE_DOCS=false diff --git a/vulnera-core/src/config/mod.rs b/vulnera-core/src/config/mod.rs index eab0a1eb..98831eb4 100644 --- a/vulnera-core/src/config/mod.rs +++ b/vulnera-core/src/config/mod.rs @@ -1197,6 +1197,37 @@ impl Default for AnalyticsConfig { /// Sandbox configuration for module execution isolation /// /// enabled with Landlock/seccomp on Linux 5.13+, provides kernel-level isolation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SandboxBackendPreference { + #[default] + Landlock, + Auto, + Process, + Noop, + Wasm, +} + +impl SandboxBackendPreference { + pub fn as_str(self) -> &'static str { + match self { + Self::Landlock => "landlock", + Self::Auto => "auto", + Self::Process => "process", + Self::Noop => "noop", + Self::Wasm => "wasm", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SandboxFailureMode { + #[default] + BestEffort, + FailClosed, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct SandboxConfig { @@ -1205,8 +1236,10 @@ pub struct SandboxConfig { /// When enabled on Linux, uses Landlock + seccomp for kernel-level isolation. /// Enable only after thorough testing with your specific workloads. pub enabled: bool, - /// Sandbox backend preference: "noop", "auto", "landlock", "process" - pub backend: String, + /// Sandbox backend preference. + pub backend: SandboxBackendPreference, + /// How to handle sandbox setup failures. + pub failure_mode: SandboxFailureMode, /// Base timeout per module in milliseconds (dynamically adjusted based on source size) pub timeout_ms: u64, /// Base memory limit per module in bytes (dynamically adjusted based on source size) @@ -1227,7 +1260,8 @@ impl Default for SandboxConfig { fn default() -> Self { Self { enabled: true, // Enabled by default - for_analysis() provides safe paths - backend: "auto".to_string(), // Auto-select best backend (Landlock on Linux) + backend: SandboxBackendPreference::Landlock, + failure_mode: SandboxFailureMode::BestEffort, timeout_ms: 120_000, // 2 minutes base timeout max_memory_bytes: 2 * 1024 * 1024 * 1024, // 2GB base allow_network: true, // DependencyAnalyzer needs network diff --git a/vulnera-orchestrator/src/application/use_cases.rs b/vulnera-orchestrator/src/application/use_cases.rs index 7ae49ebd..f33a7c7b 100644 --- a/vulnera-orchestrator/src/application/use_cases.rs +++ b/vulnera-orchestrator/src/application/use_cases.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use tokio::task::JoinSet; use tracing::{debug, error, info, instrument, warn}; use vulnera_core::domain::module::{ - FindingSeverity, ModuleConfig, ModuleExecutionError, ModuleResult, + FindingSeverity, ModuleConfig, ModuleExecutionError, ModuleResult, ModuleType, }; +use walkdir::WalkDir; use crate::domain::entities::{ AggregatedReport, AnalysisJob, CveInfo, DependencyRecommendations, FindingsByType, @@ -15,8 +16,10 @@ use crate::domain::entities::{ use crate::domain::services::{ModuleSelector, ProjectDetectionError, ProjectDetector}; use crate::domain::value_objects::{AnalysisDepth, SourceType}; use crate::infrastructure::ModuleRegistry; -use vulnera_core::config::SandboxConfig; -use vulnera_sandbox::{SandboxExecutor, SandboxPolicy, SandboxSelector}; +use vulnera_core::config::{SandboxConfig, SandboxFailureMode}; +use vulnera_sandbox::{ + SandboxExecutor, SandboxPolicy, SandboxPolicyProfile, SandboxSelector, calculate_limits, +}; /// Use case for creating a new analysis job pub struct CreateAnalysisJobUseCase { @@ -98,18 +101,21 @@ impl ExecuteAnalysisJobUseCase { vulnera_sandbox::infrastructure::noop::NoOpSandbox::new(), ))) } else { - // Select backend based on config (auto, landlock, process, wasm) - let backend = match SandboxSelector::select_by_name(&config.backend) { + // Select backend based on typed config + let backend = match SandboxSelector::select_by_name(config.backend.as_str()) { Some(backend) => backend, None => { warn!( - backend = %config.backend, + backend = config.backend.as_str(), "Requested sandbox backend unavailable, falling back to automatic selection" ); SandboxSelector::select() } }; - Arc::new(SandboxExecutor::new(backend)) + Arc::new(SandboxExecutor::with_options( + backend, + matches!(config.failure_mode, SandboxFailureMode::FailClosed), + )) }; Self { @@ -146,6 +152,14 @@ impl ExecuteAnalysisJobUseCase { "Starting parallel execution of analysis modules" ); + let source_size_bytes = estimate_source_size_bytes(&effective_source_uri); + debug!( + job_id = %job.job_id, + source_uri = %effective_source_uri, + source_size_bytes = source_size_bytes.unwrap_or(0), + "Estimated source size for dynamic sandbox limits" + ); + // Create JoinSet for parallel execution // We always return Ok from spawned tasks (errors are converted to ModuleResult with error field) let mut join_set: JoinSet<(vulnera_core::domain::module::ModuleType, ModuleResult)> = @@ -178,9 +192,8 @@ impl ExecuteAnalysisJobUseCase { let module_arc = Arc::clone(&module); let executor = self.executor.clone(); - let sandbox_timeout = std::time::Duration::from_millis(self.config.timeout_ms); - let sandbox_mem_bytes = self.config.max_memory_bytes; let sandbox_config = self.config.clone(); + let source_size_bytes = source_size_bytes; debug!( job_id = %job.job_id, @@ -191,27 +204,22 @@ impl ExecuteAnalysisJobUseCase { join_set.spawn(async move { let module_start = std::time::Instant::now(); + let limits = calculate_limits( + &sandbox_config, + source_size_bytes, + module_type_clone.clone(), + ); + + let profile = module_policy_profile(&module_type_clone, &sandbox_config); + let profile_name = sandbox_profile_name(profile).to_string(); + let backend_name = executor.backend_name().to_string(); + let strict_mode = executor.strict_mode(); + // Build sandbox policy with essential system paths for analysis // for_analysis() includes /usr, /lib, /proc, /etc/ssl, /tmp etc. - let effective_mem_limit = - std::cmp::max(sandbox_mem_bytes, 2 * 1024 * 1024 * 1024); - - let mut policy = SandboxPolicy::for_analysis(&config.source_uri) - .with_timeout(sandbox_timeout) - .with_memory_limit(effective_mem_limit); - - // Configure network access if enabled - if sandbox_config.allow_network { - // Add common HTTPS ports - policy = policy.with_http_access(); - - // DependencyAnalyzer needs to connect to Dragonfly (Redis) - if module_type_clone - == vulnera_core::domain::module::ModuleType::DependencyAnalyzer - { - policy = policy.with_port(6379); - } - } + let policy = SandboxPolicy::for_profile(&config.source_uri, profile) + .with_timeout(limits.timeout) + .with_memory_limit(limits.max_memory); // Execute within sandbox let result = executor @@ -256,16 +264,67 @@ impl ExecuteAnalysisJobUseCase { // Convert error to ModuleResult with error field set // Always return a ModuleResult (either success or error) match result { - Ok(r) => (module_type_clone, r), + Ok(mut r) => { + r.metadata.additional_info.insert( + "sandbox.profile".to_string(), + profile_name.clone(), + ); + r.metadata.additional_info.insert( + "sandbox.timeout_ms".to_string(), + limits.timeout.as_millis().to_string(), + ); + r.metadata.additional_info.insert( + "sandbox.max_memory_bytes".to_string(), + limits.max_memory.to_string(), + ); + r.metadata.additional_info.insert( + "sandbox.backend_effective".to_string(), + backend_name.clone(), + ); + r.metadata.additional_info.insert( + "sandbox.strict_mode".to_string(), + strict_mode.to_string(), + ); + r.metadata.additional_info.insert( + "sandbox.source_size_bytes".to_string(), + source_size_bytes.unwrap_or(0).to_string(), + ); + + (module_type_clone, r) + } Err(e) => { // Create error result - let error_result = ModuleResult { + let mut error_result = ModuleResult { job_id: config.job_id, module_type: module_type_clone.clone(), findings: vec![], metadata: Default::default(), error: Some(e.to_string()), }; + error_result.metadata.additional_info.insert( + "sandbox.profile".to_string(), + profile_name, + ); + error_result.metadata.additional_info.insert( + "sandbox.timeout_ms".to_string(), + limits.timeout.as_millis().to_string(), + ); + error_result.metadata.additional_info.insert( + "sandbox.max_memory_bytes".to_string(), + limits.max_memory.to_string(), + ); + error_result.metadata.additional_info.insert( + "sandbox.backend_effective".to_string(), + backend_name, + ); + error_result.metadata.additional_info.insert( + "sandbox.strict_mode".to_string(), + strict_mode.to_string(), + ); + error_result.metadata.additional_info.insert( + "sandbox.source_size_bytes".to_string(), + source_size_bytes.unwrap_or(0).to_string(), + ); (module_type_clone, error_result) } } @@ -358,6 +417,66 @@ impl ExecuteAnalysisJobUseCase { } } +fn module_policy_profile(module_type: &ModuleType, sandbox_config: &SandboxConfig) -> SandboxPolicyProfile { + match module_type { + ModuleType::DependencyAnalyzer if sandbox_config.allow_network => { + SandboxPolicyProfile::DependencyResolution { + include_cache_port: true, + } + } + _ => SandboxPolicyProfile::ReadOnlyAnalysis, + } +} + +fn sandbox_profile_name(profile: SandboxPolicyProfile) -> &'static str { + match profile { + SandboxPolicyProfile::ReadOnlyAnalysis => "read_only_analysis", + SandboxPolicyProfile::DependencyResolution { .. } => "dependency_resolution", + } +} + +fn estimate_source_size_bytes(source_uri: &str) -> Option { + let path = std::path::Path::new(source_uri); + if !path.exists() { + return None; + } + + if path.is_file() { + return std::fs::metadata(path).ok().map(|m| m.len()); + } + + let ignored_dirs = [ + ".git", + "target", + "node_modules", + "vendor", + ".venv", + "venv", + "dist", + "build", + "__pycache__", + ]; + + let mut total: u64 = 0; + for entry in WalkDir::new(path) + .follow_links(false) + .into_iter() + .filter_entry(|entry| { + let name = entry.file_name().to_string_lossy(); + !ignored_dirs.contains(&name.as_ref()) + }) + .filter_map(Result::ok) + { + if entry.file_type().is_file() { + if let Ok(metadata) = entry.metadata() { + total = total.saturating_add(metadata.len()); + } + } + } + + Some(total) +} + /// Use case for aggregating results from multiple modules pub struct AggregateResultsUseCase; diff --git a/vulnera-sandbox/src/application/executor.rs b/vulnera-sandbox/src/application/executor.rs index 02856b12..aa6e791e 100644 --- a/vulnera-sandbox/src/application/executor.rs +++ b/vulnera-sandbox/src/application/executor.rs @@ -46,14 +46,21 @@ pub struct SandboxExecutor { backend: Arc, /// Path to the worker binary (auto-discovered or configured) worker_path: Option, + strict_mode: bool, } impl SandboxExecutor { /// Create a new executor with the specified backend pub fn new(backend: Arc) -> Self { + Self::with_options(backend, false) + } + + /// Create a new executor with explicit strict-mode option + pub fn with_options(backend: Arc, strict_mode: bool) -> Self { Self { backend, worker_path: Self::discover_worker_path(), + strict_mode, } } @@ -95,6 +102,11 @@ impl SandboxExecutor { self.backend.name() } + /// Check if strict fail-closed mode is enabled. + pub fn strict_mode(&self) -> bool { + self.strict_mode + } + /// Check if the backend is available pub fn is_available(&self) -> bool { self.backend.is_available() @@ -176,6 +188,8 @@ impl SandboxExecutor { let mut command = Command::new(worker_path); command + .arg("--sandbox-backend") + .arg(self.backend.name()) .arg("--module") .arg(&module_type) .arg("--source-uri") @@ -193,6 +207,10 @@ impl SandboxExecutor { command.arg("--no-sandbox"); } + if self.strict_mode { + command.arg("--sandbox-strict"); + } + let child = command .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -253,7 +271,7 @@ impl SandboxExecutor { })?; // Parse the inner result as ModuleResult - let result: ModuleResult = + let mut result: ModuleResult = serde_json::from_value(module_result).unwrap_or_else(|_| ModuleResult { job_id: config.job_id, module_type: module.module_type(), @@ -262,6 +280,41 @@ impl SandboxExecutor { error: None, }); + result.metadata.additional_info.insert( + "sandbox.backend_requested".to_string(), + self.backend.name().to_string(), + ); + result.metadata.additional_info.insert( + "sandbox.strict_mode".to_string(), + self.strict_mode.to_string(), + ); + + if let Some(ref backend) = worker_result.sandbox_backend { + result.metadata.additional_info.insert( + "sandbox.worker_backend".to_string(), + backend.clone(), + ); + } + + if let Some(applied) = worker_result.sandbox_applied { + result.metadata.additional_info.insert( + "sandbox.applied".to_string(), + applied.to_string(), + ); + } + + if let Some(ref setup_error) = worker_result.sandbox_setup_error { + result.metadata.additional_info.insert( + "sandbox.setup_error".to_string(), + setup_error.clone(), + ); + } + + result.metadata.additional_info.insert( + "sandbox.worker_version".to_string(), + worker_result.worker_version.clone(), + ); + Ok(result) } @@ -276,7 +329,25 @@ impl SandboxExecutor { // as that would affect the orchestrator. This is a compatibility fallback. match tokio::time::timeout(policy.timeout, module.execute(config)).await { - Ok(Ok(result)) => Ok(result), + Ok(Ok(mut result)) => { + result.metadata.additional_info.insert( + "sandbox.backend_requested".to_string(), + self.backend.name().to_string(), + ); + result.metadata.additional_info.insert( + "sandbox.strict_mode".to_string(), + self.strict_mode.to_string(), + ); + result.metadata.additional_info.insert( + "sandbox.execution_mode".to_string(), + "in_process_fallback".to_string(), + ); + result.metadata.additional_info.insert( + "sandbox.applied".to_string(), + "false".to_string(), + ); + Ok(result) + } Ok(Err(e)) => Err(SandboxedExecutionError::ModuleFailed(e)), Err(_) => Err(SandboxedExecutionError::Timeout(policy.timeout)), } @@ -295,8 +366,16 @@ struct WorkerResult { success: bool, result: Option, error: Option, + #[serde(default)] + sandbox_backend: Option, + #[serde(default)] + sandbox_applied: Option, + #[serde(default)] + sandbox_setup_error: Option, #[allow(dead_code)] execution_time_ms: u64, + #[serde(default)] + worker_version: String, } /// Error type for sandboxed execution @@ -331,6 +410,7 @@ mod tests { fn test_default_executor() { let executor = SandboxExecutor::default(); assert!(executor.is_available()); + assert!(!executor.strict_mode); } #[test] diff --git a/vulnera-sandbox/src/bin/worker.rs b/vulnera-sandbox/src/bin/worker.rs index bd5aa746..063a588d 100644 --- a/vulnera-sandbox/src/bin/worker.rs +++ b/vulnera-sandbox/src/bin/worker.rs @@ -36,6 +36,14 @@ use vulnera_secrets::module::SecretDetectionModule; #[command(name = "vulnera-worker")] #[command(about = "Sandboxed worker for Vulnera analysis modules")] struct Args { + /// Sandbox backend selected by orchestrator (landlock, process, wasm, noop, auto) + #[arg(long, default_value = "auto")] + sandbox_backend: String, + + /// Fail closed if sandbox setup cannot be fully applied + #[arg(long)] + sandbox_strict: bool, + /// Module type to execute (e.g., "SAST", "SecretDetection", "ApiSecurity") #[arg(long)] module: String, @@ -117,6 +125,15 @@ struct WorkerResult { execution_time_ms: u64, /// Worker version worker_version: String, + /// Backend used by worker + #[serde(skip_serializing_if = "Option::is_none")] + sandbox_backend: Option, + /// Whether sandbox restrictions were successfully applied + #[serde(skip_serializing_if = "Option::is_none")] + sandbox_applied: Option, + /// Sandbox setup error message for degraded (best-effort) runs + #[serde(skip_serializing_if = "Option::is_none")] + sandbox_setup_error: Option, } #[tokio::main] @@ -155,16 +172,42 @@ async fn main() { ); // Apply sandbox restrictions BEFORE executing any module code + let sandbox_applied: bool; + let mut sandbox_setup_error: Option = None; + #[cfg(target_os = "linux")] if !args.no_sandbox { - if let Err(e) = apply_sandbox_restrictions(&policy) { - // Log but don't fail - sandbox is optional enhancement - warn!("Sandbox restrictions not fully applied: {}", e); + if let Err(e) = apply_sandbox_restrictions(&policy, &args.sandbox_backend) { + if args.sandbox_strict { + output_error_with_code( + &format!( + "Sandbox setup failed in strict mode (backend={}): {}", + args.sandbox_backend, e + ), + WorkerErrorCode::SandboxSetupFailed, + ); + std::process::exit(1); + } + + sandbox_applied = false; + sandbox_setup_error = Some(e.clone()); + warn!( + "Sandbox restrictions not fully applied (backend={}): {}", + args.sandbox_backend, e + ); + } else { + sandbox_applied = true; } } else { + sandbox_applied = false; warn!("Sandbox disabled via --no-sandbox flag - running without restrictions"); } + #[cfg(not(target_os = "linux"))] + { + sandbox_applied = false; + } + info!("Sandbox restrictions applied, executing module"); // Execute the module @@ -182,6 +225,9 @@ async fn main() { error_code: None, execution_time_ms, worker_version: WORKER_VERSION.to_string(), + sandbox_backend: Some(args.sandbox_backend.clone()), + sandbox_applied: Some(sandbox_applied), + sandbox_setup_error, }, Err(e) => WorkerResult { success: false, @@ -190,6 +236,9 @@ async fn main() { error_code: Some(WorkerErrorCode::SerializationFailed), execution_time_ms, worker_version: WORKER_VERSION.to_string(), + sandbox_backend: Some(args.sandbox_backend.clone()), + sandbox_applied: Some(sandbox_applied), + sandbox_setup_error, }, }, Err(e) => WorkerResult { @@ -199,6 +248,9 @@ async fn main() { error_code: Some(WorkerErrorCode::ModuleExecutionFailed), execution_time_ms, worker_version: WORKER_VERSION.to_string(), + sandbox_backend: Some(args.sandbox_backend.clone()), + sandbox_applied: Some(sandbox_applied), + sandbox_setup_error, }, }; @@ -225,27 +277,41 @@ fn parse_policy(args: &Args) -> Result { /// Apply Landlock + seccomp + rlimit restrictions #[cfg(target_os = "linux")] -fn apply_sandbox_restrictions(policy: &SandboxPolicy) -> Result<(), String> { +fn apply_sandbox_restrictions(policy: &SandboxPolicy, backend_name: &str) -> Result<(), String> { use vulnera_sandbox::infrastructure::{ landlock::apply_landlock_restrictions, seccomp::{apply_seccomp_filter, create_analysis_config}, }; - // Layer 1: Landlock (filesystem + network restrictions) - apply_landlock_restrictions(policy).map_err(|e| format!("Landlock: {}", e))?; + match backend_name.to_lowercase().as_str() { + "landlock" | "auto" => { + // Layer 1: Landlock (filesystem + network restrictions) + apply_landlock_restrictions(policy).map_err(|e| format!("Landlock: {}", e))?; + debug!("Landlock restrictions applied"); - debug!("Landlock restrictions applied"); + // Layer 2: Seccomp (syscall filtering) + let seccomp_config = create_analysis_config(policy); + apply_seccomp_filter(&seccomp_config).map_err(|e| format!("Seccomp: {}", e))?; + debug!("Seccomp filter applied"); - // Layer 2: Seccomp (syscall filtering) - let seccomp_config = create_analysis_config(policy); - apply_seccomp_filter(&seccomp_config).map_err(|e| format!("Seccomp: {}", e))?; - - debug!("Seccomp filter applied"); - - // Layer 3: Resource limits - apply_rlimits(policy)?; - - debug!("Resource limits applied"); + // Layer 3: Resource limits + apply_rlimits(policy)?; + debug!("Resource limits applied"); + } + "process" => { + apply_rlimits(policy)?; + debug!("Process backend selected; applied rlimits only"); + } + "noop" => { + debug!("Noop backend selected; no sandbox restrictions applied"); + } + "wasm" => { + debug!("WASM backend selected on Linux worker; no native restrictions applied"); + } + other => { + return Err(format!("Unsupported sandbox backend: {}", other)); + } + } Ok(()) } @@ -414,6 +480,9 @@ fn output_error_with_code(message: &str, error_code: WorkerErrorCode) { error_code: Some(error_code), execution_time_ms: 0, worker_version: WORKER_VERSION.to_string(), + sandbox_backend: None, + sandbox_applied: None, + sandbox_setup_error: None, }; output_result(&result); } diff --git a/vulnera-sandbox/src/domain/limits.rs b/vulnera-sandbox/src/domain/limits.rs index 8b58d8b3..8e2dcf6e 100644 --- a/vulnera-sandbox/src/domain/limits.rs +++ b/vulnera-sandbox/src/domain/limits.rs @@ -90,11 +90,13 @@ pub fn calculate_limits( #[cfg(test)] mod tests { use super::*; + use vulnera_core::config::{SandboxBackendPreference, SandboxFailureMode}; fn test_config() -> SandboxConfig { SandboxConfig { enabled: false, - backend: "noop".to_string(), + backend: SandboxBackendPreference::Noop, + failure_mode: SandboxFailureMode::BestEffort, timeout_ms: 60_000, max_memory_bytes: 1024 * 1024 * 1024, allow_network: true, diff --git a/vulnera-sandbox/src/domain/policy.rs b/vulnera-sandbox/src/domain/policy.rs index b47049f7..a05cb987 100644 --- a/vulnera-sandbox/src/domain/policy.rs +++ b/vulnera-sandbox/src/domain/policy.rs @@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::Duration; +/// Predefined policy profiles for analysis modules. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SandboxPolicyProfile { + /// No network access; filesystem-only analysis. + ReadOnlyAnalysis, + /// Dependency resolution profile with outbound HTTP(S) and optional Redis/Dragonfly. + DependencyResolution { include_cache_port: bool }, +} + /// Sandbox policy defining allowed operations for module execution /// /// # Security Model @@ -184,6 +193,27 @@ impl SandboxPolicy { policy } + /// Create an analysis policy with a predefined profile. + pub fn for_profile(source_path: impl Into, profile: SandboxPolicyProfile) -> Self { + Self::for_analysis(source_path).with_profile(profile) + } + + /// Apply a predefined profile to this policy. + pub fn with_profile(mut self, profile: SandboxPolicyProfile) -> Self { + match profile { + SandboxPolicyProfile::ReadOnlyAnalysis => { + self.allowed_ports.clear(); + } + SandboxPolicyProfile::DependencyResolution { include_cache_port } => { + self = self.with_http_access(); + if include_cache_port { + self = self.with_port(6379); + } + } + } + self + } + /// Add a read-only path (chainable) pub fn with_readonly_path(mut self, path: impl Into) -> Self { self.readonly_paths.push(path.into()); @@ -284,4 +314,26 @@ mod tests { assert_eq!(policy.readonly_paths.len(), 2); assert_eq!(policy.timeout, Duration::from_secs(120)); } + + #[test] + fn test_read_only_profile_blocks_network() { + let policy = SandboxPolicy::default() + .with_http_access() + .with_profile(SandboxPolicyProfile::ReadOnlyAnalysis); + + assert!(policy.allowed_ports.is_empty()); + } + + #[test] + fn test_dependency_profile_adds_required_ports() { + let policy = SandboxPolicy::default().with_profile( + SandboxPolicyProfile::DependencyResolution { + include_cache_port: true, + }, + ); + + assert!(policy.allowed_ports.contains(&80)); + assert!(policy.allowed_ports.contains(&443)); + assert!(policy.allowed_ports.contains(&6379)); + } } diff --git a/vulnera-sandbox/src/lib.rs b/vulnera-sandbox/src/lib.rs index ca22ff6a..8eab0331 100644 --- a/vulnera-sandbox/src/lib.rs +++ b/vulnera-sandbox/src/lib.rs @@ -5,8 +5,8 @@ //! //! # Default Behavior //! -//! **By default, sandboxing is DISABLED (noop mode)** for maximum compatibility. -//! Enable it explicitly in production after testing with your specific workloads. +//! **By default, sandboxing is enabled with Landlock on Linux**. +//! If unavailable, runtime behavior depends on configured fallback mode. //! //! # Architecture //! @@ -16,7 +16,7 @@ //! |----------|-----------------|----------| //! | Linux 5.13+ | Landlock + seccomp | Process isolation | //! | Older Linux | Process isolation | - | -//! | All platforms | NoOp (default) | - | +//! | Non-Linux | WASM | NoOp | //! //! # Performance //! @@ -34,7 +34,7 @@ //! .with_readonly_path("/path/to/scan") //! .with_timeout_secs(30); //! -//! // Auto-select best backend (noop by default) +//! // Auto-select best backend (Landlock on Linux) //! let executor = SandboxExecutor::auto(); //! //! // Execute module in sandbox @@ -48,7 +48,7 @@ pub mod infrastructure; pub use application::executor::{SandboxExecutor, SandboxedExecutionError}; pub use application::selector::SandboxSelector; pub use domain::limits::{ResourceLimits, calculate_limits}; -pub use domain::policy::{SandboxPolicy, SandboxPolicyBuilder}; +pub use domain::policy::{SandboxPolicy, SandboxPolicyBuilder, SandboxPolicyProfile}; pub use domain::traits::{SandboxBackend, SandboxError, SandboxResult, SandboxStats}; // Re-export platform-specific backends diff --git a/vulnera-sandbox/tests/module_tests.rs b/vulnera-sandbox/tests/module_tests.rs index 30093116..5e16f805 100644 --- a/vulnera-sandbox/tests/module_tests.rs +++ b/vulnera-sandbox/tests/module_tests.rs @@ -5,7 +5,9 @@ use std::collections::HashMap; use vulnera_api::module::ApiSecurityModule; -use vulnera_core::config::{ApiSecurityConfig, SandboxConfig, SastConfig, SecretDetectionConfig}; +use vulnera_core::config::{ + ApiSecurityConfig, SandboxBackendPreference, SandboxConfig, SastConfig, SecretDetectionConfig, +}; use vulnera_core::domain::module::{AnalysisModule, ModuleConfig, ModuleType}; use vulnera_sandbox::{SandboxExecutor, SandboxSelector, calculate_limits}; use vulnera_sast::module::SastModule; @@ -95,7 +97,11 @@ fn test_sandbox_enabled_by_default() { let config = SandboxConfig::default(); assert!(config.enabled, "Sandbox should be enabled by default"); - assert_eq!(config.backend, "auto", "Backend should default to auto"); + assert_eq!( + config.backend, + SandboxBackendPreference::Landlock, + "Backend should default to landlock" + ); assert!( config.allow_network, "Network should be allowed for DependencyAnalyzer" From 3fd31f9da9811f996f163b9a0a4f4c5306f89763 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:25:02 +0200 Subject: [PATCH 8/9] style: Normalize code formatting and indentation across modules - Standardize indentation in function calls, match arms, and assertions - Merge redundant derive attributes in structs - Reorder imports for consistency in analysis_context.rs - No functional changes; improves readability and diff clarity --- .gitignore | 1 + .../analyzers/data_exposure_analyzer.rs | 4 +- .../analyzers/input_validation_analyzer.rs | 8 +- .../infrastructure/parser/openapi_parser.rs | 84 +++++++++++++------ .../unit/analyzers/test_enhanced_analyzers.rs | 8 +- vulnera-core/src/config/mod.rs | 6 +- .../src/domain/vulnerability/value_objects.rs | 15 +++- .../infrastructure/cache/dragonfly_cache.rs | 4 +- .../src/infrastructure/parsers/java.rs | 36 +++++--- .../src/infrastructure/parsers/npm.rs | 10 +-- .../src/infrastructure/parsers/python.rs | 17 ++-- .../src/infrastructure/parsers/yarn_pest.rs | 56 +++++++------ .../vulnerability_advisor/mod.rs | 26 +++--- .../src/application/analysis_context.rs | 2 +- vulnera-deps/src/domain/version_constraint.rs | 5 +- vulnera-deps/src/module.rs | 7 +- .../src/services/dependency_resolver.rs | 48 ++++++----- .../src/services/repository_analysis.rs | 6 +- .../src/services/resolution_algorithms.rs | 21 +++-- vulnera-deps/src/use_cases.rs | 6 +- vulnera-llm/src/domain/messages.rs | 4 +- .../src/application/use_cases.rs | 45 +++++----- .../presentation/controllers/repository.rs | 5 +- vulnera-sandbox/src/application/executor.rs | 32 +++---- vulnera-sandbox/src/domain/policy.rs | 7 +- 25 files changed, 262 insertions(+), 201 deletions(-) diff --git a/.gitignore b/.gitignore index 3ed7923b..d4e2a2a7 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ vulnera-sast/tests/snapshots/* curls.txt # Decoupled components /vulnera-cli/ +docs/modules.md diff --git a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs index 1c2c6ad1..f617680e 100644 --- a/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/data_exposure_analyzer.rs @@ -226,8 +226,8 @@ impl DataExposureAnalyzer { method, &format!("{}.{}", context, prop.name), findings, - jwt_pattern, - private_key_pattern, + jwt_pattern, + private_key_pattern, ); } } diff --git a/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs b/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs index fa46f4d8..2c59b335 100644 --- a/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs +++ b/vulnera-api/src/infrastructure/analyzers/input_validation_analyzer.rs @@ -181,9 +181,9 @@ impl InputValidationAnalyzer { // Check for mass assignment (objects allowing additional properties) if schema.schema_type.as_deref() == Some("object") && schema.additional_properties == AdditionalProperties::Allowed - && (method == "POST" || method == "PUT" || method == "PATCH") - { - findings.push(ApiFinding { + && (method == "POST" || method == "PUT" || method == "PATCH") + { + findings.push(ApiFinding { id: format!("mass-assignment-{}-{}-{}", path, method, context), vulnerability_type: ApiVulnerabilityType::MassAssignmentRisk, location: ApiLocation { @@ -201,7 +201,7 @@ impl InputValidationAnalyzer { path: Some(path.to_string()), method: Some(method.to_string()), }); - } + } // Recurse into properties for prop in &schema.properties { diff --git a/vulnera-api/src/infrastructure/parser/openapi_parser.rs b/vulnera-api/src/infrastructure/parser/openapi_parser.rs index 9578a4b3..f8bf390f 100644 --- a/vulnera-api/src/infrastructure/parser/openapi_parser.rs +++ b/vulnera-api/src/infrastructure/parser/openapi_parser.rs @@ -161,12 +161,11 @@ impl OpenApiParser { &mut schema_resolver, &component_refs, ); - let security_schemes = - Self::parse_security_schemes_with_oauth_urls( - &spec.components, - &oauth_token_urls, - &component_refs, - ); + let security_schemes = Self::parse_security_schemes_with_oauth_urls( + &spec.components, + &oauth_token_urls, + &component_refs, + ); Ok(OpenApiSpec { version: spec.openapi, @@ -330,9 +329,13 @@ impl OpenApiParser { // Security requirements are now passed in from the raw JSON/YAML parsing // Operation-level security overrides path-level security (handled in parse_paths_with_security) - let parameters = Self::parse_parameters(&operation.parameters, schema_resolver, component_refs); - let request_body = - Self::parse_request_body(operation.request_body.as_ref(), schema_resolver, component_refs); + let parameters = + Self::parse_parameters(&operation.parameters, schema_resolver, component_refs); + let request_body = Self::parse_request_body( + operation.request_body.as_ref(), + schema_resolver, + component_refs, + ); let responses = Self::parse_responses( operation .responses @@ -379,8 +382,12 @@ impl OpenApiParser { }); } oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { - if let Some(param_json) = Self::resolve_component_ref(ref_path, "parameters", component_refs) { - if let Some(param) = Self::parse_parameter_from_json(param_json, schema_resolver) { + if let Some(param_json) = + Self::resolve_component_ref(ref_path, "parameters", component_refs) + { + if let Some(param) = + Self::parse_parameter_from_json(param_json, schema_resolver) + { api_params.push(param); } } else { @@ -407,7 +414,9 @@ impl OpenApiParser { }) } Some(oas3::spec::ObjectOrReference::Ref { ref_path, .. }) => { - if let Some(rb_json) = Self::resolve_component_ref(ref_path, "requestBodies", component_refs) { + if let Some(rb_json) = + Self::resolve_component_ref(ref_path, "requestBodies", component_refs) + { Self::parse_request_body_from_json(rb_json, schema_resolver) } else { warn!(ref_path = %ref_path, "Failed to resolve request body reference"); @@ -433,7 +442,11 @@ impl OpenApiParser { oas3::spec::ObjectOrReference::Object(response) => { let content = Self::parse_content(&Some(response.content.clone()), schema_resolver); - let headers = Self::parse_response_headers(&response.headers, schema_resolver, component_refs); + let headers = Self::parse_response_headers( + &response.headers, + schema_resolver, + component_refs, + ); api_responses.push(ApiResponse { status_code: status_code.clone(), @@ -442,7 +455,9 @@ impl OpenApiParser { }); } oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { - if let Some(response_json) = Self::resolve_component_ref(ref_path, "responses", component_refs) { + if let Some(response_json) = + Self::resolve_component_ref(ref_path, "responses", component_refs) + { if let Some(response) = Self::parse_response_from_json( status_code, response_json, @@ -513,8 +528,12 @@ impl OpenApiParser { }); } oas3::spec::ObjectOrReference::Ref { ref_path, .. } => { - if let Some(header_json) = Self::resolve_component_ref(ref_path, "headers", component_refs) { - if let Some(header) = Self::parse_header_from_json(name, header_json, schema_resolver) { + if let Some(header_json) = + Self::resolve_component_ref(ref_path, "headers", component_refs) + { + if let Some(header) = + Self::parse_header_from_json(name, header_json, schema_resolver) + { api_headers.push(header); } } else { @@ -592,7 +611,11 @@ impl OpenApiParser { obj_schema .enum_values .iter() - .map(|v| v.as_str().map(|s| s.to_string()).unwrap_or_else(|| v.to_string())) + .map(|v| { + v.as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| v.to_string()) + }) .collect(), ) }; @@ -721,9 +744,9 @@ impl OpenApiParser { if let Some(scheme_json) = Self::resolve_component_ref(ref_path, "securitySchemes", component_refs) { - if let Ok(scheme) = - serde_json::from_value::(scheme_json.clone()) - { + if let Ok(scheme) = serde_json::from_value::( + scheme_json.clone(), + ) { let scheme_type = match &scheme { oas3::spec::SecurityScheme::ApiKey { location, @@ -747,8 +770,10 @@ impl OpenApiParser { } => { let scheme_token_urls = oauth_token_urls.get(name).cloned().unwrap_or_default(); - let oauth_flows = - Self::parse_oauth_flows_with_urls(flows, &scheme_token_urls); + let oauth_flows = Self::parse_oauth_flows_with_urls( + flows, + &scheme_token_urls, + ); SecuritySchemeType::OAuth2 { flows: oauth_flows } } oas3::spec::SecurityScheme::OpenIdConnect { @@ -758,7 +783,9 @@ impl OpenApiParser { url: open_id_connect_url.clone(), }, oas3::spec::SecurityScheme::MutualTls { description: _ } => { - warn!("MutualTLS security scheme is not supported, skipping"); + warn!( + "MutualTLS security scheme is not supported, skipping" + ); continue; } }; @@ -1070,8 +1097,9 @@ impl OpenApiParser { .collect(); } - if let Some(security_schemes) = - components.get("securitySchemes").and_then(|v| v.as_object()) + if let Some(security_schemes) = components + .get("securitySchemes") + .and_then(|v| v.as_object()) { refs.security_schemes = security_schemes .iter() @@ -1165,7 +1193,11 @@ impl OpenApiParser { if let Some(resolved) = Self::resolve_component_ref(ref_path, "headers", component_refs) { - return Self::parse_header_from_json(name, resolved, schema_resolver); + return Self::parse_header_from_json( + name, + resolved, + schema_resolver, + ); } return None; } diff --git a/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs b/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs index cb7a7f58..a440c59e 100644 --- a/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs +++ b/vulnera-api/tests/unit/analyzers/test_enhanced_analyzers.rs @@ -314,12 +314,8 @@ fn test_detects_missing_pagination_for_wrapped_array_response() { }; let findings = ResourceRestrictionAnalyzer::analyze(&spec); - assert!( - findings - .iter() - .any(|f| f.id.contains("missing-pagination") - && f.vulnerability_type == ApiVulnerabilityType::ResourceExhaustion) - ); + assert!(findings.iter().any(|f| f.id.contains("missing-pagination") + && f.vulnerability_type == ApiVulnerabilityType::ResourceExhaustion)); } // --- Security Misconfig Tests --- diff --git a/vulnera-core/src/config/mod.rs b/vulnera-core/src/config/mod.rs index 98831eb4..25d54d83 100644 --- a/vulnera-core/src/config/mod.rs +++ b/vulnera-core/src/config/mod.rs @@ -1259,12 +1259,12 @@ pub struct SandboxConfig { impl Default for SandboxConfig { fn default() -> Self { Self { - enabled: true, // Enabled by default - for_analysis() provides safe paths + enabled: true, // Enabled by default - for_analysis() provides safe paths backend: SandboxBackendPreference::Landlock, failure_mode: SandboxFailureMode::BestEffort, - timeout_ms: 120_000, // 2 minutes base timeout + timeout_ms: 120_000, // 2 minutes base timeout max_memory_bytes: 2 * 1024 * 1024 * 1024, // 2GB base - allow_network: true, // DependencyAnalyzer needs network + allow_network: true, // DependencyAnalyzer needs network dynamic_limits: true, timeout_per_mb_ms: 200, // +200ms per MB of source memory_per_mb_ratio: 10.0, // 10x source size for memory overhead diff --git a/vulnera-core/src/domain/vulnerability/value_objects.rs b/vulnera-core/src/domain/vulnerability/value_objects.rs index c50eb938..93540b15 100644 --- a/vulnera-core/src/domain/vulnerability/value_objects.rs +++ b/vulnera-core/src/domain/vulnerability/value_objects.rs @@ -983,7 +983,10 @@ mod tests { assert_eq!(Ecosystem::from_str("go").unwrap(), Ecosystem::Go); assert_eq!(Ecosystem::from_str("golang").unwrap(), Ecosystem::Go); assert_eq!(Ecosystem::from_str("crates.io").unwrap(), Ecosystem::Cargo); - assert_eq!(Ecosystem::from_str("composer").unwrap(), Ecosystem::Packagist); + assert_eq!( + Ecosystem::from_str("composer").unwrap(), + Ecosystem::Packagist + ); assert_eq!(Ecosystem::from_str("bundler").unwrap(), Ecosystem::RubyGems); assert_eq!(Ecosystem::from_str("pip").unwrap(), Ecosystem::PyPI); @@ -1004,8 +1007,14 @@ mod tests { assert_eq!(Ecosystem::Cargo.advisor_name(), "crates.io"); assert_eq!(Ecosystem::Packagist.registry_name(), "composer"); - assert_eq!(Ecosystem::PyPI.normalize_package_name(" Requests "), "requests"); - assert_eq!(Ecosystem::Go.normalize_package_name("golang.org/x/mod"), "golang.org/x/mod"); + assert_eq!( + Ecosystem::PyPI.normalize_package_name(" Requests "), + "requests" + ); + assert_eq!( + Ecosystem::Go.normalize_package_name("golang.org/x/mod"), + "golang.org/x/mod" + ); } #[test] diff --git a/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs b/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs index 9bd71562..e9b8267e 100644 --- a/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs +++ b/vulnera-core/src/infrastructure/cache/dragonfly_cache.rs @@ -351,7 +351,9 @@ impl DragonflyCache { } /// Return parsed Redis INFO STATS metrics as key/value pairs. - pub async fn info_stats(&self) -> Result, ApplicationError> { + pub async fn info_stats( + &self, + ) -> Result, ApplicationError> { let mut conn = (*self.connection_manager).clone(); let raw: String = redis::cmd("INFO") .arg("STATS") diff --git a/vulnera-core/src/infrastructure/parsers/java.rs b/vulnera-core/src/infrastructure/parsers/java.rs index e07ce1e5..97a3ff1c 100644 --- a/vulnera-core/src/infrastructure/parsers/java.rs +++ b/vulnera-core/src/infrastructure/parsers/java.rs @@ -617,10 +617,9 @@ dependencies { "#; let result = parser.parse_file(content).await.unwrap(); - assert!(result - .packages - .iter() - .any(|p| p.name == "org.springframework:spring-core" && p.version.to_string() == "6.1.5")); + assert!(result.packages.iter().any( + |p| p.name == "org.springframework:spring-core" && p.version.to_string() == "6.1.5" + )); } #[test] @@ -632,20 +631,28 @@ dependencies { parser.clean_maven_version("5.3.21", &properties).unwrap(), "5.3.21" ); - assert!(parser - .clean_maven_version("${spring.version}", &properties) - .is_err()); + assert!( + parser + .clean_maven_version("${spring.version}", &properties) + .is_err() + ); assert_eq!( - parser.clean_maven_version("[1.0,2.0)", &properties).unwrap(), + parser + .clean_maven_version("[1.0,2.0)", &properties) + .unwrap(), "1.0" ); assert_eq!( - parser.clean_maven_version("(1.0,2.0]", &properties).unwrap(), + parser + .clean_maven_version("(1.0,2.0]", &properties) + .unwrap(), "1.0" ); - assert!(parser - .clean_maven_version("${unknown.property}", &properties) - .is_err()); + assert!( + parser + .clean_maven_version("${unknown.property}", &properties) + .is_err() + ); let mut resolved = HashMap::new(); resolved.insert("custom.version".to_string(), "9.8.7".to_string()); @@ -670,7 +677,10 @@ dependencies { assert_eq!(parser.clean_gradle_version("1.2.+").unwrap(), "1.2.0"); assert_eq!(parser.clean_gradle_version("[1.2,2.0)").unwrap(), "1.2"); assert_eq!(parser.clean_gradle_version("(,2.0]").unwrap(), "2.0"); - assert_eq!(parser.clean_gradle_version("latest.release").unwrap(), "1.0.0"); + assert_eq!( + parser.clean_gradle_version("latest.release").unwrap(), + "1.0.0" + ); } #[test] diff --git a/vulnera-core/src/infrastructure/parsers/npm.rs b/vulnera-core/src/infrastructure/parsers/npm.rs index 581c32a6..2b5599f6 100644 --- a/vulnera-core/src/infrastructure/parsers/npm.rs +++ b/vulnera-core/src/infrastructure/parsers/npm.rs @@ -320,7 +320,8 @@ impl PackageLockParser { // Recurse when v1 dependencies map contains nested package objects. if let Some(deps_obj) = nested_deps.as_object() { if deps_obj.values().any(Value::is_object) { - let nested_result = Self::extract_lockfile_data(nested_deps, false)?; + let nested_result = + Self::extract_lockfile_data(nested_deps, false)?; packages.extend(nested_result.packages); dependencies.extend(nested_result.dependencies); } @@ -975,9 +976,8 @@ lodash@~4.17.21: let result = parser.parse_file(content).await.unwrap(); - assert!(result - .dependencies - .iter() - .any(|d| d.from.name == "express" && d.to.name == "accepts" && d.requirement == "~1.3.7")); + assert!(result.dependencies.iter().any(|d| d.from.name == "express" + && d.to.name == "accepts" + && d.requirement == "~1.3.7")); } } diff --git a/vulnera-core/src/infrastructure/parsers/python.rs b/vulnera-core/src/infrastructure/parsers/python.rs index 30b6081d..4bc1ae50 100644 --- a/vulnera-core/src/infrastructure/parsers/python.rs +++ b/vulnera-core/src/infrastructure/parsers/python.rs @@ -36,10 +36,9 @@ impl RequirementsTxtParser { if line.starts_with("-e ") || line.starts_with("git+") || line.contains(" @ http") { if let Some((name, version_hint)) = self.parse_url_or_vcs_requirement(line) { let normalized = self.normalize_python_version(&version_hint)?; - let version = - Version::parse(&normalized).map_err(|_| ParseError::Version { - version: version_hint.clone(), - })?; + let version = Version::parse(&normalized).map_err(|_| ParseError::Version { + version: version_hint.clone(), + })?; let package = Package::new(name, version, Ecosystem::PyPI) .map_err(|e| ParseError::MissingField { field: e })?; @@ -87,8 +86,7 @@ impl RequirementsTxtParser { static RE_EGG_NAME: Lazy = Lazy::new(|| Regex::new(r"#egg=([A-Za-z0-9_.\-]+)").unwrap()); static RE_WHEEL_NAME_VERSION: Lazy = Lazy::new(|| { - Regex::new(r"([A-Za-z0-9_.\-]+)-([0-9]+(?:\.[0-9]+){0,2}(?:[ab]|rc)?[0-9]*)") - .unwrap() + Regex::new(r"([A-Za-z0-9_.\-]+)-([0-9]+(?:\.[0-9]+){0,2}(?:[ab]|rc)?[0-9]*)").unwrap() }); let requirement = line.trim_start_matches("-e ").trim(); @@ -124,12 +122,7 @@ impl RequirementsTxtParser { } fn extract_version_hint_from_url(&self, url: &str) -> Option { - let token = url - .split('@') - .next_back()? - .split(['#', '?']) - .next()? - .trim(); + let token = url.split('@').next_back()?.split(['#', '?']).next()?.trim(); if token.is_empty() || token.contains('/') { return None; diff --git a/vulnera-core/src/infrastructure/parsers/yarn_pest.rs b/vulnera-core/src/infrastructure/parsers/yarn_pest.rs index 80ec7159..e66b5d77 100644 --- a/vulnera-core/src/infrastructure/parsers/yarn_pest.rs +++ b/vulnera-core/src/infrastructure/parsers/yarn_pest.rs @@ -255,8 +255,10 @@ impl YarnPestParser { // bundledDependencies can list names without versions. for dep_part in dep_item.into_inner() { if dep_part.as_rule() == Rule::dep_key { - dependency_specs - .push((Self::dequote(dep_part.as_str()), "*".to_string())); + dependency_specs.push(( + Self::dequote(dep_part.as_str()), + "*".to_string(), + )); } } } @@ -569,7 +571,8 @@ impl PackageFileParser for YarnPestParser { }; for from_name in &entry.names { - let Ok(from_package) = Package::new(from_name.clone(), from_version.clone(), Ecosystem::Npm) + let Ok(from_package) = + Package::new(from_name.clone(), from_version.clone(), Ecosystem::Npm) else { continue; }; @@ -684,9 +687,9 @@ lodash@^4.17.21: ); } - #[tokio::test] - async fn test_yarn_lock_extracts_dependency_edges() { - let content = r#" + #[tokio::test] + async fn test_yarn_lock_extracts_dependency_edges() { + let content = r#" foo@^1.0.0: version "1.0.0" dependencies: @@ -696,26 +699,27 @@ bar@^2.0.0: version "2.1.0" "#; - let parser = YarnPestParser::new(); - let result = parser.parse_file(content).await.unwrap(); - - assert!(result - .dependencies - .iter() - .any(|d| d.from.name == "foo" && d.to.name == "bar" && d.requirement == "^2.0.0"), - "dependencies={:?}, packages={:?}", - result - .dependencies - .iter() - .map(|d| format!("{} -> {} ({})", d.from.name, d.to.name, d.requirement)) - .collect::>(), - result - .packages - .iter() - .map(|p| format!("{}@{}", p.name, p.version)) - .collect::>() - ); - } + let parser = YarnPestParser::new(); + let result = parser.parse_file(content).await.unwrap(); + + assert!( + result + .dependencies + .iter() + .any(|d| d.from.name == "foo" && d.to.name == "bar" && d.requirement == "^2.0.0"), + "dependencies={:?}, packages={:?}", + result + .dependencies + .iter() + .map(|d| format!("{} -> {} ({})", d.from.name, d.to.name, d.requirement)) + .collect::>(), + result + .packages + .iter() + .map(|p| format!("{}@{}", p.name, p.version)) + .collect::>() + ); + } #[test] fn test_extract_name_from_key_spec() { diff --git a/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs b/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs index 43ca2966..c1c25a2e 100644 --- a/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs +++ b/vulnera-core/src/infrastructure/vulnerability_advisor/mod.rs @@ -308,16 +308,18 @@ impl VulneraAdvisorRepository { let default_version = fixed_versions .first() .cloned() - .or_else(|| version_ranges.first().and_then(|range| { - let probe_versions = ["0.0.0", "0.1.0", "1.0.0", "2.0.0", "10.0.0"]; - for probe in probe_versions { - let parsed = Version::parse(probe).ok()?; - if range.contains(&parsed) { - return Some(parsed); + .or_else(|| { + version_ranges.first().and_then(|range| { + let probe_versions = ["0.0.0", "0.1.0", "1.0.0", "2.0.0", "10.0.0"]; + for probe in probe_versions { + let parsed = Version::parse(probe).ok()?; + if range.contains(&parsed) { + return Some(parsed); + } } - } - None - })) + None + }) + }) .or_else(|| Version::parse("0.0.0").ok()) .ok_or_else(|| "Failed to construct default package version".to_string())?; @@ -441,7 +443,11 @@ impl IVulnerabilityRepository for VulneraAdvisorRepository { .get(id.as_str()) .await .map_err(|error| VulnerabilityError::Repository { - message: format!("Failed to query advisory store for {}: {}", id.as_str(), error), + message: format!( + "Failed to query advisory store for {}: {}", + id.as_str(), + error + ), })?; let Some(advisory) = advisory else { diff --git a/vulnera-deps/src/application/analysis_context.rs b/vulnera-deps/src/application/analysis_context.rs index d6a2b00d..bb1a43a6 100644 --- a/vulnera-deps/src/application/analysis_context.rs +++ b/vulnera-deps/src/application/analysis_context.rs @@ -3,9 +3,9 @@ //! This module provides context management for analyzing dependencies across //! entire projects, including workspace detection and configuration management. +use globset::{Glob, GlobSetBuilder}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use globset::{Glob, GlobSetBuilder}; use vulnera_core::domain::vulnerability::value_objects::Ecosystem; /// Configuration for dependency analysis diff --git a/vulnera-deps/src/domain/version_constraint.rs b/vulnera-deps/src/domain/version_constraint.rs index d8f4c2b5..6435ed5c 100644 --- a/vulnera-deps/src/domain/version_constraint.rs +++ b/vulnera-deps/src/domain/version_constraint.rs @@ -474,6 +474,9 @@ mod tests { let range = VersionConstraint::parse(">=1.0.0,<2.0.0").unwrap(); let intersection = exact.intersect(&range).unwrap(); - assert_eq!(intersection, VersionConstraint::Exact(Version::parse("1.2.3").unwrap())); + assert_eq!( + intersection, + VersionConstraint::Exact(Version::parse("1.2.3").unwrap()) + ); } } diff --git a/vulnera-deps/src/module.rs b/vulnera-deps/src/module.rs index b811f3ec..79568972 100644 --- a/vulnera-deps/src/module.rs +++ b/vulnera-deps/src/module.rs @@ -176,10 +176,9 @@ impl AnalysisModule for DependencyAnalyzerModule { } }; - let ecosystem = vulnera_core::domain::vulnerability::value_objects::Ecosystem::from_str( - ecosystem_str, - ) - .map_err(ModuleExecutionError::InvalidConfig)?; + let ecosystem = + vulnera_core::domain::vulnerability::value_objects::Ecosystem::from_str(ecosystem_str) + .map_err(ModuleExecutionError::InvalidConfig)?; let filename = config.config.get("filename").and_then(|v| v.as_str()); diff --git a/vulnera-deps/src/services/dependency_resolver.rs b/vulnera-deps/src/services/dependency_resolver.rs index da5ea752..f38e7252 100644 --- a/vulnera-deps/src/services/dependency_resolver.rs +++ b/vulnera-deps/src/services/dependency_resolver.rs @@ -219,21 +219,19 @@ impl DependencyResolverService for DependencyResolverServiceImpl { continue; }; - let transitive_package = match Package::new( - dependency.name.clone(), - version, - current.ecosystem.clone(), - ) { - Ok(package) => package, - Err(error) => { - warn!( - dependency = %dependency.name, - "Failed to build transitive package model: {}", - error - ); - continue; - } - }; + let transitive_package = + match Package::new(dependency.name.clone(), version, current.ecosystem.clone()) + { + Ok(package) => package, + Err(error) => { + warn!( + dependency = %dependency.name, + "Failed to build transitive package model: {}", + error + ); + continue; + } + }; let key = Self::package_key(&transitive_package); if discovered.contains_key(&key) { @@ -465,10 +463,10 @@ mod tests { project_url: None, license: None, }; - self.metadata.lock().unwrap().insert( - Self::metadata_key(ecosystem, name, version), - metadata, - ); + self.metadata + .lock() + .unwrap() + .insert(Self::metadata_key(ecosystem, name, version), metadata); } } @@ -575,8 +573,16 @@ mod tests { .await .expect("transitive resolution should succeed"); - assert!(resolved.iter().any(|pkg| pkg.name == "dep-a" && pkg.version == Version::parse("1.2.0").unwrap())); - assert!(resolved.iter().any(|pkg| pkg.name == "dep-b" && pkg.version == Version::parse("2.0.0").unwrap())); + assert!( + resolved + .iter() + .any(|pkg| pkg.name == "dep-a" && pkg.version == Version::parse("1.2.0").unwrap()) + ); + assert!( + resolved + .iter() + .any(|pkg| pkg.name == "dep-b" && pkg.version == Version::parse("2.0.0").unwrap()) + ); } #[tokio::test] diff --git a/vulnera-deps/src/services/repository_analysis.rs b/vulnera-deps/src/services/repository_analysis.rs index 3c39516b..47b44d1f 100644 --- a/vulnera-deps/src/services/repository_analysis.rs +++ b/vulnera-deps/src/services/repository_analysis.rs @@ -327,11 +327,7 @@ where } fn is_lockfile_path(path: &str) -> bool { - let file_name = path - .rsplit('/') - .next() - .unwrap_or(path) - .to_ascii_lowercase(); + let file_name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); matches!( file_name.as_str(), diff --git a/vulnera-deps/src/services/resolution_algorithms.rs b/vulnera-deps/src/services/resolution_algorithms.rs index c79a061c..0fee73a4 100644 --- a/vulnera-deps/src/services/resolution_algorithms.rs +++ b/vulnera-deps/src/services/resolution_algorithms.rs @@ -89,10 +89,15 @@ impl BacktrackingResolver { // Conflicts are already captured during the search. } - ResolutionResult { resolved, conflicts } + ResolutionResult { + resolved, + conflicts, + } } - fn constraints_by_package(graph: &DependencyGraph) -> HashMap> { + fn constraints_by_package( + graph: &DependencyGraph, + ) -> HashMap> { let mut constraints: HashMap> = HashMap::new(); for edge in &graph.edges { constraints @@ -198,9 +203,10 @@ impl BacktrackingResolver { constraints_by_package, assignments, conflicts, - ) { - return true; - } + ) + { + return true; + } assignments.remove(package_id); } @@ -439,7 +445,10 @@ mod tests { available_versions.insert(root_id.clone(), vec![Version::parse("1.0.0").unwrap()]); available_versions.insert( dep_id.clone(), - vec![Version::parse("1.5.0").unwrap(), Version::parse("2.1.0").unwrap()], + vec![ + Version::parse("1.5.0").unwrap(), + Version::parse("2.1.0").unwrap(), + ], ); let result = BacktrackingResolver::resolve(&graph, &available_versions); diff --git a/vulnera-deps/src/use_cases.rs b/vulnera-deps/src/use_cases.rs index 3b092ee0..13305d03 100644 --- a/vulnera-deps/src/use_cases.rs +++ b/vulnera-deps/src/use_cases.rs @@ -282,9 +282,9 @@ impl AnalyzeDependenciesUseCase { && !final_packages .iter() .any(|p: &Package| p.name == pkg.name && p.version == pkg.version) - { - final_packages.push(pkg.clone()); - } + { + final_packages.push(pkg.clone()); + } } packages = final_packages; diff --git a/vulnera-llm/src/domain/messages.rs b/vulnera-llm/src/domain/messages.rs index e7009ebf..7e6e8b27 100644 --- a/vulnera-llm/src/domain/messages.rs +++ b/vulnera-llm/src/domain/messages.rs @@ -160,8 +160,7 @@ impl Message { } /// Completion request to send to an LLM provider -#[derive(Debug, Clone, Serialize, Deserialize)] -#[derive(Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CompletionRequest { /// The conversation messages pub messages: Vec, @@ -203,7 +202,6 @@ pub struct CompletionRequest { pub stream: Option, } - impl CompletionRequest { /// Create a new completion request pub fn new() -> Self { diff --git a/vulnera-orchestrator/src/application/use_cases.rs b/vulnera-orchestrator/src/application/use_cases.rs index f33a7c7b..850dce7c 100644 --- a/vulnera-orchestrator/src/application/use_cases.rs +++ b/vulnera-orchestrator/src/application/use_cases.rs @@ -193,8 +193,6 @@ impl ExecuteAnalysisJobUseCase { let executor = self.executor.clone(); let sandbox_config = self.config.clone(); - let source_size_bytes = source_size_bytes; - debug!( job_id = %job.job_id, module = ?module_type_clone, @@ -265,10 +263,9 @@ impl ExecuteAnalysisJobUseCase { // Always return a ModuleResult (either success or error) match result { Ok(mut r) => { - r.metadata.additional_info.insert( - "sandbox.profile".to_string(), - profile_name.clone(), - ); + r.metadata + .additional_info + .insert("sandbox.profile".to_string(), profile_name.clone()); r.metadata.additional_info.insert( "sandbox.timeout_ms".to_string(), limits.timeout.as_millis().to_string(), @@ -281,10 +278,9 @@ impl ExecuteAnalysisJobUseCase { "sandbox.backend_effective".to_string(), backend_name.clone(), ); - r.metadata.additional_info.insert( - "sandbox.strict_mode".to_string(), - strict_mode.to_string(), - ); + r.metadata + .additional_info + .insert("sandbox.strict_mode".to_string(), strict_mode.to_string()); r.metadata.additional_info.insert( "sandbox.source_size_bytes".to_string(), source_size_bytes.unwrap_or(0).to_string(), @@ -301,10 +297,10 @@ impl ExecuteAnalysisJobUseCase { metadata: Default::default(), error: Some(e.to_string()), }; - error_result.metadata.additional_info.insert( - "sandbox.profile".to_string(), - profile_name, - ); + error_result + .metadata + .additional_info + .insert("sandbox.profile".to_string(), profile_name); error_result.metadata.additional_info.insert( "sandbox.timeout_ms".to_string(), limits.timeout.as_millis().to_string(), @@ -313,14 +309,14 @@ impl ExecuteAnalysisJobUseCase { "sandbox.max_memory_bytes".to_string(), limits.max_memory.to_string(), ); - error_result.metadata.additional_info.insert( - "sandbox.backend_effective".to_string(), - backend_name, - ); - error_result.metadata.additional_info.insert( - "sandbox.strict_mode".to_string(), - strict_mode.to_string(), - ); + error_result + .metadata + .additional_info + .insert("sandbox.backend_effective".to_string(), backend_name); + error_result + .metadata + .additional_info + .insert("sandbox.strict_mode".to_string(), strict_mode.to_string()); error_result.metadata.additional_info.insert( "sandbox.source_size_bytes".to_string(), source_size_bytes.unwrap_or(0).to_string(), @@ -417,7 +413,10 @@ impl ExecuteAnalysisJobUseCase { } } -fn module_policy_profile(module_type: &ModuleType, sandbox_config: &SandboxConfig) -> SandboxPolicyProfile { +fn module_policy_profile( + module_type: &ModuleType, + sandbox_config: &SandboxConfig, +) -> SandboxPolicyProfile { match module_type { ModuleType::DependencyAnalyzer if sandbox_config.allow_network => { SandboxPolicyProfile::DependencyResolution { diff --git a/vulnera-orchestrator/src/presentation/controllers/repository.rs b/vulnera-orchestrator/src/presentation/controllers/repository.rs index 6d04a277..afd31a17 100644 --- a/vulnera-orchestrator/src/presentation/controllers/repository.rs +++ b/vulnera-orchestrator/src/presentation/controllers/repository.rs @@ -155,9 +155,8 @@ fn resolve_repository_coordinates( "repository_url cannot be empty", ))); } - let (owner, repo, derived_ref) = - parse_repository_identifier(trimmed) - .map_err(|error| Box::new(validation_error_response(error)))?; + let (owner, repo, derived_ref) = parse_repository_identifier(trimmed) + .map_err(|error| Box::new(validation_error_response(error)))?; return Ok(RepositoryCoordinates { owner, repo, diff --git a/vulnera-sandbox/src/application/executor.rs b/vulnera-sandbox/src/application/executor.rs index aa6e791e..9ab6b3ee 100644 --- a/vulnera-sandbox/src/application/executor.rs +++ b/vulnera-sandbox/src/application/executor.rs @@ -290,24 +290,24 @@ impl SandboxExecutor { ); if let Some(ref backend) = worker_result.sandbox_backend { - result.metadata.additional_info.insert( - "sandbox.worker_backend".to_string(), - backend.clone(), - ); + result + .metadata + .additional_info + .insert("sandbox.worker_backend".to_string(), backend.clone()); } if let Some(applied) = worker_result.sandbox_applied { - result.metadata.additional_info.insert( - "sandbox.applied".to_string(), - applied.to_string(), - ); + result + .metadata + .additional_info + .insert("sandbox.applied".to_string(), applied.to_string()); } if let Some(ref setup_error) = worker_result.sandbox_setup_error { - result.metadata.additional_info.insert( - "sandbox.setup_error".to_string(), - setup_error.clone(), - ); + result + .metadata + .additional_info + .insert("sandbox.setup_error".to_string(), setup_error.clone()); } result.metadata.additional_info.insert( @@ -342,10 +342,10 @@ impl SandboxExecutor { "sandbox.execution_mode".to_string(), "in_process_fallback".to_string(), ); - result.metadata.additional_info.insert( - "sandbox.applied".to_string(), - "false".to_string(), - ); + result + .metadata + .additional_info + .insert("sandbox.applied".to_string(), "false".to_string()); Ok(result) } Ok(Err(e)) => Err(SandboxedExecutionError::ModuleFailed(e)), diff --git a/vulnera-sandbox/src/domain/policy.rs b/vulnera-sandbox/src/domain/policy.rs index a05cb987..efc8d959 100644 --- a/vulnera-sandbox/src/domain/policy.rs +++ b/vulnera-sandbox/src/domain/policy.rs @@ -326,11 +326,10 @@ mod tests { #[test] fn test_dependency_profile_adds_required_ports() { - let policy = SandboxPolicy::default().with_profile( - SandboxPolicyProfile::DependencyResolution { + let policy = + SandboxPolicy::default().with_profile(SandboxPolicyProfile::DependencyResolution { include_cache_port: true, - }, - ); + }); assert!(policy.allowed_ports.contains(&80)); assert!(policy.allowed_ports.contains(&443)); From f19d2b4235da8f8fb995a9b3e9f9a36e982d4d10 Mon Sep 17 00:00:00 2001 From: k5602 <188656344+k5602@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:26:16 +0200 Subject: [PATCH 9/9] Delete rust-guardrails.yml --- .github/workflows/rust-guardrails.yml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .github/workflows/rust-guardrails.yml diff --git a/.github/workflows/rust-guardrails.yml b/.github/workflows/rust-guardrails.yml deleted file mode 100644 index 7dfef372..00000000 --- a/.github/workflows/rust-guardrails.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Rust Guardrails - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - panic-guardrails: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Enforce panic guardrails - run: | - cargo clippy --no-deps -p vulnera-orchestrator -p vulnera-sandbox -p vulnera-llm -- \ - -D clippy::unwrap_used \ - -D clippy::expect_used \ - -D clippy::panic