secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! SARIF output format tests with spec validation.

mod common;

use common::{
    assert_sarif_result, assert_sarif_results_match_rules, assert_sarif_rule,
    assert_sarif_run, assert_sarif_schema, finding, finding_full, render_sarif,
};
use secreport::Format;
use secfinding::{Evidence, Finding, Reportable, Severity};

#[test]
fn sarif_empty_findings_structure() {
    let value = render_sarif(&[]);
    assert_sarif_schema(&value);
    let run = &value["runs"][0];
    assert_sarif_run(run, "test-tool");
    let results = run["results"].as_array().unwrap();
    assert!(results.is_empty());
    let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
    assert!(rules.is_empty());
}

#[test]
fn sarif_single_finding_full_structure() {
    let f = finding_full("RCE", Severity::Critical);
    let value = render_sarif(&[f]);
    assert_sarif_schema(&value);
    let run = &value["runs"][0];
    assert_sarif_run(run, "test-tool");
    let rules = run["tool"]["driver"]["rules"].as_array().unwrap();
    assert_eq!(rules.len(), 1);
    assert_sarif_rule(&rules[0]);
    let results = run["results"].as_array().unwrap();
    assert_eq!(results.len(), 1);
    assert_sarif_result(&results[0]);
    assert_eq!(results[0]["level"], "error");
}

