secreport 0.3.0

Output formatters for security findings — JSON, JSONL, SARIF, Markdown, Text
Documentation
//! Serialization roundtrip tests.

mod common;

use common::{finding, finding_full};
use secreport::Format;
use secfinding::{Evidence, Finding, FindingKind, Severity};

#[test]
fn format_serde_roundtrip() {
    for format in [
        Format::Text,
        Format::Json,
        Format::Jsonl,
        Format::Sarif,
        Format::Markdown,
    ] {
        let json = serde_json::to_string(&format).unwrap();
        let back: Format = serde_json::from_str(&json).unwrap();
        assert_eq!(back, format, "serde roundtrip failed for {:?}", format);
    }
}

#[test]
fn format_serde_deserialize_from_strings() {
    let pairs = [
        ("\"Text\"", Format::Text),
        ("\"Json\"", Format::Json),
        ("\"Jsonl\"", Format::Jsonl),
        ("\"Sarif\"", Format::Sarif),
        ("\"Markdown\"", Format::Markdown),
    ];
    for (json, expected) in pairs {
        let back: Format = serde_json::from_str(json).unwrap();
        assert_eq!(back, expected);
    }
}

#[test]
fn json_roundtrip_parsed_values() {
    let f = finding_full("RT", Severity::Critical);
    let out = secreport::render(&[f], Format::Json, "tool").unwrap();
    let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
    let json = serde_json::to_string(&parsed).unwrap();
    let reparsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed, reparsed);
}

#[test]
fn jsonl_roundtrip_parsed_values() {
    let findings = vec![
        finding("A", Severity::High),
        finding("B", Severity::Medium),
        finding("C", Severity::Low),
    ];
    let out = secreport::render(&findings, Format::Jsonl, "tool").unwrap();
    let parsed: Vec<serde_json::Value> = out
        .lines()
        .map(|line| serde_json::from_str(line).unwrap())
        .collect();
    let jsonl = parsed
        .iter()
        .map(|v| serde_json::to_string(v).unwrap())
        .collect::<Vec<_>>()
        .join("\n");
    let reparsed: Vec<serde_json::Value> = jsonl
        .lines()
        .map(|line| serde_json::from_str(line).unwrap())
        .collect();
    assert_eq!(parsed, reparsed);
}

#[test]
fn sarif_roundtrip_parsed_value() {
    let f = finding_full("SARIF-RT", Severity::High);
    let out = secreport::render(&[f], Format::Sarif, "tool").unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
    let json = serde_json::to_string_pretty(&parsed).unwrap();
    let reparsed: serde_json::Value = serde_json::from_str(&json).unwrap();
    assert_eq!(parsed, reparsed);
}

#[test]
fn markdown_output_is_deterministic_for_same_input() {
    let f = finding_full("MD", Severity::Medium);
    let out1 = secreport::render(&[f.clone()], Format::Markdown, "tool").unwrap();
    let out2 = secreport::render(&[f], Format::Markdown, "tool").unwrap();
    assert_eq!(out1, out2);
}

#[test]
fn text_output_is_deterministic_for_same_input() {
    let f = finding_full("TXT", Severity::Medium);
    let out1 = secreport::render(&[f.clone()], Format::Text, "tool").unwrap();
    let out2 = secreport::render(&[f], Format::Text, "tool").unwrap();
    assert_eq!(out1, out2);
}

#[test]
fn json_output_is_deterministic_for_same_input() {
    let f = finding_full("JSON", Severity::Medium);
    let out1 = secreport::render(&[f.clone()], Format::Json, "tool").unwrap();
    let out2 = secreport::render(&[f], Format::Json, "tool").unwrap();
    assert_eq!(out1, out2);
}

#[test]
fn jsonl_output_is_deterministic_for_same_input() {
    let f = finding_full("JSONL", Severity::Medium);
    let out1 = secreport::render(&[f.clone()], Format::Jsonl, "tool").unwrap();
    let out2 = secreport::render(&[f], Format::Jsonl, "tool").unwrap();
    assert_eq!(out1, out2);
}

