parlov 0.8.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};

use crate::parse::parse_risk;

/// 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).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),
        "error-message-granularity" => Ok(Vector::ErrorMessageGranularity),
        "redirect-diff" => Ok(Vector::RedirectDiff),
        "status-code-diff" => Ok(Vector::StatusCodeDiff),
        other => Err(Error::Cli(format!(
            "unknown vector '{other}'; expected cache-probing | error-message-granularity | redirect-diff | status-code-diff"
        ))),
    }
}

/// 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);
    }

    // --- EMG vector tests ---

    #[test]
    fn parse_vector_emg_name_only() {
        let f = parse_vector_flag("error-message-granularity").unwrap();
        assert_eq!(f.vector, Vector::ErrorMessageGranularity);
        assert_eq!(f.risk, None);
    }

    #[test]
    fn parse_vector_emg_with_risk() {
        let f = parse_vector_flag("error-message-granularity:method-destructive").unwrap();
        assert_eq!(f.vector, Vector::ErrorMessageGranularity);
        assert_eq!(f.risk, Some(RiskLevel::MethodDestructive));
    }

    #[test]
    fn parse_vector_emg_with_safe_risk() {
        let f = parse_vector_flag("error-message-granularity:safe").unwrap();
        assert_eq!(f.vector, Vector::ErrorMessageGranularity);
        assert_eq!(f.risk, Some(RiskLevel::Safe));
    }

    // --- RedirectDiff vector tests ---

    #[test]
    fn parse_vector_redirect_diff_name_only() {
        let f = parse_vector_flag("redirect-diff").unwrap();
        assert_eq!(f.vector, Vector::RedirectDiff);
        assert_eq!(f.risk, None);
    }

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

    #[test]
    fn parse_vector_redirect_diff_with_safe_risk() {
        let f = parse_vector_flag("redirect-diff:safe").unwrap();
        assert_eq!(f.vector, Vector::RedirectDiff);
        assert_eq!(f.risk, Some(RiskLevel::Safe));
    }

    #[test]
    fn filter_emg_specs_only() {
        use http::{HeaderMap, Method};
        use parlov_core::{
            always_applicable, NormativeStrength, OracleClass, ProbeDefinition, SignalSurface,
            Technique,
        };
        use parlov_elicit::{ProbePair, StrategyMetadata};

        let make_def = || ProbeDefinition {
            url: "https://example.com/1".to_owned(),
            method: Method::GET,
            headers: HeaderMap::new(),
            body: None,
        };

        let make_pair = |vector: Vector| {
            ProbeSpec::Pair(ProbePair {
                baseline: make_def(),
                probe: make_def(),
                canonical_baseline: None,
                metadata: StrategyMetadata {
                    strategy_id: "test",
                    strategy_name: "Test",
                    risk: RiskLevel::Safe,
                },
                technique: Technique {
                    id: "test",
                    name: "Test",
                    oracle_class: OracleClass::Existence,
                    vector,
                    strength: NormativeStrength::Should,
                    normalization_weight: None,
                    inverted_signal_weight: None,
                    method_relevant: false,
                    parser_relevant: false,
                    applicability: always_applicable,
                    contradiction_surface: SignalSurface::Status,
                },
                chain_provenance: None,
            })
        };

        let plan = vec![
            make_pair(Vector::StatusCodeDiff),
            make_pair(Vector::ErrorMessageGranularity),
            make_pair(Vector::CacheProbing),
            make_pair(Vector::ErrorMessageGranularity),
        ];

        let filters = vec![VectorFilter {
            vector: Vector::ErrorMessageGranularity,
            risk: None,
        }];

        let filtered = apply_vector_filters(plan, &filters);
        assert_eq!(filtered.len(), 2);
        for spec in &filtered {
            assert_eq!(spec.technique().vector, Vector::ErrorMessageGranularity);
        }
    }
}