parlov 0.8.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
use super::*;
use parlov_probe::http::HttpProbe;

fn make_no_signal_finding(strategy_id: &str) -> (ScanFinding, StrategyOutcome) {
    use parlov_core::{NormativeStrength, OracleClass, OracleResult, OracleVerdict, Vector};
    let result = OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::NotPresent,
        severity: None,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: Some(NormativeStrength::Must),
        label: None,
        leaks: None,
        rfc_basis: None,
    };
    let finding = ScanFinding {
        target_url: "https://example.com/{id}".to_owned(),
        strategy_id: strategy_id.to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        method: "GET".to_owned(),
        result: result.clone(),
        repro: None,
        probe: parlov_output::ProbeContext {
            baseline_url: "https://example.com/1".to_owned(),
            probe_url: "https://example.com/9999".to_owned(),
            method: "GET".to_owned(),
            headers: None,
        },
        exchange: parlov_output::ExchangeContext {
            baseline_status: 200,
            probe_status: 404,
            headers: None,
            body_samples: None,
        },
        chain_provenance: None,
    };
    (finding, StrategyOutcome::NoSignal(result))
}

fn make_positive_finding(strategy_id: &str, confidence: u8) -> (ScanFinding, StrategyOutcome) {
    use parlov_core::{
        NormativeStrength, OracleClass, OracleResult, OracleVerdict, Severity, Vector,
    };
    let result = OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: Some(Severity::High),
        confidence,
        impact_class: None,
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: Some(NormativeStrength::Must),
        label: None,
        leaks: None,
        rfc_basis: None,
    };
    let finding = ScanFinding {
        target_url: "https://example.com/{id}".to_owned(),
        strategy_id: strategy_id.to_owned(),
        strategy_name: "Test Strategy".to_owned(),
        method: "GET".to_owned(),
        result: result.clone(),
        repro: None,
        probe: parlov_output::ProbeContext {
            baseline_url: "https://example.com/1".to_owned(),
            probe_url: "https://example.com/9999".to_owned(),
            method: "GET".to_owned(),
            headers: None,
        },
        exchange: parlov_output::ExchangeContext {
            baseline_status: 200,
            probe_status: 404,
            headers: None,
            body_samples: None,
        },
        chain_provenance: None,
    };
    (finding, StrategyOutcome::Positive(result))
}

#[test]
fn build_endpoint_verdict_all_no_signal_is_inconclusive() {
    use crate::pipeline_state::ScanPipelineState;
    use parlov_core::OracleVerdict;

    let mut state = ScanPipelineState::new(2);
    state.findings.push(make_no_signal_finding("a"));
    state.findings.push(make_no_signal_finding("b"));
    state.strategies_run = 2;

    let verdict = build_endpoint_verdict(&state);
    // All NoSignal -> posterior stays at 0.5 -> Inconclusive (0.20 < 0.5 < 0.60)
    assert_eq!(verdict.verdict, OracleVerdict::Inconclusive);
    assert!(
        (verdict.posterior_probability - 0.5).abs() < 1e-9,
        "expected ~0.5, got {}",
        verdict.posterior_probability
    );
}

#[test]
fn build_endpoint_verdict_positive_finding_raises_posterior() {
    use crate::pipeline_state::ScanPipelineState;
    use parlov_core::OracleVerdict;

    let mut state = ScanPipelineState::new(1);
    let (finding, outcome) = make_positive_finding("a", 85);
    state
        .accumulator
        .ingest(&outcome, parlov_core::Vector::StatusCodeDiff);
    state.findings.push((finding, outcome));
    state.strategies_run = 1;

    let verdict = build_endpoint_verdict(&state);
    assert_ne!(verdict.verdict, OracleVerdict::NotPresent);
    assert!(verdict.posterior_probability > 0.5);
}

#[test]
fn build_endpoint_verdict_stop_reason_exhausted_when_none() {
    use crate::pipeline_state::ScanPipelineState;
    use parlov_core::EndpointStopReason;

    let state = ScanPipelineState::new(0);
    // stop_decision is None -> ExhaustedPlan
    let verdict = build_endpoint_verdict(&state);
    assert!(
        matches!(verdict.stop_reason, Some(EndpointStopReason::ExhaustedPlan)),
        "expected ExhaustedPlan, got {:?}",
        verdict.stop_reason
    );
}

