parlov-output 0.4.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! SARIF v2.1.0 output rendering for parlov oracle results.
//!
//! Uses `serde_json::json!` rather than `serde-sarif`'s typed builder API because
//! `serde-sarif`'s `PropertyBag::additional_properties` only accepts `BTreeMap<String, String>`,
//! which cannot represent the `Vec<String>` evidence list or the `Vec<DiffedHeader>` fields
//! we need in `properties`. Building the document directly with `json!` avoids that impedance
//! mismatch while still producing spec-compliant SARIF v2.1.0.

use parlov_core::{OracleClass, OracleResult, OracleVerdict};
use serde_json::{json, Map, Value};

use crate::ScanFinding;

// ── rule ID / name helpers ───────────────────────────────────────────────────

/// Returns the stable SARIF rule ID for an oracle class.
fn oracle_rule_id(class: OracleClass) -> &'static str {
    match class {
        OracleClass::Existence => "PAR001",
        _ => "PAR000",
    }
}

/// Returns the SARIF rule name for an oracle class.
fn oracle_rule_name(class: OracleClass) -> &'static str {
    match class {
        OracleClass::Existence => "ExistenceOracle",
        _ => "UnknownOracle",
    }
}

/// Returns the short description text for a rule.
fn oracle_rule_description(class: OracleClass) -> &'static str {
    match class {
        OracleClass::Existence => "Resource existence leaked via HTTP differential",
        _ => "Unknown oracle class",
    }
}

/// Maps a verdict to the SARIF `level` string.
///
/// `NotPresent` is never emitted as a result; callers must filter before calling this.
fn verdict_level(verdict: OracleVerdict) -> &'static str {
    match verdict {
        OracleVerdict::Confirmed => "error",
        OracleVerdict::Likely => "warning",
        OracleVerdict::Inconclusive | OracleVerdict::NotPresent => "note",
    }
}

// ── shared document builders ─────────────────────────────────────────────────

/// Builds the `tool.driver.rules` array from a deduplicated set of oracle classes.
fn build_rules(classes: &[OracleClass]) -> Value {
    let rules: Vec<Value> = classes
        .iter()
        .copied()
        .collect::<std::collections::HashSet<_>>()
        .into_iter()
        .map(|c| {
            json!({
                "id": oracle_rule_id(c),
                "name": oracle_rule_name(c),
                "shortDescription": { "text": oracle_rule_description(c) }
            })
        })
        .collect();
    json!(rules)
}

/// Builds the `properties` map for a SARIF result entry.
///
/// `extras` is merged in after the core fields, allowing scan-specific properties
/// (e.g. `method`, `strategy_id`) to be added without a second mutable borrow.
fn build_properties(result: &OracleResult, extras: &Map<String, Value>) -> Map<String, Value> {
    let mut props: Map<String, Value> = Map::new();
    props.insert("evidence".to_owned(), json!(result.evidence));

    if let Some(s) = result.severity {
        props.insert("severity".to_owned(), json!(format!("{s:?}")));
    }
    if let Some(label) = &result.label {
        props.insert("label".to_owned(), json!(label));
    }
    if let Some(rfc) = &result.rfc_basis {
        props.insert("rfc_basis".to_owned(), json!(rfc));
    }
    if let Some(bs) = &result.baseline_summary {
        props.insert("baseline_status".to_owned(), json!(bs.status));
    }
    if let Some(ps) = &result.probe_summary {
        props.insert("probe_status".to_owned(), json!(ps.status));
    }
    if !result.header_diffs.is_empty() {
        props.insert("header_diffs".to_owned(), json!(result.header_diffs));
    }
    for (k, v) in extras {
        props.insert(k.clone(), v.clone());
    }
    props
}

/// Builds a SARIF `result` object from an `OracleResult`, target URL, and extra properties.
///
/// Returns `None` when the verdict is `NotPresent` — SARIF conventionally contains
/// only actual findings.
fn build_result(
    target_url: &str,
    result: &OracleResult,
    extras: &Map<String, Value>,
) -> Option<Value> {
    if result.verdict == OracleVerdict::NotPresent {
        return None;
    }

    let message_text = result
        .leaks
        .as_deref()
        .map_or_else(|| result.evidence.join("; "), str::to_owned);

    let props = build_properties(result, extras);

    Some(json!({
        "ruleId": oracle_rule_id(result.class),
        "level": verdict_level(result.verdict),
        "message": { "text": message_text },
        "locations": [{
            "physicalLocation": {
                "artifactLocation": { "uri": target_url }
            }
        }],
        "properties": Value::Object(props)
    }))
}

/// Assembles the complete SARIF v2.1.0 document from a rules list and results array.
fn build_sarif_document(rules: &Value, results: &[Value]) -> Value {
    json!({
        "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": {
                "driver": {
                    "name": "parlov",
                    "version": env!("CARGO_PKG_VERSION"),
                    "rules": rules
                }
            },
            "results": results
        }]
    })
}

// ── public API ───────────────────────────────────────────────────────────────