#[test]
fn sarif_multiple_findings_deduplicate_rules() {
    let findings = vec![
        finding("SameRule", Severity::High),
        finding("SameRule", Severity::High),
    ];
    let value = render_sarif(&findings);
    let rules = value["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .unwrap();
    let results = value["runs"][0]["results"].as_array().unwrap();
    assert_eq!(rules.len(), 1, "duplicate rules should be deduplicated");
    assert_eq!(results.len(), 2);
}

#[test]
fn sarif_different_rules_preserved() {
    let findings = vec![
        finding("RuleA", Severity::High),
        finding("RuleB", Severity::Medium),
        finding("RuleC", Severity::Low),
    ];
    let value = render_sarif(&findings);
    let rules = value["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .unwrap();
    assert_eq!(rules.len(), 3);
}

#[test]
fn sarif_results_match_rules() {
    let findings = vec![
        finding("A", Severity::High),
        finding("B", Severity::Medium),
        finding("A", Severity::High),
    ];
    let value = render_sarif(&findings);
    assert_sarif_results_match_rules(&value);
}

#[test]
fn sarif_rule_properties_populated() {
    let f = finding_full("Props", Severity::High);
    let value = render_sarif(&[f]);
    let rule = &value["runs"][0]["tool"]["driver"]["rules"][0];
    let props = &rule["properties"];
    assert_eq!(props["severity"], "high");
    assert_eq!(props["category"], "Vulnerability");
    assert_eq!(props["precision"], "high");
    assert!(props["tags"].is_array());
    assert!(props["cwe"].is_array());
    assert!(props["cve"].is_array());
}

#[test]
fn sarif_result_properties_populated() {
    let f = finding_full("ResultProps", Severity::Medium);
    let value = render_sarif(&[f]);
    let result = &value["runs"][0]["results"][0];
    let props = &result["properties"];
    assert_eq!(props["severity"], "medium");
    assert_eq!(props["kind"], "Vulnerability");
    assert_eq!(props["confidence"], 0.95);
    assert_eq!(props["exploit_hint"], "curl -X GET https://example.com/poc");
    assert!(props["evidence"].is_array());
    assert!(props["cwe_ids"].is_array());
    assert!(props["cve_ids"].is_array());
}

#[test]
fn sarif_locations_structure() {
    let f = finding("Loc", Severity::High);
    let value = render_sarif(&[f]);
    let locs = value["runs"][0]["results"][0]["locations"].as_array().unwrap();
    assert!(!locs.is_empty());
    assert!(locs[0]["physicalLocation"]["artifactLocation"]["uri"].is_string());
}

#[test]
fn sarif_codeflows_structure() {
    let f = finding("CF", Severity::High);
    let value = render_sarif(&[f]);
    let code_flows = value["runs"][0]["results"][0]["codeFlows"]
        .as_array()
        .unwrap();
    assert_eq!(code_flows.len(), 1);
    let thread_flows = code_flows[0]["threadFlows"].as_array().unwrap();
    assert_eq!(thread_flows.len(), 1);
    let locations = thread_flows[0]["locations"].as_array().unwrap();
    assert_eq!(locations.len(), 1);
    assert!(
        locations[0]["location"]["physicalLocation"]["artifactLocation"]["uri"].is_string()
    );
}

#[test]
fn sarif_fingerprint_is_stable() {
    let f = finding("Stable", Severity::High);
    let v1 = render_sarif(&[f.clone()]);
    let v2 = render_sarif(&[f]);
    let fp1 = v1["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
        .as_str()
        .unwrap();
    let fp2 = v2["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
        .as_str()
        .unwrap();
    assert_eq!(fp1, fp2);
}

#[test]
fn sarif_fingerprint_changes_with_content() {
    let f1 = Finding::new("s", "t", Severity::High, "T", "D1").unwrap();
    let f2 = Finding::new("s", "t", Severity::High, "T", "D2").unwrap();
    let v1 = render_sarif(&[f1]);
    let v2 = render_sarif(&[f2]);
    let fp1 = v1["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
        .as_str()
        .unwrap();
    let fp2 = v2["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
        .as_str()
        .unwrap();
    assert_ne!(fp1, fp2);
}

#[test]
fn sarif_fingerprint_is_16_hex_chars() {
    let f = finding("Hex", Severity::High);
    let value = render_sarif(&[f]);
    let fp = value["runs"][0]["results"][0]["partialFingerprints"]
        ["primaryLocationLineHash"]
        .as_str()
        .unwrap();
    assert_eq!(fp.len(), 16);
    assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
}

#[test]
fn sarif_levels_match_severity() {
    let pairs = vec![
        (Severity::Critical, "error"),
        (Severity::High, "error"),
        (Severity::Medium, "warning"),
        (Severity::Low, "note"),
        (Severity::Info, "note"),
    ];
    for (sev, expected_level) in pairs {
        let f = finding("L", sev);
        let value = render_sarif(&[f]);
        assert_eq!(
            value["runs"][0]["results"][0]["level"], expected_level,
            "severity {:?} should map to SARIF level {:?}",
            sev, expected_level
        );
    }
}

#[test]
fn sarif_empty_rule_id_fallback() {
    struct Custom {
        scanner: String,
        target: String,
        title: String,
    }
    impl Reportable for Custom {
        fn scanner(&self) -> &str {
            &self.scanner
        }
        fn target(&self) -> &str {
            &self.target
        }
        fn severity(&self) -> Severity {
            Severity::High
        }
        fn title(&self) -> &str {
            &self.title
        }
        fn rule_id(&self) -> String {
            String::new()
        }
    }
    let findings = vec![Custom {
        scanner: "fallback-scanner".into(),
        target: "https://target".into(),
        title: "Fallback Title".into(),
    }];
    let out = secreport::render_any(&findings, Format::Sarif, "tool").unwrap();
    let value: serde_json::Value = serde_json::from_str(&out).unwrap();
    let rule = &value["runs"][0]["tool"]["driver"]["rules"][0];
    assert_eq!(rule["id"], "fallback-scanner/fallback-title");
}

#[test]
fn sarif_unicode_in_fields() {
    let f = Finding::new("スキャナ", "https://例え.jp", Severity::High, "日本語", "詳細").unwrap();
    let out = secreport::render(&[f], Format::Sarif, "tool").unwrap();
    let value: serde_json::Value = serde_json::from_str(&out).unwrap();
    assert_eq!(
        value["runs"][0]["results"][0]["message"]["text"], "日本語\n詳細"
    );
}

#[test]
fn sarif_special_characters_in_rule_ids() {
    struct Custom {
        scanner: String,
        target: String,
        title: String,
    }
    impl Reportable for Custom {
        fn scanner(&self) -> &str {
            &self.scanner
        }
        fn target(&self) -> &str {
            &self.target
        }
        fn severity(&self) -> Severity {
            Severity::Critical
        }
        fn title(&self) -> &str {
            &self.title
        }
    }
    let findings = vec![
        Custom {
            scanner: "scan<ner>".into(),
            target: "target".into(),
            title: "SQL Injection <script>alert(1)</script>".into(),
        },
        Custom {
            scanner: "scan\"ner\"".into(),
            target: "target".into(),
            title: "RCE $(whoami) `rm -rf /`".into(),
        },
    ];
    let out = secreport::render_any(&findings, Format::Sarif, "tool").unwrap();
    let value: serde_json::Value = serde_json::from_str(&out).unwrap();
    let results = value["runs"][0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 2);
    for result in results {
        assert_sarif_result(result);
    }
}

#[test]
fn sarif_large_number_of_results() {
    let findings: Vec<Finding> = (0..10_000)
        .map(|i| finding(&format!("Finding-{}", i), Severity::Info))
        .collect();
    let value = render_sarif(&findings);
    let results = value["runs"][0]["results"].as_array().unwrap();
    assert_eq!(results.len(), 10_000);
}

#[test]
fn sarif_evidence_strings_in_properties() {
    let f = Finding::builder("s", "t", Severity::High)
        .title("T")
        .evidence(Evidence::http_status(403).unwrap())
        .build()
        .unwrap();
    let value = render_sarif(&[f]);
    let ev = value["runs"][0]["results"][0]["properties"]["evidence"]
        .as_array()
        .unwrap();
    assert!(!ev.is_empty());
    assert!(ev[0].is_string());
}

#[test]
fn sarif_tool_name_reflected() {
    let f = finding("T", Severity::High);
    let value = render_sarif(&[f]);
    assert_eq!(value["runs"][0]["tool"]["driver"]["name"], "test-tool");
}