parlov 0.8.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Endpoint verdict construction from accumulated pipeline state.
//!
//! Per-strategy log-odds contributions come from the offline reducer's `reduce_with_attribution`,
//! which guarantees the per-event values sum to the same total log-odds as the accumulator's
//! posterior — no online replay logic.

use parlov_analysis::EvidenceAccumulator;
use parlov_core::{
    compute_observability, posterior_to_verdict, verdict_to_severity, BlockFamily,
    ContributingFinding, EndpointStopReason, EndpointVerdict, OracleClass, OracleVerdict,
    StrategyOutcome, StrategyOutcomeKind,
};
use parlov_output::ScanFinding;

use crate::pipeline_state::ScanPipelineState;
use crate::scan::CONFIRM_LOG_ODDS_THRESHOLD;

/// Builds an endpoint-level aggregated verdict from accumulated pipeline state.
///
/// Applies the coverage gate after `posterior_to_verdict`: a `NotPresent` raw verdict
/// downgrades to `Inconclusive` unless the accumulated events include enough independent
/// Contradictory firings — at least one of which is Strong-weighted — to claim the
/// endpoint has been meaningfully tested. The threshold rules in `posterior_to_verdict`
/// are unchanged; the gate is a separate post-processing step.
pub(crate) fn build_endpoint_verdict(state: &ScanPipelineState) -> EndpointVerdict {
    use parlov_analysis::StopDecision;

    let posterior = state.accumulator.posterior_probability();
    let raw_verdict = posterior_to_verdict(posterior);
    let verdict = if raw_verdict == OracleVerdict::NotPresent
        && !parlov_analysis::passes_not_present_gate(state.accumulator.events())
    {
        OracleVerdict::Inconclusive
    } else {
        raw_verdict
    };
    let severity = verdict_to_severity(verdict);

    let stop_reason = match &state.stop_decision {
        Some(StopDecision::EarlyAccept { .. }) => Some(EndpointStopReason::EarlyAccept),
        Some(StopDecision::EarlyReject { .. }) => Some(EndpointStopReason::EarlyReject),
        None | Some(StopDecision::Continue) => Some(EndpointStopReason::ExhaustedPlan),
    };

    let contributing_findings = build_contributing_findings(&state.accumulator, &state.findings);

    let oracle_class = state
        .findings
        .first()
        .map_or(OracleClass::Existence, |(f, _)| f.result.class);

    let (observability_status, block_summary) = compute_observability(&contributing_findings);

    let final_confirming_strategy =
        compute_final_confirming_strategy(&contributing_findings, CONFIRM_LOG_ODDS_THRESHOLD);

    EndpointVerdict {
        oracle_class,
        posterior_probability: posterior,
        verdict,
        severity,
        strategies_run: state.strategies_run,
        strategies_total: state.strategies_total,
        stop_reason,
        first_threshold_crossed_by: state.first_threshold_crossed_by.clone(),
        final_confirming_strategy,
        contributing_findings,
        observability_status,
        block_summary,
    }
}

/// First strategy in scan order where cumulative final-attributed log-odds crosses `threshold`.
///
/// Returns `None` when no prefix sum reaches the threshold. The returned strategy is
/// guaranteed to have `log_odds_contribution > 0.0` by construction — a zero or negative
/// contribution cannot be the first to push a running sum over a positive threshold unless
/// the sum was already at threshold from prior entries, in which case those entries would
/// have been selected first.
pub(crate) fn compute_final_confirming_strategy(
    findings: &[ContributingFinding],
    threshold: f64,
) -> Option<String> {
    debug_assert!(threshold > 0.0, "threshold must be positive");
    let mut running = 0.0_f64;
    findings.iter().find_map(|f| {
        running += f.log_odds_contribution;
        if running >= threshold {
            Some(f.strategy_id.clone())
        } else {
            None
        }
    })
}

/// Maps each finding to its per-event log-odds contribution from the offline reducer.
///
/// The reducer's `contributions` vector is parallel to the events the accumulator collected.
/// `scan_exec::ingest_outcome` pushes one event per `Positive` or `Contradictory` outcome and
/// pushes findings in the same order, so we walk the findings and pull the next attribution
/// for each event-producing outcome. `NoSignal` and `Inapplicable` findings carry `0.0`.
fn build_contributing_findings(
    accumulator: &EvidenceAccumulator,
    findings: &[(ScanFinding, StrategyOutcome)],
) -> Vec<ContributingFinding> {
    let attribution = accumulator.reduce_with_attribution();

    // Invariant: one event per Positive/Contradictory finding, none for NoSignal/Inapplicable.
    debug_assert_eq!(
        accumulator.event_count(),
        findings
            .iter()
            .filter(|(_, o)| matches!(
                o,
                StrategyOutcome::Positive(_) | StrategyOutcome::Contradictory(_, _)
            ))
            .count(),
        "accumulator events out of sync with findings"
    );

    let mut event_iter = attribution.contributions.into_iter();
    findings
        .iter()
        .map(|(finding, outcome)| build_finding(finding, outcome, &mut event_iter))
        .collect()
}

/// Builds a single `ContributingFinding`, consuming one attribution slot when the outcome
/// produced an event.
fn build_finding(
    finding: &ScanFinding,
    outcome: &StrategyOutcome,
    contributions: &mut impl Iterator<Item = f64>,
) -> ContributingFinding {
    let (outcome_kind, log_odds_contribution, block_family, block_reason) = match outcome {
        StrategyOutcome::Positive(_) => (
            StrategyOutcomeKind::Positive,
            contributions.next().unwrap_or(0.0),
            None,
            None,
        ),
        StrategyOutcome::Contradictory(_, _) => (
            StrategyOutcomeKind::Contradictory,
            contributions.next().unwrap_or(0.0),
            None,
            None,
        ),
        StrategyOutcome::NoSignal(_) => (StrategyOutcomeKind::NoSignal, 0.0, None, None),
        StrategyOutcome::Inapplicable(reason) => {
            let family = block_family_from_reason(reason);
            let reason_str = Some(reason.to_string());
            (StrategyOutcomeKind::Inapplicable, 0.0, family, reason_str)
        }
    };
    ContributingFinding {
        strategy_id: finding.strategy_id.clone(),
        strategy_name: finding.strategy_name.clone(),
        outcome_kind,
        log_odds_contribution,
        block_family,
        block_reason,
    }
}

/// Maps a `PreconditionBlock::as_str()` reason string to a `BlockFamily`.
///
/// Uses substring matching against the stable string literals produced by
/// `PreconditionBlock::as_str()` — avoids a direct dependency on `parlov-analysis`
/// from `parlov-core`.
fn block_family_from_reason(reason: &str) -> Option<BlockFamily> {
    if reason.contains("auth gate fired before technique")
        || reason.contains("auth gate fired before technique reached oracle layer")
        || reason.contains("proxy auth required")
        || reason.contains("network/captive-portal")
        || reason.contains("login-redirect fired")
    {
        return Some(BlockFamily::Authorization);
    }
    if reason.contains("method-level rejection") {
        return Some(BlockFamily::Method);
    }
    if reason.contains("parser/validator rejection") {
        return Some(BlockFamily::Parser);
    }
    if reason.contains("applicability marker not observed")
        || reason.contains("mutation broke baseline route")
    {
        return Some(BlockFamily::TechniqueLocal);
    }
    if reason.contains("surface this technique does not test") {
        return Some(BlockFamily::Surface);
    }
    None
}

#[cfg(test)]
#[path = "verdict_builder_tests.rs"]
mod tests;