secfinding 0.3.0

Universal security finding types for vulnerability scanners.
Documentation
// Extracted from src/finding.rs
use secfinding::*;
use secfinding::finding::*;

use super::*;

#[test]
fn builder_basic() {
    let f = Finding::builder("gossan", "https://example.com", Severity::High)
        .title("Open Admin Panel")
        .detail("Admin panel accessible without authentication")
        .kind(FindingKind::Exposure)
        .tag("admin")
        .build()
        .unwrap();

    assert_eq!(f.scanner, "gossan");
    assert_eq!(f.severity, Severity::High);
    assert_eq!(f.kind, FindingKind::Exposure);
    assert_eq!(f.tags, vec!["admin"]);
}

#[test]
fn builder_empty_fields_fall_back_to_empty() {
    let f = Finding::builder("gossan", "https://example.com", Severity::Low)
        .title("title")
        .build()
        .unwrap();

    assert_eq!(f.title, "title");
    assert_eq!(f.detail, "");
    assert_eq!(f.kind, FindingKind::Other);
    assert_eq!(f.evidence.len(), 0);
}

#[test]
fn builder_full_and_duplicate_tags_are_deduped() {
    let f = Finding::builder("calyx", "https://target.com", Severity::Critical)
        .title("Remote Code Execution")
        .detail("Template injection in search parameter")
        .kind(FindingKind::Vulnerability)
        .evidence(Evidence::http_status(500).unwrap())
        .tag("rce")
        .tag("rce")
        .tag("ssti")
        .tag("ssti")
        .cve("CVE-2024-12345")
        .cwe("CWE-94")
        .reference("https://nvd.nist.gov/vuln/detail/CVE-2024-12345")
        .confidence(0.95)
        .exploit_hint("curl https://target.com/search?q={{7*7}}")
        .remediation("Escape template input before rendering")
        .matched_value("49")
        .matched_value("49")
        .build()
        .unwrap();

    assert_eq!(f.title, "Remote Code Execution");
    assert_eq!(f.cve_ids, vec!["CVE-2024-12345"]);
    assert_eq!(f.cwe_ids, vec!["CWE-94"]);
    assert_eq!(
        f.references,
        vec!["https://nvd.nist.gov/vuln/detail/CVE-2024-12345"]
    );
    assert_eq!(f.confidence, Some(0.95));
    assert_eq!(
        f.remediation.as_deref(),
        Some("Escape template input before rendering")
    );
    assert_eq!(f.tags, vec!["rce", "ssti"]);
    assert_eq!(f.matched_values, vec!["49", "49"]);
}

#[test]
fn builder_rejects_very_long_cve_identifier() {
    let long = "CVE-".to_string() + &"9".repeat(30_000);
    let f = Finding::builder("scan", "target", Severity::Medium)
        .title("test")
        .cve(long.clone())
        .build();

    assert!(f.is_err());
}

#[test]
fn serde_roundtrip_preserves_findings() {
    let f = Finding::builder("test", "target", Severity::Medium)
        .title("test")
        .confidence(1.5)
        .reference("https://example.com/advisory")
        .cwe("CWE-89")
        .tag("cfg")
        .matched_value("needle")
        .build()
        .unwrap();

    let json = serde_json::to_string(&f).unwrap();
    let back: Finding = serde_json::from_str(&json).unwrap();
    assert_eq!(back.scanner, "test");
    assert_eq!(back.severity, Severity::Medium);
    assert_eq!(back.confidence, Some(1.0));
    assert_eq!(back.cwe_ids, vec!["CWE-89"]);
    assert_eq!(back.references, vec!["https://example.com/advisory"]);
    assert_eq!(back.tags, vec!["cfg"]);
    assert_eq!(back.matched_values, vec!["needle"]);
}

#[test]
fn new_convenience_constructor() {
    let f = Finding::new("scanner", "target", Severity::Info, "Title", "Detail").unwrap();
    assert_eq!(f.scanner, "scanner");
    assert_eq!(f.target, "target");
    assert_eq!(f.severity, Severity::Info);
    assert_eq!(f.title, "Title");
    assert_eq!(f.detail, "Detail");
    assert!(!f.id.is_nil());
}

#[test]
fn each_finding_gets_unique_id() {
    let a = Finding::new("s", "t", Severity::Low, "title", "").unwrap();
    let b = Finding::new("s", "t", Severity::Low, "title", "").unwrap();
    assert_ne!(a.id, b.id);
}

