secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! Shared test helpers for secreport integration tests.

use secreport::Format;
use secfinding::{Evidence, Finding, FindingKind, Severity};
use std::collections::HashSet;

/// Create a minimal valid finding.
pub fn finding(title: &str, severity: Severity) -> Finding {
    Finding::new("test-scanner", "https://example.com", severity, title, "detail text").unwrap()
}

/// Create a finding with full fields populated.
pub fn finding_full(title: &str, severity: Severity) -> Finding {
    Finding::builder("test-scanner", "https://example.com", severity)
        .title(title)
        .detail("Detailed description of the issue.")
        .kind(FindingKind::Vulnerability)
        .tag("web")
        .tag("xss")
        .cwe("CWE-79")
        .cve("CVE-2024-12345")
        .reference("https://example.com/ref")
        .confidence(0.95)
        .cvss_score(7.5)
        .exploit_hint("curl -X GET https://example.com/poc")
        .remediation("Sanitize user input.")
        .matched_value("<script>alert(1)</script>")
        .evidence(Evidence::http_status(200).unwrap())
        .evidence(Evidence::Banner {
            raw: "Server: nginx".into(),
        })
        .build()
        .unwrap()
}

/// Assert that the rendered output for every format is non-empty (or empty for jsonl when 0 findings).
pub fn assert_all_formats_produce_output(findings: &[Finding]) {
    for format in [
        Format::Text,
        Format::Json,
        Format::Jsonl,
        Format::Sarif,
        Format::Markdown,
    ] {
        let out = secreport::render(findings, format, "test-tool").unwrap();
        if findings.is_empty() && format == Format::Jsonl {
            assert_eq!(out, "", "jsonl with 0 findings should be empty");
        } else {
            assert!(
                !out.is_empty(),
                "format {:?} produced empty output for {} findings",
                format,
                findings.len()
            );
        }
    }
}

/// Validate SARIF top-level structure.
pub fn assert_sarif_schema(value: &serde_json::Value) {
    assert_eq!(
        value["$schema"],
        "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
    );
    assert_eq!(value["version"], "2.1.0");
    let runs = value["runs"].as_array().expect("runs must be array");
    assert_eq!(runs.len(), 1, "SARIF must contain exactly one run");
}

/// Validate SARIF run structure.
pub fn assert_sarif_run(run: &serde_json::Value, expected_tool_name: &str) {
    let tool = &run["tool"]["driver"];
    assert_eq!(tool["name"], expected_tool_name);
    assert!(tool["rules"].is_array(), "tool.driver.rules must be array");
    assert!(run["results"].is_array(), "run.results must be array");
}

/// Validate SARIF rule structure.
pub fn assert_sarif_rule(rule: &serde_json::Value) {
    assert!(rule["id"].is_string(), "rule.id must be string");
    assert!(rule["name"].is_string(), "rule.name must be string");
    assert!(
        rule["shortDescription"]["text"].is_string(),
        "rule.shortDescription.text must be string"
    );
    assert!(
        rule["fullDescription"]["text"].is_string(),
        "rule.fullDescription.text must be string"
    );
    assert!(rule["help"]["text"].is_string(), "rule.help.text must be string");
    assert!(
        rule["help"]["markdown"].is_string(),
        "rule.help.markdown must be string"
    );
    assert!(rule["properties"].is_object(), "rule.properties must be object");
}

/// Validate SARIF result structure.
pub fn assert_sarif_result(result: &serde_json::Value) {
    assert!(result["ruleId"].is_string(), "result.ruleId must be string");
    assert!(result["level"].is_string(), "result.level must be string");
    assert!(
        result["message"]["text"].is_string(),
        "result.message.text must be string"
    );
    assert!(result["locations"].is_array(), "result.locations must be array");
    assert!(
        result["partialFingerprints"].is_object(),
        "result.partialFingerprints must be object"
    );
    assert!(
        result["partialFingerprints"]["primaryLocationLineHash"]
            .as_str()
            .is_some(),
        "result.partialFingerprints.primaryLocationLineHash must be string"
    );
    assert!(result["codeFlows"].is_array(), "result.codeFlows must be array");
    assert!(result["properties"].is_object(), "result.properties must be object");
}

/// Validate that every result has a matching rule in the rules array.
pub fn assert_sarif_results_match_rules(value: &serde_json::Value) {
    let rules = value["runs"][0]["tool"]["driver"]["rules"]
        .as_array()
        .unwrap();
    let results = value["runs"][0]["results"].as_array().unwrap();
    let rule_ids: HashSet<String> = rules
        .iter()
        .filter_map(|r| r["id"].as_str().map(String::from))
        .collect();
    for result in results {
        let rid = result["ruleId"].as_str().unwrap_or("");
        if !rid.is_empty() {
            assert!(
                rule_ids.contains(rid),
                "result ruleId {:?} not found in rules array",
                rid
            );
        }
    }
}

/// Render findings to SARIF and return parsed JSON.
pub fn render_sarif(findings: &[Finding]) -> serde_json::Value {
    let out = secreport::render(findings, Format::Sarif, "test-tool").unwrap();
    serde_json::from_str(&out).expect("SARIF output must be valid JSON")
}

/// Render findings to JSON and return parsed JSON array.
pub fn render_json(findings: &[Finding]) -> Vec<serde_json::Value> {
    let out = secreport::render(findings, Format::Json, "test-tool").unwrap();
    serde_json::from_str(&out).expect("JSON output must be valid array")
}

/// Render findings to JSONL and return parsed lines.
pub fn render_jsonl(findings: &[Finding]) -> Vec<serde_json::Value> {
    let out = secreport::render(findings, Format::Jsonl, "test-tool").unwrap();
    out.lines()
        .filter(|l| !l.is_empty())
        .map(|line| serde_json::from_str(line).expect("JSONL line must be valid JSON"))
        .collect()
}