#[test]
fn build_endpoint_verdict_stop_reason_early_accept() {
    use crate::pipeline_state::ScanPipelineState;
    use parlov_analysis::StopDecision;

    let mut state = ScanPipelineState::new(10);
    state.stop_decision = Some(StopDecision::EarlyAccept { posterior: 0.92 });
    let verdict = build_endpoint_verdict(&state);
    assert!(
        matches!(verdict.stop_reason, Some(EndpointStopReason::EarlyAccept)),
        "expected EarlyAccept, got {:?}",
        verdict.stop_reason
    );
}

#[test]
fn exhaustive_flag_defaults_to_false() {
    let args = minimal_args("https://api.example.com/users/{id}", "1001");
    assert!(!args.exhaustive);
}

#[test]
fn pipeline_state_first_threshold_crossed_by_starts_none() {
    let state = ScanPipelineState::new(5);
    assert!(state.first_threshold_crossed_by.is_none());
}

#[test]
fn build_endpoint_verdict_first_threshold_crossed_by_none_when_not_set() {
    let state = ScanPipelineState::new(2);
    let verdict = build_endpoint_verdict(&state);
    assert!(verdict.first_threshold_crossed_by.is_none());
}

#[test]
fn build_endpoint_verdict_first_threshold_crossed_by_propagates() {
    let mut state = ScanPipelineState::new(2);
    state.first_threshold_crossed_by = Some("rd-percent-encoding".to_owned());
    let verdict = build_endpoint_verdict(&state);
    assert_eq!(
        verdict.first_threshold_crossed_by,
        Some("rd-percent-encoding".to_owned())
    );
}

#[tokio::test]
async fn stop_decision_none_when_exhaustive() {
    let mut state = ScanPipelineState::new(0);
    let stop_rule = StopRule::new();
    let probe = HttpProbe::new();
    run_plan_specs(
        &[],
        "https://example.com/{id}",
        &mut state,
        &stop_rule,
        &probe,
        crate::scan_exec::RunOpts {
            exhaustive: true,
            confirm_threshold: CONFIRM_LOG_ODDS_THRESHOLD,
            repro: false,
            verbose: false,
        },
    )
    .await;
    assert!(state.stop_decision.is_none());
    assert!(state.first_threshold_crossed_by.is_none());
}

// Fix #8: when exhaustive=false, first_threshold_crossed_by must remain None even when
// log-odds have crossed CONFIRM_LOG_ODDS_THRESHOLD. The guard `if exhaustive && ...` is
// the only write site; verifying state after crossing the threshold with exhaustive=false
// proves the guard is correctly gated.
#[tokio::test]
async fn first_threshold_crossed_by_stays_none_when_not_exhaustive() {
    use parlov_core::{NormativeStrength, OracleClass, OracleResult, OracleVerdict, Vector};

    // Ingest enough positive outcomes to push log_odds above CONFIRM_LOG_ODDS_THRESHOLD.
    let mut state = ScanPipelineState::new(0);
    let result = OracleResult {
        class: OracleClass::Existence,
        verdict: OracleVerdict::Confirmed,
        severity: None,
        confidence: 99,
        impact_class: None,
        reasons: vec![],
        signals: vec![],
        technique_id: None,
        vector: Some(Vector::StatusCodeDiff),
        normative_strength: Some(NormativeStrength::Must),
        label: None,
        leaks: None,
        rfc_basis: None,
    };
    let outcome = parlov_core::StrategyOutcome::Positive(result);
    // Two high-confidence positives from different families exceed the threshold.
    state.accumulator.ingest(&outcome, Vector::StatusCodeDiff);
    state.accumulator.ingest(&outcome, Vector::CacheProbing);
    assert!(
        state.accumulator.log_odds_current() >= CONFIRM_LOG_ODDS_THRESHOLD,
        "precondition: log_odds must be >= threshold before running empty plan"
    );

    // Run an empty plan with exhaustive=false -- the loop body never executes, so the
    // guard cannot fire. first_confirm_at must remain None.
    let stop_rule = StopRule::new();
    let probe = HttpProbe::new();
    run_plan_specs(
        &[],
        "https://example.com/{id}",
        &mut state,
        &stop_rule,
        &probe,
        crate::scan_exec::RunOpts {
            exhaustive: false,
            confirm_threshold: CONFIRM_LOG_ODDS_THRESHOLD,
            repro: false,
            verbose: false,
        },
    )
    .await;

    assert!(
        state.first_threshold_crossed_by.is_none(),
        "first_threshold_crossed_by must stay None when exhaustive=false, got {:?}",
        state.first_threshold_crossed_by
    );
}