parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! C8 auth-challenge-informed producer/consumer chain.
//!
//! `ScdAuthChallengeProducer` parses the `WWW-Authenticate` header from 401/407
//! (`AuthChallenge`) exchanges, extracting scheme, realm, and scope parameters.
//! `ScdAuthChallengeConsumer` uses those values to construct a follow-up GET with
//! a syntactically valid but semantically wrong `Authorization` header, driving a
//! stronger auth error (401 with credentials, or 403) vs 404 for a nonexistent
//! resource — amplifying the existence differential.

use http::{HeaderMap, Method};
use parlov_core::{
    always_applicable, NormativeStrength, OracleClass, ResponseClass, SignalSurface, Technique,
    Vector,
};

use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::context::ScanContext;
use crate::types::{ProbeSpec, RiskLevel, StrategyMetadata};
use crate::util::{build_pair, try_clone_headers_with};

static CHAIN_METADATA: StrategyMetadata = StrategyMetadata {
    strategy_id: "scd-auth-challenge-chain",
    strategy_name: "Auth-Challenge Chain (scope-manipulation probe)",
    risk: RiskLevel::Safe,
};

static CHAIN_TECHNIQUE: Technique = Technique {
    id: "auth-challenge-chain",
    name: "Auth-challenge-informed scope-manipulation chain",
    oracle_class: OracleClass::Existence,
    vector: Vector::StatusCodeDiff,
    strength: NormativeStrength::Should,
    normalization_weight: None,
    inverted_signal_weight: None,
    method_relevant: false,
    parser_relevant: false,
    applicability: always_applicable,
    contradiction_surface: SignalSurface::Status,
};

/// Parses `WWW-Authenticate` scheme, realm, and scope from 401/407 responses.
pub(super) struct ScdAuthChallengeProducer;

impl Producer for ScdAuthChallengeProducer {
    fn admits(&self, class: ResponseClass) -> bool {
        matches!(class, ResponseClass::AuthChallenge)
    }

    fn extract(&self, _class: ResponseClass, headers: &HeaderMap) -> Option<ProducerOutput> {
        let raw = headers.get(http::header::WWW_AUTHENTICATE)?.to_str().ok()?;
        let scheme = parse_scheme(raw);
        if scheme.is_empty() {
            return None;
        }
        let realm = parse_param(raw, "realm");
        let scope = parse_param(raw, "scope");
        Some(ProducerOutput::AuthChallenge {
            scheme: scheme.to_owned(),
            realm,
            scope,
        })
    }
}

/// Extracts the scheme token: the first whitespace-delimited or end-of-string token.
fn parse_scheme(raw: &str) -> &str {
    raw.split_ascii_whitespace()
        .next()
        .unwrap_or("")
        .trim_end_matches(',')
}

/// Finds `key="value"` or `key=value` in the header, returns the value.
fn parse_param(raw: &str, key: &str) -> Option<String> {
    let needle = key;
    // Walk through comma-separated params after the scheme token
    let params_start = raw.find(char::is_whitespace).unwrap_or(raw.len());
    let params = &raw[params_start..];

    for part in params.split(',') {
        let part = part.trim();
        let Some(rest) = part.strip_prefix(needle) else {
            continue;
        };
        let rest = rest.trim_start_matches(char::is_whitespace);
        let Some(rest) = rest.strip_prefix('=') else {
            continue;
        };
        let rest = rest.trim();
        let value = if rest.starts_with('"') {
            rest.trim_matches('"').to_owned()
        } else {
            rest.split_ascii_whitespace()
                .next()
                .unwrap_or(rest)
                .to_owned()
        };
        if !value.is_empty() {
            return Some(value);
        }
    }
    None
}

/// Converts a harvested auth challenge into one GET probe spec with a crafted
/// `Authorization` header using the extracted scheme (and realm for Bearer).
pub(super) struct ScdAuthChallengeConsumer;

impl Consumer for ScdAuthChallengeConsumer {
    fn needs(&self) -> ProducerOutputKind {
        ProducerOutputKind::AuthChallenge
    }

    fn generate(&self, ctx: &ScanContext, output: &ProducerOutput) -> Vec<ProbeSpec> {
        let ProducerOutput::AuthChallenge { scheme, realm, .. } = output else {
            return vec![];
        };
        let auth_value = build_auth_value(scheme, realm.as_deref());
        let Some(hdrs) = try_clone_headers_with(&ctx.headers, "authorization", &auth_value) else {
            return vec![];
        };
        vec![ProbeSpec::Pair(build_pair(
            ctx,
            Method::GET,
            hdrs.clone(),
            hdrs,
            None,
            CHAIN_METADATA.clone(),
            CHAIN_TECHNIQUE,
        ))]
    }
}

/// Produces a minimal, syntactically valid but wrong `Authorization` value.
fn build_auth_value(scheme: &str, realm: Option<&str>) -> String {
    match scheme {
        "Bearer" => match realm {
            Some(r) => format!("Bearer realm=\"{r}\""),
            None => "Bearer".to_owned(),
        },
        "Basic" => "Basic Og==".to_owned(),
        other => other.to_owned(),
    }
}

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