parlov 0.8.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Scan pipeline: automated elicitation across all applicable strategies.
//!
//! Phase 1 drives `parlov_elicit::generate_plan` with a `ScanContext` built from `ScanArgs`,
//! dispatches each `ProbeSpec`, and collects findings. Baseline response headers from phase 1
//! pairs are threaded up through `run_plan_specs` as classified exchanges — a
//! `Vec<(ResponseClass, HeaderMap, Bytes)>` — without issuing any additional network requests.
//! Phase 2 walks the producer/consumer DAG via `generate_dag_chained_plan` using those
//! exchanges and the `default_chain_registry`. Phase 2 is skipped when the C9 rate-limit
//! gate fires (429/503 with `Retry-After` or `RateLimit-Remaining: 0`). All findings are
//! merged and rendered once.

use parlov_analysis::StopRule;
use parlov_core::{EndpointVerdict, Error};
use parlov_elicit::{default_chain_registry, generate_dag_chained_plan};
use parlov_output::{
    render_endpoint_verdict_json, render_endpoint_verdict_sarif, render_endpoint_verdict_table,
    ScanFinding,
};
use parlov_probe::http::HttpProbe;

use crate::cli::{OutputFormat, ScanArgs};
use crate::pipeline_state::ScanPipelineState;
use crate::scan_context::build_scan_context;
use crate::scan_exec::{rate_limit_gate, run_plan_specs, RunOpts};
use crate::strategy_filter::apply_strategy_filters;
use crate::vector_filter::{apply_vector_filters, parse_vector_flag, VectorFilter};
use crate::verdict_builder::build_endpoint_verdict;

/// Logit of 0.80: the confirm threshold (≈ 1.3862944).
pub(crate) const CONFIRM_LOG_ODDS_THRESHOLD: f64 = 1.386_294_361_119_890_6_f64;

/// Runs the automated elicitation scan pipeline for the given CLI arguments.
///
/// Phase 1 executes the standard filtered plan and collects classified exchanges.
/// Phase 2 walks the producer/consumer DAG to run chained probes, unless the C9
/// rate-limit gate fires or (outside exhaustive mode) the stop rule triggered.
/// All findings are merged and rendered once. Strategy errors are logged and skipped.
pub async fn run(args: ScanArgs, format: OutputFormat) -> Result<(), Error> {
    let vector_filters = parse_vector_flags(&args.vectors)?;
    validate_vector_risk_exclusivity(&args, &vector_filters)?;
    validate_strategy_exclusivity(&args, &vector_filters)?;

    let ctx = build_scan_context(&args, &vector_filters)?;
    let plan = build_filtered_plan(&ctx, &vector_filters, &args.strategies);

    let total_specs = plan.len();
    let mut state = ScanPipelineState::new(total_specs);
    let stop_rule = StopRule::new();
    let probe = HttpProbe::new();
    tracing::debug!(strategies_total = state.strategies_total, "starting scan");

    let opts = RunOpts {
        exhaustive: args.exhaustive,
        confirm_threshold: CONFIRM_LOG_ODDS_THRESHOLD,
        repro: args.repro,
        verbose: args.verbose,
    };
    let exchanges = run_plan_specs(&plan, &args.target, &mut state, &stop_rule, &probe, opts).await;

    // Phase 2: chained probes — skipped if stop rule fired (non-exhaustive),
    // or if the C9 rate-limit gate signals budget exhaustion.
    let phase2_allowed =
        (state.stop_decision.is_none() || args.exhaustive) && !rate_limit_gate(&exchanges);
    if phase2_allowed {
        let registry = default_chain_registry();
        let chained = generate_dag_chained_plan(&ctx, &exchanges, &registry);
        state.strategies_total += chained.len();
        run_plan_specs(&chained, &args.target, &mut state, &stop_rule, &probe, opts).await;
    }

    let verdict = build_endpoint_verdict(&state);
    let findings: Vec<ScanFinding> = state.findings_only().cloned().collect();
    let output = format_scan_results(format, &args.target, &verdict, &findings)?;
    println!("{output}");
    Ok(())
}

fn format_scan_results(
    format: OutputFormat,
    target: &str,
    verdict: &EndpointVerdict,
    findings: &[ScanFinding],
) -> Result<String, Error> {
    match format {
        OutputFormat::Table => Ok(render_endpoint_verdict_table(verdict, findings)),
        OutputFormat::Json => {
            render_endpoint_verdict_json(target, verdict, findings).map_err(Error::Serialization)
        }
        OutputFormat::Sarif => {
            render_endpoint_verdict_sarif(target, verdict, findings).map_err(Error::Serialization)
        }
    }
}

/// Parses all `--vector` flag values into `VectorFilter`s.
fn parse_vector_flags(raw: &[String]) -> Result<Vec<VectorFilter>, Error> {
    raw.iter().map(|s| parse_vector_flag(s)).collect()
}

/// Returns an error when both `--vector` and a non-default `--risk` are provided.
fn validate_vector_risk_exclusivity(
    args: &ScanArgs,
    filters: &[VectorFilter],
) -> Result<(), Error> {
    if !filters.is_empty() && args.risk != "safe" {
        return Err(Error::Cli(
            "--vector and --risk are mutually exclusive".to_owned(),
        ));
    }
    Ok(())
}

/// Returns an error when `--strategy` is combined with `--risk` or `--vector`.
///
/// `--strategy` + non-default `--risk` is rejected because strategy filtering
/// already pins the exact spec; layering a risk ceiling is ambiguous.
/// `--strategy` + `--vector` is rejected for the same reason.
fn validate_strategy_exclusivity(args: &ScanArgs, filters: &[VectorFilter]) -> Result<(), Error> {
    if args.strategies.is_empty() {
        return Ok(());
    }
    if args.risk != "safe" {
        return Err(Error::Cli(
            "--strategy and --risk are mutually exclusive".to_owned(),
        ));
    }
    if !filters.is_empty() {
        return Err(Error::Cli(
            "--strategy and --vector are mutually exclusive".to_owned(),
        ));
    }
    Ok(())
}

/// Generates a plan and applies per-vector and per-strategy filtering.
///
/// Strategy ID filtering takes precedence over vector filtering when both are
/// present (though that combination is rejected earlier by validation). An
/// empty `strategy_ids` list means no strategy filtering is applied.
fn build_filtered_plan(
    ctx: &parlov_elicit::ScanContext,
    filters: &[VectorFilter],
    strategy_ids: &[String],
) -> Vec<parlov_elicit::ProbeSpec> {
    let plan = parlov_elicit::generate_plan(ctx);
    if !strategy_ids.is_empty() {
        return apply_strategy_filters(plan, strategy_ids);
    }
    if filters.is_empty() {
        plan
    } else {
        apply_vector_filters(plan, filters)
    }
}

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