parlov 0.5.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Scan pipeline: automated elicitation across all applicable strategies.
//!
//! Drives `parlov_elicit::generate_plan` with a `ScanContext` built from `ScanArgs`,
//! dispatches each `ProbeSpec` to the appropriate runner, collects findings, and
//! renders a summary table to stdout.

use http::HeaderMap;
use parlov_core::{Error, OracleClass, OracleResult, OracleVerdict, Severity, Signal, SignalKind};
use parlov_elicit::{BurstSpec, ProbePair, ProbeSpec, StrategyMetadata};
use parlov_output::{render_scan_json, render_scan_sarif, render_scan_table, ScanFinding};
use parlov_probe::http::HttpProbe;
use parlov_probe::Probe;
use tracing::warn;

use crate::cli::{OutputFormat, ScanArgs};
use crate::existence::collect_with_technique;
use crate::scan_context::build_scan_context;
use crate::vector_filter::{apply_vector_filters, parse_vector_flag, VectorFilter};

/// Runs the automated elicitation scan pipeline for the given CLI arguments.
///
/// Builds a `ScanContext`, generates a probe plan, executes each spec, and
/// prints output in the format specified by `format`. Strategy errors are logged
/// and skipped; the scan does not abort on a single failure.
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)?;

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

    let mut findings: Vec<ScanFinding> = Vec::new();
    for spec in &plan {
        match dispatch_spec(&args.target, spec).await {
            Ok(finding) => findings.push(finding),
            Err(e) => warn!("strategy failed, skipping: {e}"),
        }
    }

    let output = format_scan_results(format, &args.target, &findings)?;
    println!("{output}");
    Ok(())
}

/// Renders a `ScanFinding` slice in the requested output format.
fn format_scan_results(
    format: OutputFormat,
    target: &str,
    findings: &[ScanFinding],
) -> Result<String, Error> {
    match format {
        OutputFormat::Table => Ok(render_scan_table(findings)),
        OutputFormat::Json => {
            render_scan_json(target, findings).map_err(Error::Serialization)
        }
        OutputFormat::Sarif => {
            render_scan_sarif(target, findings).map_err(Error::Serialization)
        }
    }
}

/// Dispatches a `ProbeSpec` to the appropriate runner and returns a `ScanFinding`.
async fn dispatch_spec(target: &str, spec: &ProbeSpec) -> Result<ScanFinding, Error> {
    match spec {
        ProbeSpec::Pair(pair) => run_pair(target, pair).await,
        ProbeSpec::Burst(burst) => run_burst(target, burst).await,
        ProbeSpec::HeaderDiff(pair) => run_header_diff(target, pair).await,
    }
}

/// Runs a standard adaptive pair loop and returns a `ScanFinding`.
async fn run_pair(target: &str, pair: &ProbePair) -> Result<ScanFinding, Error> {
    let result =
        collect_with_technique(&pair.baseline, &pair.probe, pair.technique.clone()).await?;
    let method = pair.baseline.method.as_str().to_owned();
    Ok(make_finding(target, &pair.metadata, &method, result))
}

/// Sends `burst_count` requests to each side and checks for 429 differentials.
async fn run_burst(target: &str, spec: &BurstSpec) -> Result<ScanFinding, Error> {
    let client = HttpProbe::new();
    let (baseline_429, probe_429) = collect_429_counts(&client, spec).await?;
    let result = burst_result(baseline_429, probe_429, &spec.technique);
    let method = spec.baseline.method.as_str().to_owned();
    Ok(make_finding(target, &spec.metadata, &method, result))
}

/// Collects 429 response counts for both baseline and probe sides.
async fn collect_429_counts(
    client: &HttpProbe,
    spec: &BurstSpec,
) -> Result<(usize, usize), Error> {
    let mut baseline_429 = 0usize;
    let mut probe_429 = 0usize;
    for _ in 0..spec.burst_count {
        let exchange = client.execute(&spec.baseline).await?;
        if exchange.response.status.as_u16() == 429 {
            baseline_429 += 1;
        }
    }
    for _ in 0..spec.burst_count {
        let exchange = client.execute(&spec.probe).await?;
        if exchange.response.status.as_u16() == 429 {
            probe_429 += 1;
        }
    }
    Ok((baseline_429, probe_429))
}

/// Builds an `OracleResult` from burst 429 counts.
fn burst_result(
    baseline_429: usize,
    probe_429: usize,
    technique: &parlov_core::Technique,
) -> OracleResult {
    let evidence = format!("baseline 429 count: {baseline_429}, probe 429 count: {probe_429}");
    let (verdict, severity) = if baseline_429 > 0 && probe_429 == 0 {
        (OracleVerdict::Confirmed, Some(Severity::Medium))
    } else {
        (OracleVerdict::NotPresent, None)
    };
    technique_result(verdict, severity, evidence, technique)
}

