parlov-output 0.6.0

Output formatters for parlov: SARIF, terminal table, and raw JSON.
Documentation
//! Structured JSON output for single and multi-finding results.
//!
//! Defines serialization-only DTOs (`SingleFindingOutput`, `ScanOutput`) that match the
//! parlov v1.0.0 JSON schema. The render functions accept domain types and assemble
//! the nested JSON structure with deterministic `finding_id` fingerprints.

use parlov_core::{
    finding_id, ImpactClass, NormativeStrength, OracleResult, ScoringReason, Signal, Vector,
};
use serde::Serialize;

use crate::ScanFinding;

/// Schema version stamped on every JSON output document.
const SCHEMA_VERSION: &str = "1.0.0";

// ── output DTOs ─────────────────────────────────────────────────────────────

/// Top-level JSON envelope for a single finding (the `existence` command).
#[derive(Serialize)]
pub(crate) struct SingleFindingOutput<'a> {
    schema_version: &'static str,
    target_url: &'a str,
    finding: FindingOutput,
}

/// Top-level JSON envelope for multiple findings (the `scan` command).
#[derive(Serialize)]
pub(crate) struct ScanOutput<'a> {
    schema_version: &'static str,
    target_url: &'a str,
    findings: Vec<FindingOutput>,
}

/// One finding within either envelope.
#[derive(Serialize)]
struct FindingOutput {
    finding_id: String,
    strategy: StrategyOutput,
    result: ResultOutput,
    technique: TechniqueOutput,
    matched_pattern: MatchedPatternOutput,
    evidence: EvidenceOutput,
}

/// Strategy that generated the probe pair.
#[derive(Serialize)]
struct StrategyOutput {
    id: String,
    name: String,
    method: String,
}

/// Classification result: verdict, confidence, severity, impact.
#[derive(Serialize)]
struct ResultOutput {
    oracle_class: String,
    verdict: String,
    confidence: u8,
    severity: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    impact_class: Option<String>,
}

/// Technique metadata from the probe definition.
#[derive(Serialize)]
struct TechniqueOutput {
    id: String,
    vector: String,
    normative_strength: String,
}

/// Matched pattern context from the analyzer.
#[derive(Serialize)]
struct MatchedPatternOutput {
    #[serde(skip_serializing_if = "Option::is_none")]
    label: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    leaks: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    rfc_basis: Option<String>,
}

/// Evidence block: scoring reasons and typed signals.
#[derive(Serialize)]
struct EvidenceOutput {
    reasons: Vec<ScoringReason>,
    signals: Vec<Signal>,
}

// ── public render functions ─────────────────────────────────────────────────

/// Renders a single `OracleResult` as the v1.0.0 nested JSON schema.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails (cannot occur for well-formed inputs).
pub fn render_json(
    target_url: &str,
    result: &OracleResult,
    strategy_id: &str,
    strategy_name: &str,
    method: &str,
) -> Result<String, serde_json::Error> {
    let finding = build_finding(target_url, result, strategy_id, strategy_name, method);
    let output = SingleFindingOutput {
        schema_version: SCHEMA_VERSION,
        target_url,
        finding,
    };
    serde_json::to_string_pretty(&output)
}

/// Renders a slice of scan findings as the v1.0.0 nested JSON schema.
///
/// The `target_url` is extracted from the first finding. All findings share the same
/// root-level target URL.
///
/// # Errors
///
/// Returns `Err` if `serde_json` serialization fails (cannot occur for well-formed inputs).
pub fn render_scan_json(
    target_url: &str,
    findings: &[ScanFinding],
) -> Result<String, serde_json::Error> {
    let items: Vec<FindingOutput> = findings
        .iter()
        .map(|f| {
            build_finding(
                target_url,
                &f.result,
                &f.strategy_id,
                &f.strategy_name,
                &f.method,
            )
        })
        .collect();
    let output = ScanOutput {
        schema_version: SCHEMA_VERSION,
        target_url,
        findings: items,
    };
    serde_json::to_string_pretty(&output)
}

// ── builders ────────────────────────────────────────────────────────────────

