use regex::RegexSet;
use tracing::warn;
use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
use crate::Rule;
pub static SCANNER_RULES: &[Rule] = &[
Rule {
id: "scanner-tool-ua",
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",
severity: Severity::Critical,
paranoia: 1,
},
Rule {
id: "scanner-oob-interaction",
pattern: r"(?i)\b(?:burpcollaborator\.net|interact\.sh|oast\.(?:me|fun|pro|live|site|online))\b",
severity: Severity::Critical,
paranoia: 1,
},
];
#[derive(Default)]
pub struct ScannerModule {
rule_set: Option<RegexSet>,
active_rules: Vec<&'static Rule>,
}
impl ScannerModule {
pub fn new() -> Self {
Self::default()
}
}
impl WafModule for ScannerModule {
fn id(&self) -> &str {
"scanner"
}
fn phase(&self) -> Phase {
Phase::Headers
}
fn init(&mut self, cfg: &Config) {
let pl = cfg.waf.paranoia_level;
self.active_rules = SCANNER_RULES.iter().filter(|r| r.paranoia <= pl).collect();
self.rule_set = Some(
RegexSet::new(self.active_rules.iter().map(|r| r.pattern))
.expect("scanner rule compilation failed — check patterns at startup"),
);
}
fn inspect(&self, ctx: &RequestContext) -> Decision {
let Some(rule_set) = &self.rule_set else {
return Decision::Allow;
};
let mut hit = vec![false; rule_set.len()];
for (_, value) in ctx.normalized.headers.iter().filter(|(n, _)| n == "user-agent") {
for idx in rule_set.matches(value).into_iter() {
hit[idx] = true;
}
}
let items: Vec<ScoreItem> = hit
.iter()
.enumerate()
.filter_map(|(idx, &fired)| {
if !fired {
return None;
}
let rule = self.active_rules[idx];
warn!(
request_id = %ctx.request_id,
rule_id = %rule.id,
severity = ?rule.severity,
"scanner detection"
);
Some(ScoreItem {
rule_id: rule.id.to_string(),
severity: rule.severity,
})
})
.collect();
if items.is_empty() {
Decision::Allow
} else {
Decision::Scores(items)
}
}
}