Skip to main content

waf_detection/crs/
operator.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Compiled ModSecurity operators (v1 subset). `@rx` compiles to a [`regex::Regex`] at
5//! load time (never in `inspect`); the string operators keep their argument.
6
7use regex::Regex;
8
9use super::ast::Operator;
10
11/// A compiled operator ready to test a (already transformed) value.
12#[derive(Debug)]
13pub enum Matcher {
14    Rx(Regex),
15    /// `@pm` — lowercased phrases; matches if the value contains ANY (case-insensitive).
16    Pm(Vec<String>),
17    Contains(String),
18    BeginsWith(String),
19    EndsWith(String),
20    /// `@within` — the value must be a substring of the operator's parameter set.
21    Within(String),
22    StrEq(String),
23}
24
25impl Matcher {
26    /// Does this operator match `value`? (`value` has already had the rule's `t:` applied.)
27    pub fn matches(&self, value: &str) -> bool {
28        match self {
29            Matcher::Rx(re) => re.is_match(value),
30            Matcher::Pm(phrases) => {
31                let lower = value.to_ascii_lowercase();
32                phrases.iter().any(|p| lower.contains(p.as_str()))
33            }
34            Matcher::Contains(s) => value.contains(s.as_str()),
35            Matcher::BeginsWith(s) => value.starts_with(s.as_str()),
36            Matcher::EndsWith(s) => value.ends_with(s.as_str()),
37            Matcher::Within(set) => set.contains(value),
38            Matcher::StrEq(s) => value == s,
39        }
40    }
41}
42
43/// Compile a parsed [`Operator`] into a [`Matcher`]. `Err` (invalid regex / an operator
44/// that should have been resolved/skipped earlier) makes the loader skip the rule.
45pub fn compile(op: &Operator) -> Result<Matcher, String> {
46    match op {
47        Operator::Rx(pattern) => Regex::new(pattern)
48            .map(Matcher::Rx)
49            .map_err(|e| format!("invalid @rx regex: {e}")),
50        Operator::Pm(phrases) => {
51            Ok(Matcher::Pm(phrases.iter().map(|p| p.to_ascii_lowercase()).collect()))
52        }
53        // `@pmf`/`@pmFromFile` would need per-file base-dir resolution of the phrase file;
54        // not modelled in v1 → the rule is skipped (use inline `@pm` instead).
55        Operator::PmFromFile(_) => {
56            Err("@pmFromFile not supported in v1 (use inline @pm)".to_string())
57        }
58        Operator::Contains(s) => Ok(Matcher::Contains(s.clone())),
59        Operator::BeginsWith(s) => Ok(Matcher::BeginsWith(s.clone())),
60        Operator::EndsWith(s) => Ok(Matcher::EndsWith(s.clone())),
61        Operator::Within(s) => Ok(Matcher::Within(s.clone())),
62        Operator::StrEq(s) => Ok(Matcher::StrEq(s.clone())),
63        Operator::Unsupported(name) => Err(format!("unsupported operator @{name}")),
64    }
65}
66
67/// The raw `@rx` pattern, if this operator is a regex (used for fast-bucket RegexSets).
68pub fn rx_pattern(op: &Operator) -> Option<&str> {
69    match op {
70        Operator::Rx(p) => Some(p.as_str()),
71        _ => None,
72    }
73}