parlov 0.3.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
//! 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};
use parlov_elicit::{
    BurstSpec, KnownDuplicate, ProbePair, ProbeSpec, RiskLevel, ScanContext, StateField,
    StrategyMetadata,
};
use parlov_output::{render_scan_table, ScanFinding};
use parlov_probe::http::HttpProbe;
use parlov_probe::Probe;
use tracing::warn;

use crate::cli::ScanArgs;
use crate::existence::collect_until_verdict;
use crate::util::parse_headers;

/// Runs the automated elicitation scan pipeline for the given CLI arguments.
///
/// Builds a `ScanContext`, generates a probe plan, executes each spec, and
/// prints a summary table. Strategy errors are logged and skipped; scan does
/// not abort on a single failure.
pub async fn run(args: ScanArgs) -> Result<(), Error> {
    let ctx = build_scan_context(&args)?;
    let plan = parlov_elicit::generate_plan(&ctx);

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

    println!("{}", render_scan_table(&findings));
    Ok(())
}

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

/// Runs a standard adaptive pair loop and returns a `ScanFinding`.
///
/// Delegates to `collect_until_verdict` from the existence pipeline.
async fn run_pair(pair: &ProbePair) -> Result<ScanFinding, Error> {
    let result = collect_until_verdict(&pair.baseline, &pair.probe).await?;
    let method = pair.baseline.method.as_str().to_owned();
    Ok(make_finding(&pair.metadata, &method, result))
}

/// Sends `burst_count` requests to each side and checks for 429 differentials.
///
/// Returns `Confirmed` when baseline received at least one 429 and probe received none.
async fn run_burst(spec: &BurstSpec) -> Result<ScanFinding, Error> {
    let client = HttpProbe::new();
    let mut baseline_429 = 0usize;
    let mut probe_429 = 0usize;

    for _ in 0..spec.burst_count {
        let surface = client.execute(&spec.baseline).await?;
        if surface.status.as_u16() == 429 {
            baseline_429 += 1;
        }
    }

    for _ in 0..spec.burst_count {
        let surface = client.execute(&spec.probe).await?;
        if surface.status.as_u16() == 429 {
            probe_429 += 1;
        }
    }

    let result = burst_result(baseline_429, probe_429);
    let method = spec.baseline.method.as_str().to_owned();
    Ok(make_finding(&spec.metadata, &method, result))
}

/// Builds an `OracleResult` from burst 429 counts.
fn burst_result(baseline_429: usize, probe_429: usize) -> 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)
    };
    OracleResult {
        class: OracleClass::Existence,
        verdict,
        evidence: vec![evidence],
        severity,
        label: None,
        leaks: None,
        rfc_basis: None,
    }
}

