1pub const MAX_FINGERPRINT_BODY_SCAN_BYTES: usize = 16 * 1024;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum DefenseProduct {
19 Cloudflare,
21 AwsWaf,
23 ModSecurity,
25 Akamai,
27 Imperva,
29 F5BigIp,
31 Barracuda,
33 Fortinet,
35 Sucuri,
37 Wordfence,
39}
40
41impl DefenseProduct {
42 pub const fn name(self) -> &'static str {
44 match self {
45 Self::Cloudflare => "Cloudflare",
46 Self::AwsWaf => "AWS WAF",
47 Self::ModSecurity => "ModSecurity",
48 Self::Akamai => "Akamai",
49 Self::Imperva => "Imperva",
50 Self::F5BigIp => "F5 BIG-IP ASM",
51 Self::Barracuda => "Barracuda",
52 Self::Fortinet => "Fortinet FortiWeb",
53 Self::Sucuri => "Sucuri",
54 Self::Wordfence => "Wordfence",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum FingerprintConfidence {
64 Weak,
66 Probable,
68 Strong,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct DefenseFingerprint {
75 product: DefenseProduct,
76 confidence: FingerprintConfidence,
77 signal: &'static str,
78}
79
80impl DefenseFingerprint {
81 pub(crate) const fn from_assessment_hint(
82 product: DefenseProduct,
83 confidence: FingerprintConfidence,
84 ) -> Self {
85 Self {
86 product,
87 confidence,
88 signal: "assessment:fingerprint-hint",
89 }
90 }
91
92 pub const fn product(&self) -> DefenseProduct {
94 self.product
95 }
96
97 pub const fn confidence(&self) -> FingerprintConfidence {
99 self.confidence
100 }
101
102 pub const fn signal(&self) -> &'static str {
104 self.signal
105 }
106}
107
108enum Match {
110 HeaderPresent(&'static str),
112 HeaderValueContains(&'static str, &'static str),
114 CookieContains(&'static str),
116 BodyContains(&'static str),
118}
119
120struct Rule {
121 product: DefenseProduct,
122 confidence: FingerprintConfidence,
123 signal: &'static str,
124 matcher: Match,
125}
126
127const RULES: &[Rule] = &[
133 Rule {
134 product: DefenseProduct::Cloudflare,
135 confidence: FingerprintConfidence::Strong,
136 signal: "header:cf-ray",
137 matcher: Match::HeaderPresent("cf-ray"),
138 },
139 Rule {
140 product: DefenseProduct::Cloudflare,
141 confidence: FingerprintConfidence::Strong,
142 signal: "header:server=cloudflare",
143 matcher: Match::HeaderValueContains("server", "cloudflare"),
144 },
145 Rule {
146 product: DefenseProduct::Cloudflare,
147 confidence: FingerprintConfidence::Probable,
148 signal: "body:attention-required",
149 matcher: Match::BodyContains("attention required"),
150 },
151 Rule {
152 product: DefenseProduct::AwsWaf,
153 confidence: FingerprintConfidence::Probable,
154 signal: "header:x-amzn-waf",
155 matcher: Match::HeaderPresent("x-amzn-waf-action"),
156 },
157 Rule {
158 product: DefenseProduct::AwsWaf,
159 confidence: FingerprintConfidence::Weak,
160 signal: "header:x-amzn-requestid",
161 matcher: Match::HeaderPresent("x-amzn-requestid"),
162 },
163 Rule {
164 product: DefenseProduct::ModSecurity,
165 confidence: FingerprintConfidence::Strong,
166 signal: "header:server=mod_security",
167 matcher: Match::HeaderValueContains("server", "mod_security"),
168 },
169 Rule {
170 product: DefenseProduct::ModSecurity,
171 confidence: FingerprintConfidence::Strong,
172 signal: "body:mod_security",
173 matcher: Match::BodyContains("mod_security"),
174 },
175 Rule {
176 product: DefenseProduct::Akamai,
177 confidence: FingerprintConfidence::Strong,
178 signal: "header:server=akamaighost",
179 matcher: Match::HeaderValueContains("server", "akamaighost"),
180 },
181 Rule {
182 product: DefenseProduct::Imperva,
183 confidence: FingerprintConfidence::Strong,
184 signal: "header:x-iinfo",
185 matcher: Match::HeaderPresent("x-iinfo"),
186 },
187 Rule {
188 product: DefenseProduct::Imperva,
189 confidence: FingerprintConfidence::Strong,
190 signal: "cookie:incap_ses",
191 matcher: Match::CookieContains("incap_ses"),
192 },
193 Rule {
194 product: DefenseProduct::F5BigIp,
195 confidence: FingerprintConfidence::Strong,
196 signal: "cookie:bigipserver",
197 matcher: Match::CookieContains("bigipserver"),
198 },
199 Rule {
200 product: DefenseProduct::Barracuda,
201 confidence: FingerprintConfidence::Strong,
202 signal: "cookie:barra_counter",
203 matcher: Match::CookieContains("barra_counter_session"),
204 },
205 Rule {
206 product: DefenseProduct::Fortinet,
207 confidence: FingerprintConfidence::Strong,
208 signal: "cookie:fortiwafsid",
209 matcher: Match::CookieContains("fortiwafsid"),
210 },
211 Rule {
212 product: DefenseProduct::Sucuri,
213 confidence: FingerprintConfidence::Strong,
214 signal: "header:x-sucuri-id",
215 matcher: Match::HeaderPresent("x-sucuri-id"),
216 },
217 Rule {
218 product: DefenseProduct::Wordfence,
219 confidence: FingerprintConfidence::Strong,
220 signal: "body:generated-by-wordfence",
221 matcher: Match::BodyContains("generated by wordfence"),
222 },
223];
224
225pub fn fingerprint(headers: &[(&str, &str)], body: &str) -> Option<DefenseFingerprint> {
231 let body_prefix = lowercase_prefix(body, MAX_FINGERPRINT_BODY_SCAN_BYTES);
232
233 let mut best: Option<&Rule> = None;
234 for rule in RULES {
235 if !rule_matches(rule, headers, &body_prefix) {
236 continue;
237 }
238 if best.is_none_or(|current| rule.confidence > current.confidence) {
239 best = Some(rule);
240 }
241 }
242
243 best.map(|rule| DefenseFingerprint {
244 product: rule.product,
245 confidence: rule.confidence,
246 signal: rule.signal,
247 })
248}
249
250fn rule_matches(rule: &Rule, headers: &[(&str, &str)], body_prefix: &str) -> bool {
251 match &rule.matcher {
252 Match::HeaderPresent(name) => header_present(headers, name),
253 Match::HeaderValueContains(name, needle) => header_value_contains(headers, name, needle),
254 Match::CookieContains(needle) => cookie_contains(headers, needle),
255 Match::BodyContains(needle) => body_prefix.contains(needle),
256 }
257}
258
259fn header_present(headers: &[(&str, &str)], lower_name: &str) -> bool {
260 headers
261 .iter()
262 .any(|(name, _)| name.eq_ignore_ascii_case(lower_name))
263}
264
265fn header_value_contains(headers: &[(&str, &str)], lower_name: &str, lower_needle: &str) -> bool {
266 headers.iter().any(|(name, value)| {
267 name.eq_ignore_ascii_case(lower_name) && value.to_ascii_lowercase().contains(lower_needle)
268 })
269}
270
271fn cookie_contains(headers: &[(&str, &str)], lower_needle: &str) -> bool {
272 headers.iter().any(|(name, value)| {
273 name.eq_ignore_ascii_case("set-cookie") && value.to_ascii_lowercase().contains(lower_needle)
274 })
275}
276
277fn lowercase_prefix(body: &str, max_bytes: usize) -> String {
279 if body.len() <= max_bytes {
280 return body.to_ascii_lowercase();
281 }
282 let mut end = max_bytes;
283 while end > 0 && !body.is_char_boundary(end) {
284 end -= 1;
285 }
286 body[..end].to_ascii_lowercase()
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn matches_headers_case_insensitively() {
295 let strong = fingerprint(&[("CF-RAY", "7d1e-LHR")], "").unwrap();
296 assert_eq!(strong.product(), DefenseProduct::Cloudflare);
297 assert_eq!(strong.confidence(), FingerprintConfidence::Strong);
298
299 let server = fingerprint(&[("Server", "CloudFlare")], "").unwrap();
300 assert_eq!(server.product(), DefenseProduct::Cloudflare);
301 }
302
303 #[test]
304 fn stronger_signal_supersedes_weaker_one() {
305 let weak = fingerprint(&[("x-amzn-requestid", "abc")], "").unwrap();
307 assert_eq!(weak.product(), DefenseProduct::AwsWaf);
308 assert_eq!(weak.confidence(), FingerprintConfidence::Weak);
309
310 let strong = fingerprint(
312 &[("x-amzn-requestid", "abc"), ("x-amzn-waf-action", "block")],
313 "",
314 )
315 .unwrap();
316 assert_eq!(strong.confidence(), FingerprintConfidence::Probable);
317 }
318
319 #[test]
320 fn amazon_s3_banner_is_not_treated_as_a_waf() {
321 assert!(fingerprint(&[("Server", "AmazonS3")], "").is_none());
324 }
325
326 #[test]
327 fn cookie_and_body_signals_match() {
328 let f5 = fingerprint(&[("Set-Cookie", "BIGipServerpool=123; path=/")], "").unwrap();
329 assert_eq!(f5.product(), DefenseProduct::F5BigIp);
330
331 let body = fingerprint(&[], "This page is Generated by Wordfence for security").unwrap();
332 assert_eq!(body.product(), DefenseProduct::Wordfence);
333 }
334
335 #[test]
336 fn no_signal_returns_none() {
337 assert!(fingerprint(
338 &[("Server", "nginx"), ("Content-Type", "text/html")],
339 "hello"
340 )
341 .is_none());
342 }
343
344 #[test]
345 fn body_scan_is_bounded_and_deterministic() {
346 let mut body = "x".repeat(MAX_FINGERPRINT_BODY_SCAN_BYTES);
347 body.push_str("mod_security");
348 assert!(fingerprint(&[], &body).is_none());
350
351 let mut within = "y".repeat(64);
352 within.push_str("mod_security");
353 let hit = fingerprint(&[], &within).unwrap();
354 assert_eq!(hit.product(), DefenseProduct::ModSecurity);
355 }
356}