parlov 0.5.0

HTTP oracle detection tool — systematic probing for RFC-compliant information leakage.
Documentation
//! Vector-based strategy filtering for the `scan` subcommand.
//!
//! Parses `--vector` flags into `(Vector, Option<RiskLevel>)` pairs and filters
//! a probe plan down to matching specs. Mutually exclusive with `--risk`.

use parlov_core::{Error, Vector};
use parlov_elicit::{ProbeSpec, RiskLevel};

/// A parsed vector filter: a detection vector with an optional risk ceiling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VectorFilter {
    /// Detection vector to match against.
    pub vector: Vector,
    /// Optional per-vector risk ceiling. `None` means Safe (default).
    pub risk: Option<RiskLevel>,
}

/// Parses a single `--vector` flag value into a `VectorFilter`.
///
/// Accepted formats:
/// - `"cache-probing"` -> `Vector::CacheProbing`, risk = `None`
/// - `"status-code-diff:safe"` -> `Vector::StatusCodeDiff`, risk = `Some(Safe)`
/// - `"cache-probing:method-destructive"` -> risk = `Some(MethodDestructive)`
///
/// # Errors
///
/// Returns `Err` for unrecognized vector names or risk levels.
pub(crate) fn parse_vector_flag(s: &str) -> Result<VectorFilter, Error> {
    let (vector_str, risk_str) = match s.split_once(':') {
        Some((v, r)) => (v, Some(r)),
        None => (s, None),
    };

    let vector = parse_vector_name(vector_str)?;
    let risk = risk_str.map(parse_risk_name).transpose()?;
    Ok(VectorFilter { vector, risk })
}

/// Parses a vector name string into a `Vector` enum.
fn parse_vector_name(s: &str) -> Result<Vector, Error> {
    match s {
        "cache-probing" => Ok(Vector::CacheProbing),
        "status-code-diff" => Ok(Vector::StatusCodeDiff),
        other => Err(Error::Http(format!(
            "unknown vector '{other}'; expected cache-probing | status-code-diff"
        ))),
    }
}

/// Parses a risk level name string into a `RiskLevel` enum.
fn parse_risk_name(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"
        ))),
    }
}

/// Extracts the `StrategyMetadata.risk` from a `ProbeSpec`.
fn spec_risk(spec: &ProbeSpec) -> RiskLevel {
    match spec {
        ProbeSpec::Pair(p) | ProbeSpec::HeaderDiff(p) => p.metadata.risk,
        ProbeSpec::Burst(b) => b.metadata.risk,
    }
}

/// Filters a plan to only include specs matching the given vector filters.
///
/// A spec matches if its technique vector equals any filter's vector AND its
/// strategy risk is at or below that filter's risk ceiling (defaulting to Safe).
pub(crate) fn apply_vector_filters(plan: Vec<ProbeSpec>, filters: &[VectorFilter]) -> Vec<ProbeSpec> {
    plan.into_iter()
        .filter(|spec| {
            let technique_vector = spec.technique().vector;
            let strategy_risk = spec_risk(spec);
            filters.iter().any(|f| {
                let ceiling = f.risk.unwrap_or(RiskLevel::Safe);
                technique_vector == f.vector && strategy_risk <= ceiling
            })
        })
        .collect()
}

/// Computes the maximum risk ceiling across all vector filters.
///
/// Used to set `ctx.max_risk` so `generate_plan` produces a superset
/// that is then post-filtered by per-vector risk.
pub(crate) fn max_risk_from_filters(filters: &[VectorFilter]) -> RiskLevel {
    filters
        .iter()
        .map(|f| f.risk.unwrap_or(RiskLevel::Safe))
        .max()
        .unwrap_or(RiskLevel::Safe)
}

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

    #[test]
    fn parse_vector_name_only() {
        let f = parse_vector_flag("cache-probing").unwrap();
        assert_eq!(f.vector, Vector::CacheProbing);
        assert_eq!(f.risk, None);
    }

    #[test]
    fn parse_vector_with_risk() {
        let f = parse_vector_flag("status-code-diff:method-destructive").unwrap();
        assert_eq!(f.vector, Vector::StatusCodeDiff);
        assert_eq!(f.risk, Some(RiskLevel::MethodDestructive));
    }

    #[test]
    fn parse_vector_with_safe_risk() {
        let f = parse_vector_flag("cache-probing:safe").unwrap();
        assert_eq!(f.vector, Vector::CacheProbing);
        assert_eq!(f.risk, Some(RiskLevel::Safe));
    }

    #[test]
    fn parse_vector_unknown_name_returns_err() {
        assert!(parse_vector_flag("unknown-vector").is_err());
    }

    #[test]
    fn parse_vector_unknown_risk_returns_err() {
        assert!(parse_vector_flag("cache-probing:unknown").is_err());
    }

    #[test]
    fn max_risk_defaults_to_safe_when_no_risk_specified() {
        let filters = vec![
            VectorFilter { vector: Vector::CacheProbing, risk: None },
        ];
        assert_eq!(max_risk_from_filters(&filters), RiskLevel::Safe);
    }

    #[test]
    fn max_risk_picks_highest_across_filters() {
        let filters = vec![
            VectorFilter { vector: Vector::CacheProbing, risk: Some(RiskLevel::Safe) },
            VectorFilter { vector: Vector::StatusCodeDiff, risk: Some(RiskLevel::MethodDestructive) },
        ];
        assert_eq!(max_risk_from_filters(&filters), RiskLevel::MethodDestructive);
    }

    #[test]
    fn max_risk_empty_filters_returns_safe() {
        assert_eq!(max_risk_from_filters(&[]), RiskLevel::Safe);
    }
}