parlov-analysis 0.7.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Two-stage auth-block classifier.
//!
//! Stage 1 ([`classify_auth_block`]) inspects a single (request, response) pair and returns an
//! [`AuthBlockSignature`] when auth involvement is detected. Stage 2
//! ([`super::auth_equivalence::auth_gate_decision`]) compares baseline and probe signatures and
//! decides whether to gate the technique (both sides equivalent, no oracle differential),
//! preserve evidence (non-equivalent auth-related differential), or fall through to other gates
//! (no auth involvement).
//!
//! Critical invariant: the gate may suppress only non-differential same-layer auth blocks.
//! Status, body, header, location, or challenge-parameter differentials must be preserved as
//! oracle evidence — see `scan_idor_403_vs_404` for the canonical 403/404 BOLA pattern.

use parlov_core::{ProbeDefinition, RequestAuthState, ResponseSurface};

use super::auth_parsers::{parse_auth_error_body, parse_www_authenticate};
use super::auth_types::{
    AuthBlockConfidence, AuthBlockFamily, AuthBlockSignature, AuthChallenge, AuthErrorBodySignal,
    CredentialBlockKind,
};
use super::login_redirect::is_login_redirect;

/// Classifies a single request/response pair as auth-involved or not.
///
/// Order of checks (first match wins):
/// 1. 401 → `OriginAuthentication`.
/// 2. 407 → `ProxyAuthentication`.
/// 3. 511 → `NetworkAuthentication`.
/// 4. 403 with auth-layer evidence → `OriginAuthorization` (delegates to
///    [`classify_403_auth_block`]).
/// 5. Any non-401 with `WWW-Authenticate` → `OriginAuthentication`.
/// 6. 3xx with login-redirect signal → `LoginRedirect`.
/// 7. Body-only auth-error envelope → `AuthErrorEnvelope`.
#[allow(clippy::similar_names)] // req/res pairing is canonical
#[must_use]
pub fn classify_auth_block(
    req: &ProbeDefinition,
    res: &ResponseSurface,
) -> Option<AuthBlockSignature> {
    let status = res.status.as_u16();
    let auth_state = RequestAuthState::from_request(req);
    if status == 401 {
        return Some(classify_401(res, auth_state));
    }
    if status == 407 {
        return Some(classify_407(res));
    }
    if status == 511 {
        return Some(classify_511());
    }
    if status == 403 {
        return classify_403_auth_block(req, res);
    }
    if let Some(sig) = classify_www_authenticate_non_401(res, auth_state) {
        return Some(sig);
    }
    if let Some(sig) = classify_login_redirect(res) {
        return Some(sig);
    }
    classify_body_envelope(res)
}

fn classify_401(res: &ResponseSurface, auth_state: RequestAuthState) -> AuthBlockSignature {
    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
    let confidence = strong_if_present(challenge.as_ref());
    let credential_state = credential_state_from_challenge(auth_state, challenge.as_ref());
    AuthBlockSignature {
        family: AuthBlockFamily::OriginAuthentication,
        confidence,
        status: 401,
        credential_state,
        challenge,
        body_signal: parse_body_signal(res),
        login_redirect: None,
    }
}

fn classify_407(res: &ResponseSurface) -> AuthBlockSignature {
    let challenge = parse_challenge_header(res, http::header::PROXY_AUTHENTICATE);
    let confidence = strong_if_present(challenge.as_ref());
    AuthBlockSignature {
        family: AuthBlockFamily::ProxyAuthentication,
        confidence,
        status: 407,
        credential_state: CredentialBlockKind::NoCredential,
        challenge,
        body_signal: parse_body_signal(res),
        login_redirect: None,
    }
}

fn classify_511() -> AuthBlockSignature {
    AuthBlockSignature {
        family: AuthBlockFamily::NetworkAuthentication,
        confidence: AuthBlockConfidence::Strong,
        status: 511,
        credential_state: CredentialBlockKind::NotApplicable,
        challenge: None,
        body_signal: None,
        login_redirect: None,
    }
}

fn classify_www_authenticate_non_401(
    res: &ResponseSurface,
    auth_state: RequestAuthState,
) -> Option<AuthBlockSignature> {
    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE)?;
    Some(AuthBlockSignature {
        family: AuthBlockFamily::OriginAuthentication,
        confidence: AuthBlockConfidence::Medium,
        status: res.status.as_u16(),
        credential_state: credential_state_from_challenge(auth_state, Some(&challenge)),
        challenge: Some(challenge),
        body_signal: parse_body_signal(res),
        login_redirect: None,
    })
}

