termivar_scanner/defense/
state.rs1use super::fingerprint::{fingerprint, DefenseFingerprint, FingerprintConfidence};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum DefenseStatusSignal {
14 Normal,
16 Forbidden,
18 NotAcceptable,
20 Teapot,
22 RateLimited,
24 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 pub const fn is_block(self) -> bool {
45 matches!(self, Self::Forbidden | Self::NotAcceptable | Self::Teapot)
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub enum DefensePosture {
54 Open,
56 Suspected,
58 Blocking,
60}
61
62#[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
74const 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
86const 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 #[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 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 pub const fn status(&self) -> u16 {
199 self.status
200 }
201
202 pub const fn status_signal(&self) -> DefenseStatusSignal {
204 self.status_signal
205 }
206
207 pub const fn is_challenged(&self) -> bool {
209 self.challenged
210 }
211
212 pub const fn is_rate_limited(&self) -> bool {
214 self.rate_limited
215 }
216
217 pub const fn has_rate_limit_headers(&self) -> bool {
219 self.rate_limit_headers_present
220 }
221
222 pub const fn fingerprint(&self) -> Option<&DefenseFingerprint> {
224 self.fingerprint.as_ref()
225 }
226
227 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}