Skip to main content

termivar_scanner/defense/
state.rs

1//! Observed defensive posture of a single HTTP response.
2//!
3//! [`DefenseState`] is a pure, bounded projection of the defensive signals in
4//! one response: status class, challenge and rate-limit markers, and an optional
5//! product fingerprint. It deliberately makes no payload or evasion decision — a
6//! planner consumes this observation and decides how (or whether) to proceed.
7
8use super::fingerprint::{fingerprint, DefenseFingerprint, FingerprintConfidence};
9
10/// Coarse classification of a response status for defensive reasoning.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum DefenseStatusSignal {
14    /// A status that carries no inherent blocking signal.
15    Normal,
16    /// `403 Forbidden`, a common block response.
17    Forbidden,
18    /// `406 Not Acceptable`, often a rule-engine rejection.
19    NotAcceptable,
20    /// `418`, sometimes used by edges as a bot-block sentinel.
21    Teapot,
22    /// `429 Too Many Requests`, an explicit rate-limit response.
23    RateLimited,
24    /// Any `5xx`, which is ambiguous rather than a deliberate block.
25    ServerError,
26}
27
28impl DefenseStatusSignal {
29    fn classify(status: u16) -> Self {
30        match status {
31            403 => Self::Forbidden,
32            406 => Self::NotAcceptable,
33            418 => Self::Teapot,
34            429 => Self::RateLimited,
35            500..=599 => Self::ServerError,
36            _ => Self::Normal,
37        }
38    }
39
40    /// Returns whether this status is a deliberate block signal.
41    ///
42    /// Rate limiting and server errors are handled separately because they are
43    /// not, on their own, evidence of a request-blocking rule.
44    pub const fn is_block(self) -> bool {
45        matches!(self, Self::Forbidden | Self::NotAcceptable | Self::Teapot)
46    }
47}
48
49/// Overall defensive posture inferred from one response.
50///
51/// Ordering is meaningful: a more defensive posture is greater.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub enum DefensePosture {
54    /// No defensive reaction was observed.
55    Open,
56    /// Defensive infrastructure is present or throttling, but nothing was blocked.
57    Suspected,
58    /// The response is a deliberate block or challenge.
59    Blocking,
60}
61
62/// Bounded, deterministic observation of a response's defensive posture.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DefenseState {
65    status: u16,
66    status_signal: DefenseStatusSignal,
67    challenged: bool,
68    rate_limited: bool,
69    rate_limit_headers_present: bool,
70    fingerprint: Option<DefenseFingerprint>,
71    posture: DefensePosture,
72}
73
74/// Lowercase body markers that indicate an interstitial challenge or block page.
75const CHALLENGE_MARKERS: &[&str] = &[
76    "attention required",
77    "checking your browser",
78    "please enable cookies",
79    "captcha",
80    "access denied",
81    "request blocked",
82    "you have been blocked",
83    "this request was blocked",
84];
85
86/// Lowercase header names that indicate rate-limit accounting is in effect.
87const RATE_LIMIT_HEADERS: &[&str] = &[
88    "retry-after",
89    "ratelimit-limit",
90    "ratelimit-remaining",
91    "ratelimit-reset",
92    "x-ratelimit-limit",
93    "x-ratelimit-remaining",
94    "x-ratelimit-reset",
95];
96
97impl DefenseState {
98    pub(crate) fn from_assessment_projection(
99        status: u16,
100        challenged: bool,
101        rate_limited: bool,
102        rate_limit_headers_present: bool,
103        fingerprint: Option<DefenseFingerprint>,
104    ) -> Self {
105        let status_signal = DefenseStatusSignal::classify(status);
106        let rate_limited = rate_limited
107            || rate_limit_headers_present
108            || status_signal == DefenseStatusSignal::RateLimited;
109        let posture = derive_posture(
110            status_signal,
111            challenged,
112            rate_limited,
113            fingerprint.as_ref(),
114        );
115        Self {
116            status,
117            status_signal,
118            challenged,
119            rate_limited,
120            rate_limit_headers_present,
121            fingerprint,
122            posture,
123        }
124    }
125
126    /// Builds the authorization-review projection from the exact retained
127    /// status plus an independently typed challenge/rate-limit classification.
128    /// A denial status alone is an authorization outcome, not defensive
129    /// interference, so it remains observable without becoming execution
130    /// suppression authority.
131    #[cfg(feature = "authorization-review")]
132    pub(crate) fn from_authorization_projection(
133        status: u16,
134        challenged: bool,
135        rate_limited: bool,
136    ) -> Self {
137        let status_signal = if rate_limited {
138            DefenseStatusSignal::RateLimited
139        } else if challenged {
140            DefenseStatusSignal::classify(status)
141        } else {
142            DefenseStatusSignal::Normal
143        };
144        let posture = if challenged {
145            DefensePosture::Blocking
146        } else if rate_limited {
147            DefensePosture::Suspected
148        } else {
149            DefensePosture::Open
150        };
151        Self {
152            status,
153            status_signal,
154            challenged,
155            rate_limited,
156            rate_limit_headers_present: false,
157            fingerprint: None,
158            posture,
159        }
160    }
161
162    /// Observes the defensive posture of one response.
163    ///
164    /// `headers` is a transport-neutral `(name, value)` list matched
165    /// case-insensitively. `body` is scanned only up to the fingerprint scan
166    /// ceiling, so a large body cannot turn one observation into unbounded work.
167    pub fn observe(status: u16, headers: &[(&str, &str)], body: &str) -> Self {
168        let status_signal = DefenseStatusSignal::classify(status);
169        let rate_limit_headers_present = headers.iter().any(|(name, _)| {
170            RATE_LIMIT_HEADERS
171                .iter()
172                .any(|rl| name.eq_ignore_ascii_case(rl))
173        });
174        let rate_limited =
175            status_signal == DefenseStatusSignal::RateLimited || rate_limit_headers_present;
176        let challenged = body_has_challenge_marker(body);
177        let fingerprint = fingerprint(headers, body);
178
179        let posture = derive_posture(
180            status_signal,
181            challenged,
182            rate_limited,
183            fingerprint.as_ref(),
184        );
185
186        Self {
187            status,
188            status_signal,
189            challenged,
190            rate_limited,
191            rate_limit_headers_present,
192            fingerprint,
193            posture,
194        }
195    }
196
197    /// Returns the observed HTTP status code.
198    pub const fn status(&self) -> u16 {
199        self.status
200    }
201
202    /// Returns the coarse status classification.
203    pub const fn status_signal(&self) -> DefenseStatusSignal {
204        self.status_signal
205    }
206
207    /// Returns whether the body carried an interstitial challenge or block marker.
208    pub const fn is_challenged(&self) -> bool {
209        self.challenged
210    }
211
212    /// Returns whether the response is rate limited by status or headers.
213    pub const fn is_rate_limited(&self) -> bool {
214        self.rate_limited
215    }
216
217    /// Returns whether rate-limit accounting headers were present.
218    pub const fn has_rate_limit_headers(&self) -> bool {
219        self.rate_limit_headers_present
220    }
221
222    /// Returns the product fingerprint observed for this response, if any.
223    pub const fn fingerprint(&self) -> Option<&DefenseFingerprint> {
224        self.fingerprint.as_ref()
225    }
226
227    /// Returns the overall inferred defensive posture.
228    pub const fn posture(&self) -> DefensePosture {
229        self.posture
230    }
231}
232
233fn body_has_challenge_marker(body: &str) -> bool {
234    let prefix = super::fingerprint::MAX_FINGERPRINT_BODY_SCAN_BYTES.min(body.len());
235    let mut end = prefix;
236    while end > 0 && !body.is_char_boundary(end) {
237        end -= 1;
238    }
239    let lowered = body[..end].to_ascii_lowercase();
240    CHALLENGE_MARKERS
241        .iter()
242        .any(|marker| lowered.contains(marker))
243}
244
245fn derive_posture(
246    status_signal: DefenseStatusSignal,
247    challenged: bool,
248    rate_limited: bool,
249    fingerprint: Option<&DefenseFingerprint>,
250) -> DefensePosture {
251    if status_signal.is_block() || challenged {
252        return DefensePosture::Blocking;
253    }
254
255    let strong_fingerprint =
256        fingerprint.is_some_and(|print| print.confidence() >= FingerprintConfidence::Probable);
257    if rate_limited || strong_fingerprint || fingerprint.is_some() {
258        return DefensePosture::Suspected;
259    }
260
261    DefensePosture::Open
262}
263
264#[cfg(test)]
265mod tests {
266    use super::super::fingerprint::DefenseProduct;
267    use super::*;
268
269    #[test]
270    fn a_plain_response_is_open() {
271        let state = DefenseState::observe(200, &[("Server", "nginx")], "<html>ok</html>");
272        assert_eq!(state.posture(), DefensePosture::Open);
273        assert!(!state.is_challenged());
274        assert!(!state.is_rate_limited());
275        assert!(state.fingerprint().is_none());
276    }
277
278    #[test]
279    fn a_forbidden_status_is_blocking() {
280        let state = DefenseState::observe(403, &[], "forbidden");
281        assert_eq!(state.status_signal(), DefenseStatusSignal::Forbidden);
282        assert_eq!(state.posture(), DefensePosture::Blocking);
283    }
284
285    #[test]
286    fn a_challenge_body_is_blocking_even_on_200() {
287        let state = DefenseState::observe(
288            200,
289            &[("CF-RAY", "abc")],
290            "Attention Required! Checking your browser before accessing.",
291        );
292        assert!(state.is_challenged());
293        assert_eq!(state.posture(), DefensePosture::Blocking);
294        assert_eq!(
295            state.fingerprint().unwrap().product(),
296            DefenseProduct::Cloudflare
297        );
298    }
299
300    #[test]
301    fn rate_limit_is_suspected_not_blocking() {
302        let by_status = DefenseState::observe(429, &[], "slow down");
303        assert!(by_status.is_rate_limited());
304        assert_eq!(by_status.posture(), DefensePosture::Suspected);
305
306        let by_header = DefenseState::observe(200, &[("Retry-After", "30")], "ok");
307        assert!(by_header.is_rate_limited());
308        assert!(by_header.has_rate_limit_headers());
309        assert_eq!(by_header.posture(), DefensePosture::Suspected);
310    }
311
312    #[test]
313    fn a_fingerprint_without_a_block_is_suspected() {
314        let state = DefenseState::observe(200, &[("x-amzn-requestid", "id")], "ok");
315        assert_eq!(state.posture(), DefensePosture::Suspected);
316        assert_eq!(
317            state.fingerprint().unwrap().product(),
318            DefenseProduct::AwsWaf
319        );
320    }
321
322    #[test]
323    fn server_error_alone_is_not_a_block() {
324        let state = DefenseState::observe(503, &[], "service unavailable");
325        assert_eq!(state.status_signal(), DefenseStatusSignal::ServerError);
326        assert_eq!(state.posture(), DefensePosture::Open);
327    }
328
329    #[test]
330    fn observation_is_deterministic() {
331        let first = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
332        let second = DefenseState::observe(403, &[("CF-RAY", "x")], "access denied");
333        assert_eq!(first, second);
334    }
335}