Skip to main content

waf_detection/
scanner.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4use regex::RegexSet;
5use tracing::warn;
6use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
7
8use crate::Rule;
9
10// ── rules (Fase 10a) ────────────────────────────────────────────────────────────
11//
12// Automated-scanner / security-tool fingerprinting via the request User-Agent. A
13// benign client never advertises a scanner name or an out-of-band interaction
14// domain in its UA, so these are unequivocal signals → Critical, block-alone
15// (decision 3). Two families:
16//   - the tool's own fingerprint string (sqlmap, nuclei, OpenVAS, ffuf, …, `.nasl`);
17//   - OOB callback domains (Burp Collaborator / interactsh / oast.*) embedded in the
18//     UA, including the `${jndi:ldap://…oast.me/…}` log4shell-in-UA spray (caught by
19//     the OOB domain, not a JNDI-specific rule).
20//
21// Scope is the **User-Agent header value only** (the module name's contract): the
22// fingerprints would over-match other free-text headers (e.g. a `nmap.org` Referer),
23// and the live attack surface this module owns is the UA. The content prefilter
24// over-scans all headers with these patterns — sound (it can only over-flag).
25
26pub static SCANNER_RULES: &[Rule] = &[
27    Rule {
28        id: "scanner-tool-ua",
29        // Known offensive-security tool fingerprints. `\b` anchors keep `nmap` from
30        // matching `roadmap`/`sitemap`. `fuzz faster u fool` is ffuf's UA string;
31        // `\.nasl` is the Nessus/OpenVAS plugin script suffix. `openvas\w*` tolerates a
32        // glued suffix — gotestwaf's real UA is `…OpenVASVT` (no separator), which a bare
33        // `openvas\b` missed (10c REOPEN, pcap-confirmed); the prefix is unambiguous.
34        pattern: r"(?i)\b(?:sqlmap|nikto|nuclei|openvas\w*|nessus|acunetix|netsparker|wpscan|dirbuster|gobuster|masscan|nmap|hydra|w3af|arachni|zaproxy|burpsuite|fuzz faster u fool|ffuf|wfuzz)\b|\.nasl\b",
35        severity: Severity::Critical,
36        paranoia: 1,
37    },
38    Rule {
39        id: "scanner-oob-interaction",
40        // Out-of-band interaction domains used by scanners / OOB payloads to confirm
41        // blind execution (Burp Collaborator, interactsh, the oast.* family).
42        pattern: r"(?i)\b(?:burpcollaborator\.net|interact\.sh|oast\.(?:me|fun|pro|live|site|online))\b",
43        severity: Severity::Critical,
44        paranoia: 1,
45    },
46];
47
48// ── module ──────────────────────────────────────────────────────────────────────
49
50#[derive(Default)]
51pub struct ScannerModule {
52    rule_set: Option<RegexSet>,
53    /// Rules active at the configured paranoia level, index-aligned with `rule_set`.
54    active_rules: Vec<&'static Rule>,
55}
56
57impl ScannerModule {
58    pub fn new() -> Self {
59        Self::default()
60    }
61}
62
63impl WafModule for ScannerModule {
64    fn id(&self) -> &str {
65        "scanner"
66    }
67
68    fn phase(&self) -> Phase {
69        // Ordering hint only; inspection reads the User-Agent header value.
70        Phase::Headers
71    }
72
73    fn init(&mut self, cfg: &Config) {
74        let pl = cfg.waf.paranoia_level;
75        self.active_rules = SCANNER_RULES.iter().filter(|r| r.paranoia <= pl).collect();
76        self.rule_set = Some(
77            RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
78                .expect("scanner rule compilation failed — check patterns at startup"),
79        );
80    }
81
82    fn inspect(&self, ctx: &RequestContext) -> Decision {
83        let Some(rule_set) = &self.rule_set else {
84            return Decision::Allow;
85        };
86
87        // Scope: User-Agent header value(s) only (see module docs).
88        let mut hit = vec![false; rule_set.len()];
89        for (_, value) in ctx.normalized.headers.iter().filter(|(n, _)| n == "user-agent") {
90            for idx in rule_set.matches(value).into_iter() {
91                hit[idx] = true;
92            }
93        }
94
95        let items: Vec<ScoreItem> = hit
96            .iter()
97            .enumerate()
98            .filter_map(|(idx, &fired)| {
99                if !fired {
100                    return None;
101                }
102                let rule = self.active_rules[idx];
103                warn!(
104                    request_id = %ctx.request_id,
105                    rule_id = %rule.id,
106                    severity = ?rule.severity,
107                    "scanner detection"
108                );
109                Some(ScoreItem {
110                    rule_id: rule.id.to_string(),
111                    severity: rule.severity,
112                })
113            })
114            .collect();
115
116        if items.is_empty() {
117            Decision::Allow
118        } else {
119            Decision::Scores(items)
120        }
121    }
122}