#[test]
fn sarif_output_is_deterministic_for_same_input() {
    let f = finding_full("SARIF", Severity::Medium);
    let out1 = secreport::render(&[f.clone()], Format::Sarif, "tool").unwrap();
    let out2 = secreport::render(&[f], Format::Sarif, "tool").unwrap();
    assert_eq!(out1, out2);
}

#[test]
fn evidence_serde_roundtrip() {
    let samples = vec![
        Evidence::http_status(200).unwrap(),
        Evidence::DnsRecord {
            record_type: "A".into(),
            value: "1.2.3.4".into(),
        },
        Evidence::Banner {
            raw: "banner".into(),
        },
        Evidence::JsSnippet {
            url: "https://example.com/app.js".into(),
            line: 42,
            snippet: "eval(...)".into(),
        },
        Evidence::Certificate {
            subject: "CN=example".into(),
            san: vec!["DNS:example.com".into()],
            issuer: "Let's Encrypt".into(),
            expires: "2028-01-01".into(),
        },
        Evidence::code("src/main.rs", 10, "unsafe {}", Some(5), Some("rust".into())).unwrap(),
        Evidence::HttpRequest {
            method: "GET".into(),
            url: "https://example.com".into(),
            headers: vec![("Host".into(), "example.com".into())],
            body: Some("body".into()),
        },
        Evidence::PatternMatch {
            pattern: "p".into(),
            matched: "m".into(),
        },
    ];
    for sample in samples {
        let json = serde_json::to_string(&sample).unwrap();
        let back: Evidence = serde_json::from_str(&json).unwrap();
        assert_eq!(sample, back);
    }
}

#[test]
fn finding_serde_roundtrip() {
    let f = finding_full("Roundtrip", Severity::High);
    let json = serde_json::to_string(&f).unwrap();
    let back: Finding = serde_json::from_str(&json).unwrap();
    assert_eq!(f.scanner, back.scanner);
    assert_eq!(f.target, back.target);
    assert_eq!(f.severity, back.severity);
    assert_eq!(f.title, back.title);
    assert_eq!(f.detail, back.detail);
    assert_eq!(f.kind, back.kind);
    assert_eq!(f.tags, back.tags);
    assert_eq!(f.cve_ids, back.cve_ids);
    assert_eq!(f.cwe_ids, back.cwe_ids);
    assert_eq!(f.confidence, back.confidence);
    assert_eq!(f.cvss_score, back.cvss_score);
    assert_eq!(f.exploit_hint, back.exploit_hint);
    assert_eq!(f.remediation, back.remediation);
    assert_eq!(f.matched_values, back.matched_values);
    assert_eq!(f.evidence.len(), back.evidence.len());
}

#[test]
fn generic_finding_json_value_roundtrip() {
    let evidence = vec![Evidence::http_status(403).unwrap()];
    let cwe = vec![std::sync::Arc::<str>::from("CWE-79")];
    let cve = vec![std::sync::Arc::<str>::from("CVE-2024-1")];
    let tags = vec![std::sync::Arc::<str>::from("tag1")];
    let gf = secreport::models::GenericFinding::builder("s", "t", Severity::Critical)
        .title("T")
        .detail("D")
        .cwe_ids(&cwe)
        .cve_ids(&cve)
        .tags(&tags)
        .confidence(Some(0.99))
        .rule_id("R")
        .sarif_level("error")
        .exploit_hint(Some("hint"))
        .evidence(&evidence)
        .kind(FindingKind::Vulnerability)
        .build();
    let v = gf.json_value();
    let json = serde_json::to_string(&v).unwrap();
    let back: serde_json::Value = serde_json::from_str(&json).unwrap();
    assert_eq!(back["scanner"], "s");
    assert_eq!(back["severity"], "critical");
    assert_eq!(back["confidence"], 0.99);
    assert_eq!(back["kind"], "Vulnerability");
    assert_eq!(back["exploit_hint"], "hint");
    assert_eq!(back["rule_id"], "R");
}