diff --git a/README.md b/README.md index 3880a98..2288918 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ let advisories = manager.query_ossindex(&purls).await?; ## Filtering Options -Filter vulnerabilities by severity, EPSS score, or KEV status: +Filter vulnerabilities by severity, EPSS score, KEV status, or CWE IDs: ```rust use vulnera_advisors::{MatchOptions, Severity}; @@ -114,18 +114,45 @@ let options = MatchOptions::high_severity(); // Only actively exploited (KEV) let options = MatchOptions::exploited_only(); -// Custom filters +// Filter by CWE IDs (e.g., XSS vulnerabilities) +let options = MatchOptions::with_cwes(vec!["CWE-79".to_string()]); + +// Filter by multiple CWEs (injection vulnerabilities) +let options = MatchOptions::with_cwes(vec![ + "CWE-79".to_string(), // XSS + "CWE-89".to_string(), // SQL Injection + "CWE-78".to_string(), // OS Command Injection +]); + +// Custom filters with CWE let options = MatchOptions { min_cvss: Some(7.0), min_epss: Some(0.5), kev_only: false, min_severity: Some(Severity::Medium), + cwe_ids: Some(vec!["CWE-79".to_string(), "CWE-89".to_string()]), include_enrichment: true, }; let vulns = manager.matches_with_options("npm", "lodash", "4.17.20", &options).await?; ``` +### CWE Filtering + +Filter advisories by Common Weakness Enumeration (CWE) identifiers: + +| Use Case | CWE IDs | +| -------------------- | --------- | +| Cross-Site Scripting | `CWE-79` | +| SQL Injection | `CWE-89` | +| OS Command Injection | `CWE-78` | +| Path Traversal | `CWE-22` | +| Deserialization | `CWE-502` | +| SSRF | `CWE-918` | + +CWE IDs are automatically normalized - all formats work: +- `"CWE-79"`, `"cwe-79"`, `"79"` all match the same CWE + ## Remediation Suggestions Get safe version recommendations when vulnerabilities are detected: @@ -168,11 +195,11 @@ let remediation = manager ### Upgrade Impact Classification -| Impact | Description | -|--------|-------------| -| `patch` | Bug fix only (x.y.Z) | +| Impact | Description | +| ------- | ----------------------------------------- | +| `patch` | Bug fix only (x.y.Z) | | `minor` | New features, backward compatible (x.Y.z) | -| `major` | Breaking changes (X.y.z) | +| `major` | Breaking changes (X.y.z) | ## Environment Variables diff --git a/examples/test_cwe.rs b/examples/test_cwe.rs new file mode 100644 index 0000000..3e4ac04 --- /dev/null +++ b/examples/test_cwe.rs @@ -0,0 +1,68 @@ +//! Example: Test CWE filtering functionality +//! +//! Run with: cargo run --example test_cwe + +use vulnera_advisor::{MatchOptions, Severity}; + +fn main() { + println!("=== CWE Filtering Test ===\n"); + + // Test 1: Default options (no CWE filter) + let default_opts = MatchOptions::default(); + println!("1. Default options:"); + println!(" cwe_ids: {:?}", default_opts.cwe_ids); + println!(" kev_only: {}", default_opts.kev_only); + println!(); + + // Test 2: Filter for XSS vulnerabilities (CWE-79) + let xss_opts = MatchOptions::with_cwes(vec!["CWE-79".to_string()]); + println!("2. XSS filter (CWE-79):"); + println!(" cwe_ids: {:?}", xss_opts.cwe_ids); + println!(" include_enrichment: {}", xss_opts.include_enrichment); + println!(); + + // Test 3: Filter for multiple injection vulnerabilities + let injection_opts = MatchOptions::with_cwes(vec![ + "CWE-79".to_string(), // XSS + "CWE-89".to_string(), // SQL Injection + "CWE-78".to_string(), // OS Command Injection + ]); + println!("3. Injection filter (CWE-79, CWE-89, CWE-78):"); + println!(" cwe_ids: {:?}", injection_opts.cwe_ids); + println!(); + + // Test 4: Combined filters (CWE + Severity + KEV) + let critical_xss = MatchOptions { + cwe_ids: Some(vec!["CWE-79".to_string()]), + min_severity: Some(Severity::High), + kev_only: true, + include_enrichment: true, + ..Default::default() + }; + println!("4. Critical XSS (High severity + KEV):"); + println!(" cwe_ids: {:?}", critical_xss.cwe_ids); + println!(" min_severity: {:?}", critical_xss.min_severity); + println!(" kev_only: {}", critical_xss.kev_only); + println!(); + + // Test 5: OWASP Top 10 related CWEs + let owasp_top10 = MatchOptions::with_cwes(vec![ + "CWE-79".to_string(), // A03: Injection (XSS) + "CWE-89".to_string(), // A03: Injection (SQLi) + "CWE-287".to_string(), // A07: Identification and Authentication Failures + "CWE-352".to_string(), // A01: Broken Access Control (CSRF) + "CWE-611".to_string(), // A05: Security Misconfiguration (XXE) + "CWE-502".to_string(), // A08: Software and Data Integrity Failures + ]); + println!("5. OWASP Top 10 related CWEs:"); + println!(" cwe_ids: {:?}", owasp_top10.cwe_ids); + println!(); + + println!("=== All tests completed ==="); + println!("\nTo use with VulnerabilityManager:"); + println!(" let manager = VulnerabilityManager::new(config).await?;"); + println!(" let options = MatchOptions::with_cwes(vec![\"CWE-79\".to_string()]);"); + println!( + " let xss_vulns = manager.matches_with_options(\"npm\", \"pkg\", \"1.0.0\", &options).await?;" + ); +} diff --git a/scripts/test.sh b/scripts/test.sh old mode 100644 new mode 100755 index 5f3a2e9..43218fa --- a/scripts/test.sh +++ b/scripts/test.sh @@ -12,9 +12,32 @@ echo "" GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' +BLUE='\033[0;34m' NC='\033[0m' # No Color FAILED=0 +SKIPPED=0 + +# Parse arguments +VERBOSE=false +SKIP_OPTIONAL=false +while [[ "$#" -gt 0 ]]; do + case $1 in + -v|--verbose) VERBOSE=true ;; + --skip-optional) SKIP_OPTIONAL=true ;; + -h|--help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -v, --verbose Show full output for each test" + echo " --skip-optional Skip optional checks (audit, machete)" + echo " -h, --help Show this help message" + exit 0 + ;; + *) echo "Unknown parameter: $1"; exit 1 ;; + esac + shift +done # Function to run a test run_test() { @@ -22,14 +45,45 @@ run_test() { local cmd=$2 echo -n "▶ $name ... " - if eval "$cmd" > /dev/null 2>&1; then - echo -e "${GREEN}✓${NC}" + if $VERBOSE; then + echo "" + if eval "$cmd"; then + echo -e "${GREEN}✓ Passed${NC}" + else + echo -e "${RED}✗ Failed${NC}" + FAILED=$((FAILED + 1)) + fi else - echo -e "${RED}✗${NC}" - FAILED=$((FAILED + 1)) + if eval "$cmd" > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗${NC}" + FAILED=$((FAILED + 1)) + fi fi } +# Function to run an optional test (skips if tool not found) +run_optional_test() { + local name=$1 + local tool=$2 + local cmd=$3 + + if $SKIP_OPTIONAL; then + echo -e "▶ $name ... ${YELLOW}skipped${NC}" + SKIPPED=$((SKIPPED + 1)) + return + fi + + if ! command -v "$tool" &> /dev/null; then + echo -e "▶ $name ... ${YELLOW}skipped (${tool} not installed)${NC}" + SKIPPED=$((SKIPPED + 1)) + return + fi + + run_test "$name" "$cmd" +} + # Function to run a test with output run_test_verbose() { local name=$1 @@ -44,6 +98,8 @@ run_test_verbose() { fi } +echo -e "${BLUE}=== Core Checks ===${NC}" + # Check formatting run_test "Formatting (cargo fmt)" "cargo fmt -- --check" @@ -53,25 +109,44 @@ run_test "Linting (cargo clippy)" "cargo clippy --all-targets --all-features -- # Check for common mistakes run_test "Cargo check" "cargo check --all-targets --all-features" +echo "" +echo -e "${BLUE}=== Tests ===${NC}" + # Unit tests run_test "Unit tests" "cargo test --lib" # Doc tests run_test "Doc tests" "cargo test --doc" +# CWE filtering tests (matches all tests with 'cwe' in name) +run_test "CWE filtering tests" "cargo test --lib cwe" + +echo "" +echo -e "${BLUE}=== Documentation ===${NC}" + # Build documentation run_test "Documentation" "cargo doc --no-deps --all-features" -# Security audit -run_test "Security audit" "cargo audit" +echo "" +echo -e "${BLUE}=== Optional Checks ===${NC}" -# Check for deprecated dependencies -run_test "Check for unused dependencies" "cargo machete" +# Security audit (optional - requires cargo-audit) +run_optional_test "Security audit" "cargo-audit" "cargo audit" + +# Check for unused dependencies (optional - requires cargo-machete) +run_optional_test "Unused dependencies" "cargo-machete" "cargo machete" + +# Check for outdated dependencies (optional - requires cargo-outdated) +run_optional_test "Outdated dependencies" "cargo-outdated" "cargo outdated --exit-code 1" echo "" echo "======================================" if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}✓ All tests passed!${NC}" + if [ $SKIPPED -gt 0 ]; then + echo -e "${GREEN}✓ All tests passed!${NC} (${YELLOW}$SKIPPED skipped${NC})" + else + echo -e "${GREEN}✓ All tests passed!${NC}" + fi echo "" echo "You can now:" echo " • Run 'cargo publish --dry-run' to test publishing" @@ -79,6 +154,9 @@ if [ $FAILED -eq 0 ]; then exit 0 else echo -e "${RED}✗ $FAILED test(s) failed${NC}" + if [ $SKIPPED -gt 0 ]; then + echo -e "${YELLOW} $SKIPPED test(s) skipped${NC}" + fi echo "" echo "Please fix the issues above before committing." exit 1 diff --git a/src/manager.rs b/src/manager.rs index 76a262e..7944b10 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -30,6 +30,9 @@ pub struct MatchOptions { pub min_severity: Option, /// Include enrichment data (EPSS, KEV) in results. pub include_enrichment: bool, + /// Filter by CWE IDs (e.g., ["CWE-79", "CWE-89"]). + /// Only advisories with at least one matching CWE will be returned. + pub cwe_ids: Option>, } /// Statistics for a sync operation. @@ -126,6 +129,26 @@ impl MatchOptions { ..Default::default() } } + + /// Create options to filter by specific CWE IDs. + /// + /// Only advisories containing at least one of the specified CWEs will match. + /// + /// # Example + /// + /// ```rust,ignore + /// use vulnera_advisors::MatchOptions; + /// + /// // Filter for XSS (CWE-79) or SQL Injection (CWE-89) + /// let options = MatchOptions::with_cwes(vec!["CWE-79".to_string(), "CWE-89".to_string()]); + /// ``` + pub fn with_cwes(cwe_ids: Vec) -> Self { + Self { + cwe_ids: Some(cwe_ids), + include_enrichment: true, + ..Default::default() + } + } } /// A key identifying a package for batch queries. @@ -1060,9 +1083,71 @@ impl VulnerabilityManager { } } + // Check CWE filter + if let Some(ref filter_cwes) = options.cwe_ids { + if !filter_cwes.is_empty() { + let advisory_cwes = Self::extract_cwes_from_advisory(advisory); + // Normalize both filter CWEs and advisory CWEs for consistent matching + let normalized_filter: Vec = filter_cwes + .iter() + .map(|c| Self::normalize_cwe_id(c)) + .collect(); + let normalized_advisory: Vec = advisory_cwes + .iter() + .map(|c| Self::normalize_cwe_id(c)) + .collect(); + // Advisory must have at least one matching CWE + let has_match = normalized_filter + .iter() + .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe)); + if !has_match { + return false; + } + } + } + true } + /// Normalize a CWE identifier to uppercase "CWE-XXX" format. + /// + /// Handles various input formats: + /// - "79" → "CWE-79" + /// - "cwe-79" → "CWE-79" + /// - "CWE-79" → "CWE-79" + fn normalize_cwe_id(cwe: &str) -> String { + let trimmed = cwe.trim(); + let upper = trimmed.to_uppercase(); + + if upper.starts_with("CWE-") { + upper + } else { + format!("CWE-{}", trimmed) + } + } + + /// Extract CWE identifiers from an advisory. + /// + /// CWEs may be stored in `database_specific.cwe_ids` (from OSS Index and some OSV sources). + fn extract_cwes_from_advisory(advisory: &Advisory) -> Vec { + let mut cwes = Vec::new(); + + // Check database_specific.cwe_ids (OSS Index, some OSV sources) + if let Some(ref db_specific) = advisory.database_specific { + if let Some(cwe_ids) = db_specific.get("cwe_ids") { + if let Some(arr) = cwe_ids.as_array() { + for cwe in arr { + if let Some(s) = cwe.as_str() { + cwes.push(s.to_string()); + } + } + } + } + } + + cwes + } + /// Fetch live EPSS scores for CVEs (not from cache). pub async fn fetch_epss_scores(&self, cve_ids: &[&str]) -> Result> { let scores = self.epss_source.fetch_scores(cve_ids).await?; @@ -1428,3 +1513,286 @@ impl VulnerabilityManager { map } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Advisory, Enrichment, Severity}; + + /// Helper to create a test advisory with optional CWEs in database_specific + fn create_advisory_with_cwes(id: &str, cwes: Option>) -> Advisory { + let database_specific = cwes.map(|cwe_list| { + serde_json::json!({ + "cwe_ids": cwe_list + }) + }); + + Advisory { + id: id.to_string(), + summary: Some("Test advisory".to_string()), + details: None, + affected: vec![], + references: vec![], + published: None, + modified: None, + aliases: None, + database_specific, + enrichment: None, + } + } + + /// Helper to create a test advisory with enrichment data + fn create_advisory_with_enrichment(id: &str, severity: Severity, is_kev: bool) -> Advisory { + Advisory { + id: id.to_string(), + summary: Some("Test advisory".to_string()), + details: None, + affected: vec![], + references: vec![], + published: None, + modified: None, + aliases: None, + database_specific: None, + enrichment: Some(Enrichment { + cvss_v3_severity: Some(severity), + is_kev, + ..Default::default() + }), + } + } + + #[test] + fn test_match_options_default() { + let options = MatchOptions::default(); + assert!(options.cwe_ids.is_none()); + assert!(options.min_cvss.is_none()); + assert!(!options.kev_only); + } + + #[test] + fn test_match_options_with_cwes() { + let options = MatchOptions::with_cwes(vec!["CWE-79".to_string(), "CWE-89".to_string()]); + assert!(options.cwe_ids.is_some()); + let cwes = options.cwe_ids.unwrap(); + assert_eq!(cwes.len(), 2); + assert!(cwes.contains(&"CWE-79".to_string())); + assert!(cwes.contains(&"CWE-89".to_string())); + assert!(options.include_enrichment); + } + + #[test] + fn test_extract_cwes_from_advisory_with_cwes() { + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79", "CWE-89"])); + let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + assert_eq!(cwes.len(), 2); + assert!(cwes.contains(&"CWE-79".to_string())); + assert!(cwes.contains(&"CWE-89".to_string())); + } + + #[test] + fn test_extract_cwes_from_advisory_no_cwes() { + let advisory = create_advisory_with_cwes("CVE-2024-1234", None); + let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + assert!(cwes.is_empty()); + } + + #[test] + fn test_extract_cwes_from_advisory_empty_cwes() { + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec![])); + let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + assert!(cwes.is_empty()); + } + + #[test] + fn test_cwe_filter_case_insensitive() { + // Test that CWE matching is case-insensitive + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["cwe-79"])); + + // Create options with uppercase CWE + let options = MatchOptions::with_cwes(vec!["CWE-79".to_string()]); + + // Extract CWEs + let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + + // Check case-insensitive matching + let filter_cwes = options.cwe_ids.as_ref().unwrap(); + let has_match = filter_cwes + .iter() + .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe))); + assert!(has_match, "CWE matching should be case-insensitive"); + } + + #[test] + fn test_cwe_filter_no_match() { + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79"])); + + // Create options filtering for a different CWE + let options = MatchOptions::with_cwes(vec!["CWE-89".to_string()]); + + let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + let filter_cwes = options.cwe_ids.as_ref().unwrap(); + let has_match = filter_cwes + .iter() + .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe))); + assert!(!has_match, "Should not match when CWEs don't overlap"); + } + + #[test] + fn test_cwe_filter_partial_match() { + // Advisory has multiple CWEs, filter matches one of them + let advisory = + create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79", "CWE-352", "CWE-94"])); + + let options = MatchOptions::with_cwes(vec!["CWE-89".to_string(), "CWE-79".to_string()]); + + let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + let filter_cwes = options.cwe_ids.as_ref().unwrap(); + let has_match = filter_cwes + .iter() + .any(|cwe| advisory_cwes.iter().any(|ac| ac.eq_ignore_ascii_case(cwe))); + assert!(has_match, "Should match when at least one CWE overlaps"); + } + + #[test] + fn test_match_options_empty_cwe_list() { + // Empty CWE list should not filter anything + let options = MatchOptions { + cwe_ids: Some(vec![]), + ..Default::default() + }; + + // The filter check should pass when cwe_ids list is empty + assert!(options.cwe_ids.as_ref().is_none_or(|v| v.is_empty())); + } + + #[test] + fn test_match_options_combined_filters() { + // Test that CWE filter can be combined with other filters + let options = MatchOptions { + cwe_ids: Some(vec!["CWE-79".to_string()]), + min_severity: Some(Severity::High), + kev_only: true, + include_enrichment: true, + ..Default::default() + }; + + assert!(options.cwe_ids.is_some()); + assert_eq!(options.min_severity, Some(Severity::High)); + assert!(options.kev_only); + } + + #[test] + fn test_normalize_cwe_id_with_prefix() { + assert_eq!(VulnerabilityManager::normalize_cwe_id("CWE-79"), "CWE-79"); + assert_eq!(VulnerabilityManager::normalize_cwe_id("cwe-79"), "CWE-79"); + assert_eq!(VulnerabilityManager::normalize_cwe_id("Cwe-89"), "CWE-89"); + } + + #[test] + fn test_normalize_cwe_id_bare_number() { + assert_eq!(VulnerabilityManager::normalize_cwe_id("79"), "CWE-79"); + assert_eq!(VulnerabilityManager::normalize_cwe_id("89"), "CWE-89"); + assert_eq!(VulnerabilityManager::normalize_cwe_id("352"), "CWE-352"); + } + + #[test] + fn test_normalize_cwe_id_with_whitespace() { + assert_eq!(VulnerabilityManager::normalize_cwe_id(" CWE-79 "), "CWE-79"); + assert_eq!(VulnerabilityManager::normalize_cwe_id(" 79 "), "CWE-79"); + } + + #[test] + fn test_cwe_filter_bare_id_matches_prefixed() { + // User filters with bare "79", advisory has "CWE-79" + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["CWE-79"])); + let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + + let filter_cwes = ["79".to_string()]; + let normalized_filter: Vec = filter_cwes + .iter() + .map(|c| VulnerabilityManager::normalize_cwe_id(c)) + .collect(); + let normalized_advisory: Vec = advisory_cwes + .iter() + .map(|c| VulnerabilityManager::normalize_cwe_id(c)) + .collect(); + + let has_match = normalized_filter + .iter() + .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe)); + assert!(has_match, "Bare '79' should match 'CWE-79'"); + } + + #[test] + fn test_cwe_filter_prefixed_matches_bare() { + // User filters with "CWE-79", advisory has bare "79" + let advisory = create_advisory_with_cwes("CVE-2024-1234", Some(vec!["79"])); + let advisory_cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + + let filter_cwes = ["CWE-79".to_string()]; + let normalized_filter: Vec = filter_cwes + .iter() + .map(|c| VulnerabilityManager::normalize_cwe_id(c)) + .collect(); + let normalized_advisory: Vec = advisory_cwes + .iter() + .map(|c| VulnerabilityManager::normalize_cwe_id(c)) + .collect(); + + let has_match = normalized_filter + .iter() + .any(|cwe| normalized_advisory.iter().any(|ac| ac == cwe)); + assert!(has_match, "'CWE-79' should match bare '79'"); + } + + #[test] + fn test_cwe_filter_with_enrichment_severity() { + // Test CWE filtering works correctly with enrichment data (severity) + let mut advisory = create_advisory_with_enrichment("CVE-2024-1234", Severity::High, false); + + // Add CWE data to the advisory + let mut db_specific = serde_json::Map::new(); + db_specific.insert( + "cwe_ids".to_string(), + serde_json::json!(["CWE-79", "CWE-89"]), + ); + advisory.database_specific = Some(serde_json::Value::Object(db_specific)); + + // Verify enrichment is present + assert!(advisory.enrichment.is_some()); + assert_eq!( + advisory.enrichment.as_ref().unwrap().cvss_v3_severity, + Some(Severity::High) + ); + + // Verify CWE extraction works with enrichment + let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + assert_eq!(cwes, vec!["CWE-79", "CWE-89"]); + } + + #[test] + fn test_cwe_filter_with_enrichment_kev() { + // Test CWE filtering works correctly with KEV status + let mut advisory = + create_advisory_with_enrichment("CVE-2024-5678", Severity::Critical, true); + + // Add CWE data + let mut db_specific = serde_json::Map::new(); + db_specific.insert("cwe_ids".to_string(), serde_json::json!(["CWE-78"])); + advisory.database_specific = Some(serde_json::Value::Object(db_specific)); + + // Verify KEV status is present + assert!(advisory.enrichment.as_ref().unwrap().is_kev); + + // Verify CWE extraction still works + let cwes = VulnerabilityManager::extract_cwes_from_advisory(&advisory); + assert_eq!(cwes, vec!["CWE-78"]); + + // Test normalization + let normalized: Vec = cwes + .iter() + .map(|c| VulnerabilityManager::normalize_cwe_id(c)) + .collect(); + assert_eq!(normalized, vec!["CWE-78"]); + } +}