#[test]
fn debug_impl_contains_title() {
    let f = Finding::new("scan", "target.com", Severity::High, "SQLi Found", "").unwrap();
    let debug = format!("{f:?}");
    assert!(debug.contains("SQLi Found"));
}

#[test]
fn unicode_in_all_fields() {
    let f = Finding::builder("スキャナ", "https://例え.jp", Severity::Critical)
        .title("日本語の脆弱性")
        .detail("これはテストです")
        .tag("テスト")
        .build()
        .unwrap();
    assert_eq!(f.scanner, "スキャナ");
    assert_eq!(f.title, "日本語の脆弱性");
    let json = serde_json::to_string(&f).unwrap();
    let back: Finding = serde_json::from_str(&json).unwrap();
    assert_eq!(back.title, f.title);
}

#[test]
fn empty_strings_everywhere() {
    let f = Finding::new("", "", Severity::Info, "", "");
    assert!(f.is_err());
}

#[test]
fn confidence_nan_fails() {
    let f = Finding::builder("s", "t", Severity::Info)
        .title("t")
        .confidence(f64::NAN)
        .build();
    assert!(f.is_err());
}

#[test]
fn multiple_evidence_items() {
    let f = Finding::builder("s", "t", Severity::Medium)
        .title("title")
        .evidence(Evidence::http_status(200).unwrap())
        .evidence(Evidence::http_status(500).unwrap())
        .build()
        .unwrap();
    assert_eq!(f.evidence.len(), 2);
}

#[test]
fn multiple_cves() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("title")
        .cve("CVE-2024-0001")
        .cve("CVE-2024-0002")
        .cve("CVE-2024-0003")
        .build()
        .unwrap();
    assert_eq!(f.cve_ids.len(), 3);
    assert_eq!(f.cve_ids[0], "CVE-2024-0001");
    assert_eq!(f.cve_ids[1], "CVE-2024-0002");
    assert_eq!(f.cve_ids[2], "CVE-2024-0003");
}

#[test]
fn new_fields_and_builder_methods() {
    let loc = Location::new("src/main.rs")
        .unwrap()
        .line(10)
        .unwrap()
        .column(5)
        .unwrap();
    let f = Finding::builder("scanner", "target", Severity::Medium)
        .title("title")
        .status(FindingStatus::Confirmed)
        .location(loc.clone())
        .cvss_score(7.5)
        .scan_id("scan-123")
        .add_tags(vec!["tag1", "tag2"])
        .add_cves(vec!["CVE-2024-1111", "CVE-2024-2222"])
        .build()
        .unwrap();

    assert_eq!(f.status, FindingStatus::Confirmed);
    assert_eq!(f.location, Some(loc));
    assert_eq!(f.cvss_score, Some(7.5));
    assert_eq!(f.scan_id, Some("scan-123".to_string()));
    assert!(f.tags.contains(&"tag1".to_string()));
    assert!(f.tags.contains(&"tag2".to_string()));
    assert_eq!(f.cve_ids.len(), 2);
}

#[test]
fn hash_implementation_with_floats() {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    fn calculate_hash<T: Hash>(t: &T) -> u64 {
        let mut s = DefaultHasher::new();
        t.hash(&mut s);
        s.finish()
    }

    let f1 = Finding::builder("s", "t", Severity::Info)
        .title("t")
        .confidence(0.5)
        .cvss_score(5.0)
        .build()
        .unwrap();

    let f2 = Finding::builder("s", "t", Severity::Info)
        .title("t")
        .confidence(0.5)
        .cvss_score(5.1) // different
        .build()
        .unwrap();

    // We can't guarantee hashes are different but they SHOULD be for different CVSS.
    // Also ensure it DOES hash (no panic).
    let h1 = calculate_hash(&f1);
    let h2 = calculate_hash(&f2);
    assert_ne!(h1, h2);
}

#[test]
fn display_output_contains_new_fields() {
    let loc = Location::new("lib.rs").unwrap().line(42).unwrap();
    let f = Finding::builder("scanner", "target.com", Severity::Critical)
        .title("VULN")
        .status(FindingStatus::Resolved)
        .location(loc)
        .build()
        .unwrap();

    let s = f.to_string();
    assert!(s.contains("[CRIT]"));
    assert!(s.contains("[FIXD]"));
    assert!(s.contains("at lib.rs:42"));
}