parlov-output 0.8.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
use super::*;
use parlov_core::{OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};

use crate::context::test_helpers::{exchange_ctx, probe_ctx};
use crate::ScanFinding;

fn confirmed_result() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        confidence: 85,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence: "403 (baseline) vs 404 (probe)".into(),
            rfc_basis: None,
        }],
        technique_id: Some("get-403-404".into()),
        vector: Some(parlov_core::Vector::StatusCodeDiff),
        normative_strength: Some(parlov_core::NormativeStrength::Should),
        label: Some("Authorization-based differential".into()),
        leaks: Some("Resource existence confirmed".into()),
        rfc_basis: Some("RFC 9110 \u{00a7}15.5.4".into()),
    }
}

fn not_present_result() -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence: "404 (baseline) vs 404 (probe)".into(),
            rfc_basis: None,
        }],
        technique_id: Some("get-404-404".into()),
        vector: Some(parlov_core::Vector::StatusCodeDiff),
        normative_strength: None,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

fn scan_finding(verdict: OracleVerdict, severity: Option<Severity>) -> ScanFinding {
    ScanFinding {
        target_url: "https://api.example.com/users/1".to_owned(),
        strategy_id: "existence-get-200-404".to_owned(),
        strategy_name: "GET 200/404 existence".to_owned(),
        method: "GET".to_owned(),
        result: OracleResult {
            class: OracleClass::Existence,
            verdict,
            severity,
            confidence: 85,
            impact_class: None,
            reasons: vec![],
            signals: vec![Signal {
                kind: SignalKind::StatusCodeDiff,
                evidence: "403 (baseline) vs 404 (probe)".into(),
                rfc_basis: None,
            }],
            technique_id: Some("get-200-404".into()),
            vector: Some(parlov_core::Vector::StatusCodeDiff),
            normative_strength: None,
            label: None,
            leaks: None,
            rfc_basis: None,
        },
        repro: None,
        probe: probe_ctx(
            "GET",
            "https://api.example.com/users/1",
            "https://api.example.com/users/9999",
        ),
        exchange: exchange_ctx(403, 404),
        chain_provenance: None,
    }
}

#[test]
fn sarif_confirmed_produces_error_level() {
    let r = confirmed_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(v["runs"][0]["results"][0]["level"], "error");
}

#[test]
fn sarif_not_present_produces_empty_results() {
    let r = not_present_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert!(v["runs"][0]["results"]
        .as_array()
        .expect("results")
        .is_empty());
}

#[test]
fn sarif_rule_id_is_technique_id() {
    let r = confirmed_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(v["runs"][0]["results"][0]["ruleId"], "get-403-404");
    assert_eq!(
        v["runs"][0]["tool"]["driver"]["rules"][0]["id"],
        "get-403-404"
    );
}

#[test]
fn sarif_has_fingerprints() {
    let r = confirmed_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    let fp = &v["runs"][0]["results"][0]["fingerprints"]["oracleFingerprint/v1"];
    assert_eq!(fp.as_str().expect("string").len(), 12);
}

#[test]
fn sarif_has_related_locations_for_signals() {
    let r = confirmed_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    let rl = v["runs"][0]["results"][0]["relatedLocations"]
        .as_array()
        .expect("array");
    assert_eq!(rl.len(), 1);
    assert!(rl[0]["message"]["text"]
        .as_str()
        .expect("text")
        .contains("StatusCodeDiff"));
}

#[test]
fn sarif_message_uses_leaks_when_present() {
    let r = confirmed_result();
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(
        v["runs"][0]["results"][0]["message"]["text"],
        "Resource existence confirmed"
    );
}

#[test]
fn sarif_message_falls_back_to_primary_evidence() {
    let mut r = confirmed_result();
    r.leaks = None;
    let json =
        render_sarif("https://api.example.com/users/1", &r, "s1", "GET").expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(
        v["runs"][0]["results"][0]["message"]["text"],
        "403 (baseline) vs 404 (probe)"
    );
}

#[test]
fn scan_sarif_filters_not_present() {
    let findings = vec![
        scan_finding(OracleVerdict::Confirmed, Some(Severity::High)),
        scan_finding(OracleVerdict::NotPresent, None),
    ];
    let json =
        render_scan_sarif("https://api.example.com/users/1", &findings).expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    let results = v["runs"][0]["results"].as_array().expect("results");
    assert_eq!(results.len(), 1);
}

#[test]
fn scan_sarif_has_run_properties() {
    let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
    let json =
        render_scan_sarif("https://api.example.com/users/1", &findings).expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(
        v["runs"][0]["properties"]["target_url"],
        "https://api.example.com/users/1"
    );
}

#[test]
fn scan_sarif_valid_json_with_version() {
    let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
    let json =
        render_scan_sarif("https://api.example.com/users/1", &findings).expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(v["version"], "2.1.0");
}

// --- render_endpoint_verdict_sarif tests ---

fn confirmed_endpoint_verdict() -> parlov_core::EndpointVerdict {
    use parlov_core::{
        EndpointStopReason, EndpointVerdict, ObservabilityStatus, OracleClass, OracleVerdict,
        Severity,
    };
    EndpointVerdict {
        oracle_class: OracleClass::Existence,
        posterior_probability: 0.93,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        strategies_run: 12,
        strategies_total: 26,
        stop_reason: Some(EndpointStopReason::EarlyAccept),
        first_threshold_crossed_by: None,
        final_confirming_strategy: None,
        contributing_findings: vec![],
        observability_status: ObservabilityStatus::EvidenceObserved,
        block_summary: None,
    }
}

#[test]
fn endpoint_sarif_has_endpoint_verdict_in_run_properties() {
    let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
    let verdict = confirmed_endpoint_verdict();
    let json =
        render_endpoint_verdict_sarif("https://api.example.com/users/1", &verdict, &findings)
            .expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(v["runs"][0]["properties"]["endpoint_verdict"], "Confirmed");
}

#[test]
fn endpoint_sarif_has_posterior_probability_in_run_properties() {
    let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
    let verdict = confirmed_endpoint_verdict();
    let json =
        render_endpoint_verdict_sarif("https://api.example.com/users/1", &verdict, &findings)
            .expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    let p = v["runs"][0]["properties"]["posterior_probability"]
        .as_f64()
        .expect("f64");
    assert!((p - 0.93).abs() < 1e-9);
}

#[test]
fn endpoint_sarif_has_strategies_run_and_total_in_run_properties() {
    let findings = vec![scan_finding(OracleVerdict::Confirmed, Some(Severity::High))];
    let verdict = confirmed_endpoint_verdict();
    let json =
        render_endpoint_verdict_sarif("https://api.example.com/users/1", &verdict, &findings)
            .expect("render failed");
    let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
    assert_eq!(v["runs"][0]["properties"]["strategies_run"], 12);
    assert_eq!(v["runs"][0]["properties"]["strategies_total"], 26);
}