/// Classifies a 403 response. Returns `Some` only when an auth-layer signal is present —
/// `WWW-Authenticate` header or `insufficient_scope` / `invalid_token` body. Otherwise
/// returns `None` so the 403 propagates as potential oracle evidence (BOLA / IDOR territory).
#[allow(clippy::similar_names)] // req/res pairing is canonical
#[must_use]
pub fn classify_403_auth_block(
    req: &ProbeDefinition,
    res: &ResponseSurface,
) -> Option<AuthBlockSignature> {
    let auth_state = RequestAuthState::from_request(req);
    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
    let body_signal = parse_body_signal(res);
    let has_strong_body = body_signal
        .as_ref()
        .is_some_and(|b| b.confidence == AuthBlockConfidence::Strong);
    if challenge.is_none() && !has_strong_body {
        return None;
    }
    let credential_state =
        credential_state_403(auth_state, challenge.as_ref(), body_signal.as_ref());
    let confidence = strong_if_present(challenge.as_ref());
    Some(AuthBlockSignature {
        family: AuthBlockFamily::OriginAuthorization,
        confidence,
        status: 403,
        credential_state,
        challenge,
        body_signal,
        login_redirect: None,
    })
}

fn classify_login_redirect(res: &ResponseSurface) -> Option<AuthBlockSignature> {
    let signal = is_login_redirect(res)?;
    Some(AuthBlockSignature {
        family: AuthBlockFamily::LoginRedirect,
        confidence: signal.confidence,
        status: res.status.as_u16(),
        credential_state: CredentialBlockKind::NoCredential,
        challenge: None,
        body_signal: None,
        login_redirect: Some(signal),
    })
}

fn classify_body_envelope(res: &ResponseSurface) -> Option<AuthBlockSignature> {
    let body_signal = parse_body_signal(res)?;
    Some(AuthBlockSignature {
        family: AuthBlockFamily::AuthErrorEnvelope,
        confidence: body_signal.confidence,
        status: res.status.as_u16(),
        credential_state: CredentialBlockKind::UnknownAuthFailure,
        challenge: None,
        body_signal: Some(body_signal),
        login_redirect: None,
    })
}

fn parse_challenge_header(res: &ResponseSurface, name: http::HeaderName) -> Option<AuthChallenge> {
    res.headers
        .get(name)
        .and_then(|v| v.to_str().ok())
        .and_then(parse_www_authenticate)
}

fn parse_body_signal(res: &ResponseSurface) -> Option<AuthErrorBodySignal> {
    let ct = res
        .headers
        .get(http::header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok());
    parse_auth_error_body(ct, &res.body)
}

fn strong_if_present<T>(opt: Option<&T>) -> AuthBlockConfidence {
    if opt.is_some() {
        AuthBlockConfidence::Strong
    } else {
        AuthBlockConfidence::Medium
    }
}

fn credential_state_403(
    auth_state: RequestAuthState,
    challenge: Option<&AuthChallenge>,
    body_signal: Option<&AuthErrorBodySignal>,
) -> CredentialBlockKind {
    let challenge_error = challenge.and_then(|c| c.error.as_deref());
    let body_code = body_signal.map(|b| b.code.as_str());
    let signals_insufficient = matches_token(challenge_error, "insufficient_scope")
        || matches_token(body_code, "insufficient_scope");
    let signals_rejected = matches_token(challenge_error, "invalid_token")
        || matches_token(body_code, "invalid_token");
    match (auth_state, signals_insufficient, signals_rejected) {
        (RequestAuthState::Present, true, _) => CredentialBlockKind::InsufficientScope,
        (RequestAuthState::Present, _, true) => CredentialBlockKind::CredentialRejected,
        (RequestAuthState::Absent, _, _) if challenge.is_some() => {
            CredentialBlockKind::NoCredential
        }
        _ => CredentialBlockKind::UnknownAuthFailure,
    }
}

fn credential_state_from_challenge(
    auth_state: RequestAuthState,
    challenge: Option<&AuthChallenge>,
) -> CredentialBlockKind {
    let error = challenge.and_then(|c| c.error.as_deref());
    if matches_token(error, "insufficient_scope") {
        return CredentialBlockKind::InsufficientScope;
    }
    if matches_token(error, "invalid_token") || matches_token(error, "expired_token") {
        return CredentialBlockKind::CredentialRejected;
    }
    match auth_state {
        RequestAuthState::Absent => CredentialBlockKind::NoCredential,
        RequestAuthState::Present => CredentialBlockKind::UnknownAuthFailure,
    }
}

fn matches_token(actual: Option<&str>, expected: &str) -> bool {
    actual.is_some_and(|s| s.eq_ignore_ascii_case(expected))
}

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