Skip to main content

termivar_scanner/defense/
fingerprint.rs

1//! Robust, deterministic defense-product fingerprinting from response signals.
2//!
3//! This module only *observes*. It infers which defensive product most likely
4//! produced a response from header and body signals; it never selects a payload
5//! or an evasion technique. Matching is case-insensitive and substring-based, so
6//! it does not depend on exact header strings the way a brittle equality check
7//! would.
8
9/// Maximum number of response-body bytes scanned for a fingerprint signal.
10///
11/// Fingerprinting reads only a bounded prefix so a large body cannot turn one
12/// observation into unbounded work.
13pub const MAX_FINGERPRINT_BODY_SCAN_BYTES: usize = 16 * 1024;
14
15/// Defensive product inferred from response signals.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum DefenseProduct {
19    /// Cloudflare edge and WAF.
20    Cloudflare,
21    /// AWS WAF or an AWS edge in front of the origin.
22    AwsWaf,
23    /// ModSecurity or a ModSecurity-based rule set.
24    ModSecurity,
25    /// Akamai edge and Kona Site Defender.
26    Akamai,
27    /// Imperva / Incapsula.
28    Imperva,
29    /// F5 BIG-IP ASM.
30    F5BigIp,
31    /// Barracuda Web Application Firewall.
32    Barracuda,
33    /// Fortinet FortiWeb.
34    Fortinet,
35    /// Sucuri CloudProxy.
36    Sucuri,
37    /// Wordfence for WordPress.
38    Wordfence,
39}
40
41impl DefenseProduct {
42    /// Returns the stable, human-readable product name.
43    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/// Confidence that a fingerprint reflects the named product.
60///
61/// Ordering is meaningful: a stronger match supersedes a weaker one.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
63pub enum FingerprintConfidence {
64    /// A single ambiguous signal that could also occur without the product.
65    Weak,
66    /// A signal that usually, but not exclusively, indicates the product.
67    Probable,
68    /// A signal that is specific to the product.
69    Strong,
70}
71
72/// One product fingerprint together with the signal that produced it.
73#[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    /// Returns the inferred product.
93    pub const fn product(&self) -> DefenseProduct {
94        self.product
95    }
96
97    /// Returns how strongly the signal indicates the product.
98    pub const fn confidence(&self) -> FingerprintConfidence {
99        self.confidence
100    }
101
102    /// Returns a stable label for the signal that produced this fingerprint.
103    pub const fn signal(&self) -> &'static str {
104        self.signal
105    }
106}
107
108/// A single, deterministic fingerprint rule.
109enum Match {
110    /// A header with this lowercase name is present with any value.
111    HeaderPresent(&'static str),
112    /// A header with this lowercase name has a value containing `needle`.
113    HeaderValueContains(&'static str, &'static str),
114    /// Any `set-cookie` header value contains `needle`.
115    CookieContains(&'static str),
116    /// The scanned body prefix contains `needle`.
117    BodyContains(&'static str),
118}
119
120struct Rule {
121    product: DefenseProduct,
122    confidence: FingerprintConfidence,
123    signal: &'static str,
124    matcher: Match,
125}
126
127/// Deterministic rule table. Needles are lowercase; matching lowercases inputs.
128///
129/// AWS signals are intentionally conservative: an Amazon request id or S3 server
130/// banner indicates Amazon infrastructure, not necessarily a WAF, so it is only
131/// ever a weak signal.
132const 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
225/// Returns the strongest defense-product fingerprint for one response, if any.
226///
227/// `headers` is a transport-neutral list of `(name, value)` pairs; names are
228/// matched case-insensitively. Ties at equal confidence are broken by rule
229/// order, so the result is deterministic for identical inputs.
230pub 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
277/// Lowercases at most `max_bytes` of `body` at a UTF-8 boundary.
278fn 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        // A weak Amazon request id alone is only weak.
306        let weak = fingerprint(&[("x-amzn-requestid", "abc")], "").unwrap();
307        assert_eq!(weak.product(), DefenseProduct::AwsWaf);
308        assert_eq!(weak.confidence(), FingerprintConfidence::Weak);
309
310        // A specific WAF action header is preferred over the weak id.
311        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        // The brittle legacy check inferred AWS WAF from `Server: AmazonS3`.
322        // A plain S3 banner is not a WAF signal, so nothing matches.
323        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        // The needle sits past the scan ceiling, so it is not observed.
349        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}