Skip to main content

parlov_analysis/aggregation/
auth_classifier.rs

1//! Two-stage auth-block classifier.
2//!
3//! Stage 1 ([`classify_auth_block`]) inspects a single (request, response) pair and returns an
4//! [`AuthBlockSignature`] when auth involvement is detected. Stage 2
5//! ([`super::auth_equivalence::auth_gate_decision`]) compares baseline and probe signatures and
6//! decides whether to gate the technique (both sides equivalent, no oracle differential),
7//! preserve evidence (non-equivalent auth-related differential), or fall through to other gates
8//! (no auth involvement).
9//!
10//! Critical invariant: the gate may suppress only non-differential same-layer auth blocks.
11//! Status, body, header, location, or challenge-parameter differentials must be preserved as
12//! oracle evidence — see `scan_idor_403_vs_404` for the canonical 403/404 BOLA pattern.
13
14use parlov_core::{ProbeDefinition, RequestAuthState, ResponseSurface};
15
16use super::auth_parsers::{parse_auth_error_body, parse_www_authenticate};
17use super::auth_types::{
18    AuthBlockConfidence, AuthBlockFamily, AuthBlockSignature, AuthChallenge, AuthErrorBodySignal,
19    CredentialBlockKind,
20};
21use super::login_redirect::is_login_redirect;
22
23/// Classifies a single request/response pair as auth-involved or not.
24///
25/// Order of checks (first match wins):
26/// 1. 401 → `OriginAuthentication`.
27/// 2. 407 → `ProxyAuthentication`.
28/// 3. 511 → `NetworkAuthentication`.
29/// 4. 403 with auth-layer evidence → `OriginAuthorization` (delegates to
30///    [`classify_403_auth_block`]).
31/// 5. Any non-401 with `WWW-Authenticate` → `OriginAuthentication`.
32/// 6. 3xx with login-redirect signal → `LoginRedirect`.
33/// 7. Body-only auth-error envelope → `AuthErrorEnvelope`.
34#[allow(clippy::similar_names)] // req/res pairing is canonical
35#[must_use]
36pub fn classify_auth_block(
37    req: &ProbeDefinition,
38    res: &ResponseSurface,
39) -> Option<AuthBlockSignature> {
40    let status = res.status.as_u16();
41    let auth_state = RequestAuthState::from_request(req);
42    if status == 401 {
43        return Some(classify_401(res, auth_state));
44    }
45    if status == 407 {
46        return Some(classify_407(res));
47    }
48    if status == 511 {
49        return Some(classify_511());
50    }
51    if status == 403 {
52        return classify_403_auth_block(req, res);
53    }
54    if let Some(sig) = classify_www_authenticate_non_401(res, auth_state) {
55        return Some(sig);
56    }
57    if let Some(sig) = classify_login_redirect(res) {
58        return Some(sig);
59    }
60    classify_body_envelope(res)
61}
62
63fn classify_401(res: &ResponseSurface, auth_state: RequestAuthState) -> AuthBlockSignature {
64    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
65    let confidence = strong_if_present(challenge.as_ref());
66    let credential_state = credential_state_from_challenge(auth_state, challenge.as_ref());
67    AuthBlockSignature {
68        family: AuthBlockFamily::OriginAuthentication,
69        confidence,
70        status: 401,
71        credential_state,
72        challenge,
73        body_signal: parse_body_signal(res),
74        login_redirect: None,
75    }
76}
77
78fn classify_407(res: &ResponseSurface) -> AuthBlockSignature {
79    let challenge = parse_challenge_header(res, http::header::PROXY_AUTHENTICATE);
80    let confidence = strong_if_present(challenge.as_ref());
81    AuthBlockSignature {
82        family: AuthBlockFamily::ProxyAuthentication,
83        confidence,
84        status: 407,
85        credential_state: CredentialBlockKind::NoCredential,
86        challenge,
87        body_signal: parse_body_signal(res),
88        login_redirect: None,
89    }
90}
91
92fn classify_511() -> AuthBlockSignature {
93    AuthBlockSignature {
94        family: AuthBlockFamily::NetworkAuthentication,
95        confidence: AuthBlockConfidence::Strong,
96        status: 511,
97        credential_state: CredentialBlockKind::NotApplicable,
98        challenge: None,
99        body_signal: None,
100        login_redirect: None,
101    }
102}
103
104fn classify_www_authenticate_non_401(
105    res: &ResponseSurface,
106    auth_state: RequestAuthState,
107) -> Option<AuthBlockSignature> {
108    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE)?;
109    Some(AuthBlockSignature {
110        family: AuthBlockFamily::OriginAuthentication,
111        confidence: AuthBlockConfidence::Medium,
112        status: res.status.as_u16(),
113        credential_state: credential_state_from_challenge(auth_state, Some(&challenge)),
114        challenge: Some(challenge),
115        body_signal: parse_body_signal(res),
116        login_redirect: None,
117    })
118}
119
120/// Classifies a 403 response. Returns `Some` only when an auth-layer signal is present —
121/// `WWW-Authenticate` header or `insufficient_scope` / `invalid_token` body. Otherwise
122/// returns `None` so the 403 propagates as potential oracle evidence (BOLA / IDOR territory).
123#[allow(clippy::similar_names)] // req/res pairing is canonical
124#[must_use]
125pub fn classify_403_auth_block(
126    req: &ProbeDefinition,
127    res: &ResponseSurface,
128) -> Option<AuthBlockSignature> {
129    let auth_state = RequestAuthState::from_request(req);
130    let challenge = parse_challenge_header(res, http::header::WWW_AUTHENTICATE);
131    let body_signal = parse_body_signal(res);
132    let has_strong_body = body_signal
133        .as_ref()
134        .is_some_and(|b| b.confidence == AuthBlockConfidence::Strong);
135    if challenge.is_none() && !has_strong_body {
136        return None;
137    }
138    let credential_state =
139        credential_state_403(auth_state, challenge.as_ref(), body_signal.as_ref());
140    let confidence = strong_if_present(challenge.as_ref());
141    Some(AuthBlockSignature {
142        family: AuthBlockFamily::OriginAuthorization,
143        confidence,
144        status: 403,
145        credential_state,
146        challenge,
147        body_signal,
148        login_redirect: None,
149    })
150}
151
152fn classify_login_redirect(res: &ResponseSurface) -> Option<AuthBlockSignature> {
153    let signal = is_login_redirect(res)?;
154    Some(AuthBlockSignature {
155        family: AuthBlockFamily::LoginRedirect,
156        confidence: signal.confidence,
157        status: res.status.as_u16(),
158        credential_state: CredentialBlockKind::NoCredential,
159        challenge: None,
160        body_signal: None,
161        login_redirect: Some(signal),
162    })
163}
164
165fn classify_body_envelope(res: &ResponseSurface) -> Option<AuthBlockSignature> {
166    let body_signal = parse_body_signal(res)?;
167    Some(AuthBlockSignature {
168        family: AuthBlockFamily::AuthErrorEnvelope,
169        confidence: body_signal.confidence,
170        status: res.status.as_u16(),
171        credential_state: CredentialBlockKind::UnknownAuthFailure,
172        challenge: None,
173        body_signal: Some(body_signal),
174        login_redirect: None,
175    })
176}
177
178fn parse_challenge_header(res: &ResponseSurface, name: http::HeaderName) -> Option<AuthChallenge> {
179    res.headers
180        .get(name)
181        .and_then(|v| v.to_str().ok())
182        .and_then(parse_www_authenticate)
183}
184
185fn parse_body_signal(res: &ResponseSurface) -> Option<AuthErrorBodySignal> {
186    let ct = res
187        .headers
188        .get(http::header::CONTENT_TYPE)
189        .and_then(|v| v.to_str().ok());
190    parse_auth_error_body(ct, &res.body)
191}
192
193fn strong_if_present<T>(opt: Option<&T>) -> AuthBlockConfidence {
194    if opt.is_some() {
195        AuthBlockConfidence::Strong
196    } else {
197        AuthBlockConfidence::Medium
198    }
199}
200
201fn credential_state_403(
202    auth_state: RequestAuthState,
203    challenge: Option<&AuthChallenge>,
204    body_signal: Option<&AuthErrorBodySignal>,
205) -> CredentialBlockKind {
206    let challenge_error = challenge.and_then(|c| c.error.as_deref());
207    let body_code = body_signal.map(|b| b.code.as_str());
208    let signals_insufficient = matches_token(challenge_error, "insufficient_scope")
209        || matches_token(body_code, "insufficient_scope");
210    let signals_rejected = matches_token(challenge_error, "invalid_token")
211        || matches_token(body_code, "invalid_token");
212    match (auth_state, signals_insufficient, signals_rejected) {
213        (RequestAuthState::Present, true, _) => CredentialBlockKind::InsufficientScope,
214        (RequestAuthState::Present, _, true) => CredentialBlockKind::CredentialRejected,
215        (RequestAuthState::Absent, _, _) if challenge.is_some() => {
216            CredentialBlockKind::NoCredential
217        }
218        _ => CredentialBlockKind::UnknownAuthFailure,
219    }
220}
221
222fn credential_state_from_challenge(
223    auth_state: RequestAuthState,
224    challenge: Option<&AuthChallenge>,
225) -> CredentialBlockKind {
226    let error = challenge.and_then(|c| c.error.as_deref());
227    if matches_token(error, "insufficient_scope") {
228        return CredentialBlockKind::InsufficientScope;
229    }
230    if matches_token(error, "invalid_token") || matches_token(error, "expired_token") {
231        return CredentialBlockKind::CredentialRejected;
232    }
233    match auth_state {
234        RequestAuthState::Absent => CredentialBlockKind::NoCredential,
235        RequestAuthState::Present => CredentialBlockKind::UnknownAuthFailure,
236    }
237}
238
239fn matches_token(actual: Option<&str>, expected: &str) -> bool {
240    actual.is_some_and(|s| s.eq_ignore_ascii_case(expected))
241}
242
243#[cfg(test)]
244#[path = "auth_classifier_tests.rs"]
245mod tests;