/// Assembles a `FindingOutput` from domain types.
fn build_finding(
    target_url: &str,
    result: &OracleResult,
    strategy_id: &str,
    strategy_name: &str,
    method: &str,
) -> FindingOutput {
    let oracle_class = format!("{:?}", result.class);
    let technique_id = result.technique_id.as_deref().unwrap_or("unknown");
    let fid = finding_id(technique_id, target_url, &oracle_class, method, strategy_id);

    FindingOutput {
        finding_id: fid,
        strategy: StrategyOutput {
            id: strategy_id.to_owned(),
            name: strategy_name.to_owned(),
            method: method.to_owned(),
        },
        result: build_result_output(result, &oracle_class),
        technique: build_technique_output(result),
        matched_pattern: build_matched_pattern(result),
        evidence: EvidenceOutput {
            reasons: result.reasons.clone(),
            signals: result.signals.clone(),
        },
    }
}

fn build_result_output(result: &OracleResult, oracle_class: &str) -> ResultOutput {
    ResultOutput {
        oracle_class: oracle_class.to_owned(),
        verdict: format!("{:?}", result.verdict),
        confidence: result.confidence,
        severity: format_severity(result.severity.as_ref()),
        impact_class: result.impact_class.map(format_impact_class),
    }
}

fn build_technique_output(result: &OracleResult) -> TechniqueOutput {
    TechniqueOutput {
        id: result.technique_id.clone().unwrap_or_else(|| "unknown".to_owned()),
        vector: result.vector.map_or_else(|| "Unknown".to_owned(), format_vector),
        normative_strength: result
            .normative_strength
            .map_or_else(|| "Unknown".to_owned(), format_normative_strength),
    }
}

fn build_matched_pattern(result: &OracleResult) -> MatchedPatternOutput {
    MatchedPatternOutput {
        label: result.label.clone(),
        leaks: result.leaks.clone(),
        rfc_basis: result.rfc_basis.clone(),
    }
}

// ── formatting helpers ──────────────────────────────────────────────────────

fn format_severity(severity: Option<&parlov_core::Severity>) -> String {
    match severity {
        Some(s) => format!("{s:?}"),
        None => "None".to_owned(),
    }
}

fn format_impact_class(ic: ImpactClass) -> String {
    format!("{ic:?}")
}

fn format_vector(v: Vector) -> String {
    format!("{v:?}")
}

fn format_normative_strength(ns: NormativeStrength) -> String {
    format!("{ns:?}")
}

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

    fn confirmed() -> 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("test-tech".into()),
            vector: Some(Vector::StatusCodeDiff),
            normative_strength: Some(NormativeStrength::Should),
            label: Some("Auth differential".into()),
            leaks: Some("Resource existence".into()),
            rfc_basis: Some("RFC 9110".into()),
        }
    }

    fn not_present() -> OracleResult {
        OracleResult {
            class: OracleClass::Existence,
            verdict: OracleVerdict::NotPresent,
            severity: None,
            confidence: 0,
            impact_class: None,
            reasons: vec![],
            signals: vec![],
            technique_id: None,
            vector: None,
            normative_strength: None,
            label: None,
            leaks: None,
            rfc_basis: None,
        }
    }

    #[test]
    fn single_json_has_schema_version_and_target() {
        let json = render_json("https://x.com/api", &confirmed(), "s1", "n1", "GET")
            .expect("serialization");
        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
        assert_eq!(v["schema_version"], "1.0.0");
        assert_eq!(v["target_url"], "https://x.com/api");
    }

    #[test]
    fn single_json_has_finding_id_12_hex() {
        let json = render_json("https://x.com", &confirmed(), "s1", "n1", "GET")
            .expect("serialization");
        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
        let fid = v["finding"]["finding_id"].as_str().expect("string");
        assert_eq!(fid.len(), 12);
        assert!(fid.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn single_json_omits_none_matched_pattern_fields() {
        let json = render_json("https://x.com", &not_present(), "s1", "n1", "GET")
            .expect("serialization");
        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
        let mp = &v["finding"]["matched_pattern"];
        assert!(mp.get("label").is_none());
        assert!(mp.get("leaks").is_none());
    }

    #[test]
    fn scan_json_uses_findings_array() {
        let findings = vec![ScanFinding {
            target_url: "https://x.com".to_owned(),
            strategy_id: "s1".to_owned(),
            strategy_name: "n1".to_owned(),
            method: "GET".to_owned(),
            result: confirmed(),
        }];
        let json = render_scan_json("https://x.com", &findings).expect("serialization");
        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
        assert!(v["findings"].is_array());
        assert_eq!(v["findings"].as_array().expect("array").len(), 1);
    }

    #[test]
    fn scan_json_empty_findings_produces_empty_array() {
        let json = render_scan_json("https://x.com", &[]).expect("serialization");
        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
        assert!(v["findings"].as_array().expect("array").is_empty());
    }
}