Skip to main content

waf_detection/crs/
module.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`CrsModule`] — a [`WafModule`] backed by imported CRS/ModSecurity rules.
5//!
6//! ## Performance shape (paletto #2)
7//! The module is `structural()` so it runs on EVERY request — the content fast-path
8//! (`ContentPrefilter`) only knows the native patterns and cannot prove a CRS rule inert.
9//! To keep that affordable, single-link positive `@rx` rules (the bulk of CRS) are grouped
10//! into **fast buckets** keyed by `(variables, transform-pipeline)`: each bucket extracts +
11//! transforms its target values ONCE and runs a single [`RegexSet`] over them, instead of
12//! N regexes in a loop. Chains, negations and non-`@rx` operators fall to a per-rule slow
13//! path. So cost scales with the number of distinct `(vars, t:)` buckets, not rule count.
14
15use std::collections::BTreeMap;
16
17use regex::{Regex, RegexSet};
18use tracing::warn;
19use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
20
21use super::ast::{CrsRule, MatchUnit, ParsedRuleset, SkippedRule, Transform, Variable};
22use super::operator::{compile, rx_pattern, Matcher};
23use super::parse::parse;
24use super::{target, transform};
25
26/// One compiled match unit (head or chain child).
27struct Link {
28    vars: Vec<Variable>,
29    transforms: Vec<Transform>,
30    matcher: Matcher,
31    negated: bool,
32}
33
34/// A slow-path rule (chain / negated / non-`@rx`): all links must match.
35struct Compiled {
36    id: u64,
37    severity: Severity,
38    links: Vec<Link>,
39}
40
41/// A fast bucket of single-link positive `@rx` rules sharing the same `(vars, t:)`.
42struct FastBucket {
43    vars: Vec<Variable>,
44    transforms: Vec<Transform>,
45    set: RegexSet,
46    /// Aligned, index-for-index, with the patterns in `set`.
47    ids: Vec<u64>,
48    severities: Vec<Severity>,
49}
50
51/// Accumulator while grouping fast rules before the per-bucket [`RegexSet`] is built.
52struct BucketAcc {
53    vars: Vec<Variable>,
54    transforms: Vec<Transform>,
55    patterns: Vec<String>,
56    ids: Vec<u64>,
57    severities: Vec<Severity>,
58}
59
60/// A `WafModule` evaluating imported CRS/ModSecurity rules. Build with [`CrsModule::from_str`]
61/// (one source) — the proxy loader concatenates the configured files in include order and
62/// resolves the boot report.
63pub struct CrsModule {
64    fast: Vec<FastBucket>,
65    slow: Vec<Compiled>,
66    skipped: Vec<SkippedRule>,
67    loaded: usize,
68}
69
70impl CrsModule {
71    /// Parse + compile one `seclang` source (e.g. the configured files concatenated in
72    /// include order).
73    pub fn from_source(source: &str) -> Self {
74        Self::from_parsed(parse(source))
75    }
76
77    /// Compile an already-parsed ruleset (rules that fail to compile join `skipped`).
78    pub fn from_parsed(parsed: ParsedRuleset) -> Self {
79        let mut skipped = parsed.skipped;
80        let mut fast_map: BTreeMap<String, BucketAcc> = BTreeMap::new();
81        let mut slow: Vec<Compiled> = Vec::new();
82        let mut loaded = 0usize;
83
84        for rule in parsed.rules {
85            if let Some(pattern) = fast_pattern(&rule) {
86                // Validate the regex individually so a bad one is skipped by id (a
87                // wholesale RegexSet failure would not tell us which pattern broke).
88                if let Err(e) = Regex::new(pattern) {
89                    skipped.push(SkippedRule {
90                        id: Some(rule.id),
91                        line_no: rule.line_no,
92                        reason: format!("invalid @rx regex: {e}"),
93                    });
94                    continue;
95                }
96                let key = format!("{:?}|{:?}", rule.head.variables, rule.head.transforms);
97                let acc = fast_map.entry(key).or_insert_with(|| BucketAcc {
98                    vars: rule.head.variables.clone(),
99                    transforms: rule.head.transforms.clone(),
100                    patterns: Vec::new(),
101                    ids: Vec::new(),
102                    severities: Vec::new(),
103                });
104                acc.patterns.push(pattern.to_string());
105                acc.ids.push(rule.id);
106                acc.severities.push(rule.severity);
107                loaded += 1;
108            } else {
109                match compile_rule(&rule) {
110                    Ok(c) => {
111                        slow.push(c);
112                        loaded += 1;
113                    }
114                    Err(reason) => skipped.push(SkippedRule {
115                        id: Some(rule.id),
116                        line_no: rule.line_no,
117                        reason,
118                    }),
119                }
120            }
121        }
122
123        // Build one RegexSet per bucket. A very large bucket can exceed the compiled-size
124        // limit; rather than panic (a real CRS file must not crash the load) we degrade that
125        // bucket's rules to the per-rule slow path (each pattern was already validated).
126        let mut fast = Vec::with_capacity(fast_map.len());
127        for acc in fast_map.into_values() {
128            match RegexSet::new(&acc.patterns) {
129                Ok(set) => fast.push(FastBucket {
130                    vars: acc.vars,
131                    transforms: acc.transforms,
132                    set,
133                    ids: acc.ids,
134                    severities: acc.severities,
135                }),
136                Err(_) => {
137                    for (i, pattern) in acc.patterns.iter().enumerate() {
138                        if let Ok(re) = Regex::new(pattern) {
139                            slow.push(Compiled {
140                                id: acc.ids[i],
141                                severity: acc.severities[i],
142                                links: vec![Link {
143                                    vars: acc.vars.clone(),
144                                    transforms: acc.transforms.clone(),
145                                    matcher: Matcher::Rx(re),
146                                    negated: false,
147                                }],
148                            });
149                        }
150                    }
151                }
152            }
153        }
154
155        Self { fast, slow, skipped, loaded }
156    }
157
158    /// Number of rules successfully loaded (fast + slow).
159    pub fn loaded_count(&self) -> usize {
160        self.loaded
161    }
162
163    /// Rules that could not be loaded into the v1 subset (with reasons).
164    pub fn skipped(&self) -> &[SkippedRule] {
165        &self.skipped
166    }
167
168    /// One-line boot summary; the proxy logs this so the coverage gap is explicit (D3=A).
169    pub fn report(&self) -> String {
170        format!("CRS import: {} rules loaded, {} skipped", self.loaded, self.skipped.len())
171    }
172
173    /// Collect + transform the values for one `(vars, transforms)` pair (paletto #1: from raw).
174    fn values_for(vars: &[Variable], transforms: &[Transform], ctx: &RequestContext) -> Vec<String> {
175        target::collect_values(vars, ctx)
176            .into_iter()
177            .map(|v| transform::apply(&v, transforms))
178            .collect()
179    }
180}
181
182/// The `@rx` pattern of a fast-eligible rule (single link, positive, `@rx`), else `None`.
183fn fast_pattern(rule: &CrsRule) -> Option<&str> {
184    if rule.chain.is_empty() && !rule.head.negated {
185        rx_pattern(&rule.head.operator)
186    } else {
187        None
188    }
189}
190
191fn compile_rule(rule: &CrsRule) -> Result<Compiled, String> {
192    let mut links = Vec::with_capacity(1 + rule.chain.len());
193    links.push(compile_link(&rule.head)?);
194    for child in &rule.chain {
195        links.push(compile_link(child)?);
196    }
197    Ok(Compiled { id: rule.id, severity: rule.severity, links })
198}
199
200fn compile_link(u: &MatchUnit) -> Result<Link, String> {
201    Ok(Link {
202        vars: u.variables.clone(),
203        transforms: u.transforms.clone(),
204        matcher: compile(&u.operator)?,
205        negated: u.negated,
206    })
207}
208
209fn eval_compiled(c: &Compiled, ctx: &RequestContext) -> bool {
210    // Every link (head + chain children) must match for the rule to fire.
211    for link in &c.links {
212        let values = CrsModule::values_for(&link.vars, &link.transforms, ctx);
213        let any = values.iter().any(|v| link.matcher.matches(v));
214        // Negated operator (v1 approximation): the link matches when the operator does NOT
215        // match any value (the common `!@rx ^$` "not empty" intent). Documented limit.
216        let link_matches = if link.negated { !any } else { any };
217        if !link_matches {
218            return false;
219        }
220    }
221    true
222}
223
224fn push_item(items: &mut Vec<ScoreItem>, ctx: &RequestContext, id: u64, severity: Severity) {
225    let rule_id = format!("crs-{id}");
226    warn!(request_id = %ctx.request_id, rule_id = %rule_id, severity = ?severity, "crs detection");
227    items.push(ScoreItem { rule_id, severity });
228}
229
230impl WafModule for CrsModule {
231    fn id(&self) -> &str {
232        "crs"
233    }
234
235    fn phase(&self) -> Phase {
236        Phase::Body
237    }
238
239    fn init(&mut self, _cfg: &Config) {
240        // Rules are compiled at construction (`from_str`/`from_parsed`), before serving.
241    }
242
243    fn inspect(&self, ctx: &RequestContext) -> Decision {
244        let mut items: Vec<ScoreItem> = Vec::new();
245
246        // Fast buckets: one value-extraction + one RegexSet pass per bucket.
247        for b in &self.fast {
248            let values = Self::values_for(&b.vars, &b.transforms, ctx);
249            if values.is_empty() {
250                continue;
251            }
252            let mut hit = vec![false; b.ids.len()];
253            for v in &values {
254                for idx in b.set.matches(v).into_iter() {
255                    hit[idx] = true;
256                }
257            }
258            for (i, &h) in hit.iter().enumerate() {
259                if h {
260                    push_item(&mut items, ctx, b.ids[i], b.severities[i]);
261                }
262            }
263        }
264
265        // Slow path: chains / negations / non-@rx operators.
266        for c in &self.slow {
267            if eval_compiled(c, ctx) {
268                push_item(&mut items, ctx, c.id, c.severity);
269            }
270        }
271
272        if items.is_empty() {
273            Decision::Allow
274        } else {
275            Decision::Scores(items)
276        }
277    }
278
279    /// CRS rules carry external patterns the native `ContentPrefilter` does not know about,
280    /// so the fast-path cannot prove them inert → this module must run on every request.
281    fn structural(&self) -> bool {
282        true
283    }
284}