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}