/// Sends one request per side and compares rate-limit header presence.
///
/// Returns `Confirmed` when `RateLimit-*` or `X-RateLimit-*` headers appear in
/// the baseline response but not the probe response.
async fn run_header_diff(pair: &ProbePair) -> Result<ScanFinding, Error> {
    let client = HttpProbe::new();
    let (b_surface, p_surface) =
        tokio::try_join!(client.execute(&pair.baseline), client.execute(&pair.probe))?;

    let diff_headers = rate_limit_diff(&b_surface.headers, &p_surface.headers);
    let result = header_diff_result(&diff_headers);
    let method = pair.baseline.method.as_str().to_owned();
    Ok(make_finding(&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| {
            let name = k.as_str();
            (name.starts_with("ratelimit-")
                || name.starts_with("x-ratelimit-")
                || name == "ratelimit"
                || name == "x-ratelimit")
                && !probe.contains_key(*k)
        })
        .map(|k| k.as_str().to_owned())
        .collect()
}

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

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

/// Parses a risk level string into a `RiskLevel`.
///
/// # Errors
///
/// Returns `Err` for any string other than `"safe"`, `"method-destructive"`,
/// or `"operation-destructive"`.
fn parse_risk(s: &str) -> Result<RiskLevel, Error> {
    match s {
        "safe" => Ok(RiskLevel::Safe),
        "method-destructive" => Ok(RiskLevel::MethodDestructive),
        "operation-destructive" => Ok(RiskLevel::OperationDestructive),
        other => Err(Error::Http(format!(
            "invalid risk level '{other}'; expected safe | method-destructive | operation-destructive"
        ))),
    }
}

/// Parses a `"field=value"` string into a `KnownDuplicate`.
///
/// Splits on the first `=` only; the value may itself contain `=`.
///
/// # Errors
///
/// Returns `Err` when the input contains no `=` character.
fn parse_known_duplicate(s: &str) -> Result<KnownDuplicate, Error> {
    let (field, value) = s.split_once('=').ok_or_else(|| {
        Error::Http(format!("known-duplicate must be 'field=value', got '{s}'"))
    })?;
    Ok(KnownDuplicate { field: field.to_owned(), value: value.to_owned() })
}

/// Parses a `"field=value"` string into a `StateField`.
///
/// Splits on the first `=` only; the value may itself contain `=`.
///
/// # Errors
///
/// Returns `Err` when the input contains no `=` character.
fn parse_state_field(s: &str) -> Result<StateField, Error> {
    let (field, value) = s.split_once('=').ok_or_else(|| {
        Error::Http(format!("state-field must be 'field=value', got '{s}'"))
    })?;
    Ok(StateField { field: field.to_owned(), value: value.to_owned() })
}

/// Parses an alt-credential header string `"Name: Value"` into a `HeaderMap`.
///
/// # Errors
///
/// Returns `Err` for malformed header strings.
fn parse_alt_credential(s: &str) -> Result<HeaderMap, Error> {
    parse_headers(&[s.to_owned()])
}

/// Constructs a `ScanContext` from CLI arguments.
///
/// Resolves a random UUID probe ID when none is provided, and parses all
/// optional fields, returning errors for malformed inputs.
fn build_scan_context(args: &ScanArgs) -> Result<ScanContext, Error> {
    let probe_id = args
        .probe_id
        .clone()
        .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());

    let max_risk = parse_risk(&args.risk)?;
    let headers = parse_headers(&args.headers)?;

    let known_duplicate = args
        .known_duplicate
        .as_deref()
        .map(parse_known_duplicate)
        .transpose()?;

    let state_field = args.state_field.as_deref().map(parse_state_field).transpose()?;

    let alt_credential = args
        .alt_credential
        .as_deref()
        .map(parse_alt_credential)
        .transpose()?;

    Ok(ScanContext {
        target: args.target.clone(),
        baseline_id: args.baseline_id.clone(),
        probe_id,
        headers,
        max_risk,
        known_duplicate,
        state_field,
        alt_credential,
    })
}

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

    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,
        }
    }

    #[test]
    fn parse_risk_safe() {
        assert_eq!(parse_risk("safe").unwrap(), RiskLevel::Safe);
    }

    #[test]
    fn parse_risk_method_destructive() {
        assert_eq!(parse_risk("method-destructive").unwrap(), RiskLevel::MethodDestructive);
    }

    #[test]
    fn parse_risk_operation_destructive() {
        assert_eq!(parse_risk("operation-destructive").unwrap(), RiskLevel::OperationDestructive);
    }

    #[test]
    fn parse_risk_invalid_returns_err() {
        assert!(parse_risk("invalid").is_err());
    }

    #[test]
    fn parse_known_duplicate_splits_field_and_value() {
        let kd = parse_known_duplicate("email=alice@example.com").unwrap();
        assert_eq!(kd.field, "email");
        assert_eq!(kd.value, "alice@example.com");
    }

    #[test]
    fn parse_known_duplicate_splits_on_first_equals_only() {
        let kd = parse_known_duplicate("foo=bar=baz").unwrap();
        assert_eq!(kd.field, "foo");
        assert_eq!(kd.value, "bar=baz");
    }

    #[test]
    fn parse_known_duplicate_no_divider_returns_err() {
        assert!(parse_known_duplicate("nodivider").is_err());
    }

    #[test]
    fn parse_state_field_splits_correctly() {
        let sf = parse_state_field("status=invalid").unwrap();
        assert_eq!(sf.field, "status");
        assert_eq!(sf.value, "invalid");
    }

    #[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);
    }
}