/// Sends one request per side and compares rate-limit header presence.
async fn run_header_diff(target: &str, pair: &ProbePair) -> Result<ScanFinding, Error> {
    let client = HttpProbe::new();
    let (b_exchange, p_exchange) =
        tokio::try_join!(client.execute(&pair.baseline), client.execute(&pair.probe))?;

    let diff_headers =
        rate_limit_diff(&b_exchange.response.headers, &p_exchange.response.headers);
    let result = header_diff_result(&diff_headers, &pair.technique);
    let method = pair.baseline.method.as_str().to_owned();
    Ok(make_finding(target, &pair.metadata, &method, result))
}

/// Returns names of rate-limit headers present in `baseline` but absent in `probe`.
fn rate_limit_diff(baseline: &HeaderMap, probe: &HeaderMap) -> Vec<String> {
    baseline
        .keys()
        .filter(|k| is_rate_limit_header(k.as_str()) && !probe.contains_key(*k))
        .map(|k| k.as_str().to_owned())
        .collect()
}

/// Checks whether a header name is a rate-limit-related header.
fn is_rate_limit_header(name: &str) -> bool {
    name.starts_with("ratelimit-")
        || name.starts_with("x-ratelimit-")
        || name == "ratelimit"
        || name == "x-ratelimit"
}

/// Builds an `OracleResult` from a list of differing rate-limit header names.
fn header_diff_result(diff: &[String], technique: &parlov_core::Technique) -> OracleResult {
    let (verdict, severity, evidence) = if diff.is_empty() {
        (OracleVerdict::NotPresent, None, "no rate-limit header differential".to_owned())
    } else {
        let ev = format!("rate-limit headers in baseline only: {}", diff.join(", "));
        (OracleVerdict::Confirmed, Some(Severity::Low), ev)
    };
    technique_result(verdict, severity, evidence, technique)
}

/// Builds an `OracleResult` with technique context and zero-initialized scoring fields.
fn technique_result(
    verdict: OracleVerdict,
    severity: Option<Severity>,
    evidence: String,
    technique: &parlov_core::Technique,
) -> OracleResult {
    OracleResult {
        class: OracleClass::Existence,
        verdict,
        severity,
        confidence: 0,
        impact_class: None,
        reasons: vec![],
        signals: vec![Signal {
            kind: SignalKind::StatusCodeDiff,
            evidence,
            rfc_basis: None,
        }],
        technique_id: Some(technique.id.to_string()),
        vector: Some(technique.vector),
        normative_strength: Some(technique.strength),
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

/// Assembles a `ScanFinding` from target URL, strategy metadata, method, and result.
fn make_finding(
    target: &str,
    meta: &StrategyMetadata,
    method: &str,
    result: OracleResult,
) -> ScanFinding {
    ScanFinding {
        target_url: target.to_owned(),
        strategy_id: meta.strategy_id.to_owned(),
        strategy_name: meta.strategy_name.to_owned(),
        method: method.to_owned(),
        result,
    }
}

/// 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::Http(
            "--vector and --risk are mutually exclusive".to_owned(),
        ));
    }
    Ok(())
}

/// Generates a plan and applies per-vector filtering when `--vector` flags are active.
fn build_filtered_plan(
    ctx: &parlov_elicit::ScanContext,
    filters: &[VectorFilter],
) -> Vec<ProbeSpec> {
    let plan = parlov_elicit::generate_plan(ctx);
    if filters.is_empty() {
        plan
    } else {
        apply_vector_filters(plan, filters)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use parlov_elicit::RiskLevel;

    fn minimal_args(target: &str, baseline_id: &str) -> ScanArgs {
        ScanArgs {
            target: target.to_owned(),
            baseline_id: baseline_id.to_owned(),
            probe_id: Some("9999".to_owned()),
            risk: "safe".to_owned(),
            headers: vec![],
            alt_credential: None,
            known_duplicate: None,
            state_field: None,
            vectors: vec![],
            body: None,
        }
    }

    #[test]
    fn vector_and_risk_mutually_exclusive() {
        let mut args = minimal_args("https://api.example.com/users/{id}", "1001");
        args.risk = "method-destructive".to_owned();
        args.vectors = vec!["cache-probing".to_owned()];
        let filters = parse_vector_flags(&args.vectors).unwrap();
        assert!(validate_vector_risk_exclusivity(&args, &filters).is_err());
    }

    #[test]
    fn vector_with_default_risk_is_not_exclusive() {
        let mut args = minimal_args("https://api.example.com/users/{id}", "1001");
        args.vectors = vec!["cache-probing".to_owned()];
        let filters = parse_vector_flags(&args.vectors).unwrap();
        assert!(validate_vector_risk_exclusivity(&args, &filters).is_ok());
    }

    #[test]
    fn build_scan_context_target_matches_and_risk_defaults_to_safe() {
        let args = minimal_args("https://api.example.com/users/{id}", "1001");
        let ctx = build_scan_context(&args, &[]).unwrap();
        assert_eq!(ctx.target, "https://api.example.com/users/{id}");
        assert_eq!(ctx.max_risk, RiskLevel::Safe);
    }
}