/// Renders a single `OracleResult` as a SARIF v2.1.0 JSON string.
///
/// `NotPresent` verdicts produce an empty `results` array — SARIF conventionally
/// contains only actual findings.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails, which cannot occur for
/// well-formed inputs in practice.
pub fn render_sarif(target_url: &str, result: &OracleResult) -> Result<String, serde_json::Error> {
    let rules = build_rules(&[result.class]);
    let results: Vec<Value> =
        build_result(target_url, result, &Map::new()).into_iter().collect();
    serde_json::to_string_pretty(&build_sarif_document(&rules, &results))
}

/// Renders a slice of `ScanFinding`s as a SARIF v2.1.0 JSON string.
///
/// `NotPresent` findings are excluded — SARIF conventionally contains only actual
/// findings. Populates `baseline_status`, `probe_status`, and `header_diffs` in
/// `properties` when present on the result.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails, which cannot occur for
/// well-formed inputs in practice.
pub fn render_scan_sarif(findings: &[ScanFinding]) -> Result<String, serde_json::Error> {
    let classes: Vec<OracleClass> = findings.iter().map(|f| f.result.class).collect();
    let rules = build_rules(&classes);

    let results: Vec<Value> = findings
        .iter()
        .filter(|f| f.result.verdict != OracleVerdict::NotPresent)
        .filter_map(|f| {
            let mut extras: Map<String, Value> = Map::new();
            extras.insert("method".to_owned(), json!(f.method));
            extras.insert("strategy_id".to_owned(), json!(f.strategy_id));
            build_result(&f.target_url, &f.result, &extras)
        })
        .collect();

    serde_json::to_string_pretty(&build_sarif_document(&rules, &results))
}

// ── tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use parlov_core::{DiffedHeader, OracleClass, OracleResult, OracleVerdict, ResponseSummary, Severity};

    use crate::ScanFinding;

    fn confirmed_result() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::Confirmed,
            evidence: vec!["403 (baseline) vs 404 (probe)".into()],
            severity: Some(Severity::High),
            label: Some("Authorization-based differential".into()),
            leaks: Some("Resource existence confirmed".into()),
            rfc_basis: Some("RFC 9110 §15.5.4".into()),
            baseline_summary: None,
            probe_summary: None,
            header_diffs: vec![],
        }
    }

    fn not_present_result() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            evidence: vec!["404 (baseline) vs 404 (probe)".into()],
            severity: None,
            label: None,
            leaks: None,
            rfc_basis: None,
            baseline_summary: None,
            probe_summary: None,
            header_diffs: vec![],
        }
    }

    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,
                evidence: vec!["403 (baseline) vs 404 (probe)".into()],
                severity,
                label: None,
                leaks: None,
                rfc_basis: None,
                baseline_summary: None,
                probe_summary: None,
                header_diffs: vec![],
            },
        }
    }

    #[test]
    fn render_sarif_confirmed_produces_error_level() {
        let json = render_sarif("https://api.example.com/users/1", &confirmed_result())
            .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 render_sarif_not_present_produces_empty_results() {
        let json = render_sarif("https://api.example.com/users/1", &not_present_result())
            .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 array").is_empty());
    }

    #[test]
    fn render_sarif_message_uses_leaks_when_present() {
        let json = render_sarif("https://api.example.com/users/1", &confirmed_result())
            .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 render_sarif_message_falls_back_to_evidence() {
        let mut result = confirmed_result();
        result.leaks = None;
        let json =
            render_sarif("https://api.example.com/users/1", &result).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 render_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(&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 array");
        assert_eq!(results.len(), 1);
    }

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

    #[test]
    fn render_scan_sarif_includes_baseline_and_probe_status() {
        let mut finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
        finding.result.baseline_summary = Some(ResponseSummary { status: 403 });
        finding.result.probe_summary = Some(ResponseSummary { status: 404 });
        let json = render_scan_sarif(&[finding]).expect("render failed");
        let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
        let props = &v["runs"][0]["results"][0]["properties"];
        assert_eq!(props["baseline_status"], 403);
        assert_eq!(props["probe_status"], 404);
    }

    #[test]
    fn render_scan_sarif_omits_empty_header_diffs() {
        let finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
        let json = render_scan_sarif(&[finding]).expect("render failed");
        let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
        let props = &v["runs"][0]["results"][0]["properties"];
        assert!(props.get("header_diffs").is_none());
    }

    #[test]
    fn render_scan_sarif_includes_header_diffs_when_present() {
        let mut finding = scan_finding(OracleVerdict::Confirmed, Some(Severity::High));
        finding.result.header_diffs = vec![DiffedHeader {
            name: "x-rate-limit-remaining".into(),
            baseline: Some("100".into()),
            probe: None,
        }];
        let json = render_scan_sarif(&[finding]).expect("render failed");
        let v: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
        let diffs = &v["runs"][0]["results"][0]["properties"]["header_diffs"];
        assert_eq!(diffs[0]["name"], "x-rate-limit-remaining");
    }
}