Skip to main content

zentinel_modsec/engine/
ruleset.rs

1//! Compiled ruleset for efficient rule matching.
2
3use crate::error::Result;
4use crate::operators::{compile_operator, Operator};
5use crate::parser::{
6    Action, Directive, FlowAction, MetadataAction, OperatorName, OperatorSpec, Parser,
7    RuleEngineMode as ParserRuleEngineMode, RuleIdSelector, Selection, UpdateTargetById,
8    VariableName, VariableSpec, XmlTarget,
9};
10use crate::transformations::TransformationPipeline;
11
12use super::phase::Phase;
13use std::collections::HashMap;
14use std::sync::Arc;
15
16/// A parsed SecRule ready for execution.
17#[derive(Clone)]
18pub struct CompiledRule {
19    /// Rule ID.
20    pub id: Option<String>,
21    /// Rule phase.
22    pub phase: Phase,
23    /// Variable specifications.
24    pub variables: Vec<VariableSpec>,
25    /// Compiled operator.
26    pub operator: Arc<dyn Operator>,
27    /// Original operator specification (retained for runtime macro expansion).
28    pub operator_spec: OperatorSpec,
29    /// Whether operator is negated.
30    pub operator_negated: bool,
31    /// Transformation pipeline.
32    pub transformations: TransformationPipeline,
33    /// Actions to execute on match.
34    pub actions: Vec<Action>,
35    /// Whether this rule is part of a chain.
36    pub is_chain: bool,
37    /// Index of next rule in chain (if any).
38    pub chain_next: Option<usize>,
39}
40
41impl std::fmt::Debug for CompiledRule {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("CompiledRule")
44            .field("id", &self.id)
45            .field("phase", &self.phase)
46            .field("variables", &self.variables)
47            .field("operator_negated", &self.operator_negated)
48            .field("is_chain", &self.is_chain)
49            .finish()
50    }
51}
52
53/// Rules grouped by phase for efficient processing.
54pub struct Rules {
55    /// Rules organized by phase.
56    by_phase: HashMap<Phase, Vec<CompiledRule>>,
57    /// Markers for skipAfter, as the index the marker occupies in *each*
58    /// phase's rule list. A marker separates rules in every phase, not only in
59    /// the phase of the rules written around it, so a phase-2 `skipAfter` must
60    /// be able to resume at the phase-2 position of the same marker.
61    markers: HashMap<String, HashMap<Phase, usize>>,
62}
63
64impl Rules {
65    /// Create empty rules.
66    pub fn new() -> Self {
67        Self {
68            by_phase: HashMap::new(),
69            markers: HashMap::new(),
70        }
71    }
72
73    /// Add a rule to a specific phase.
74    pub fn add(&mut self, phase: Phase, rule: CompiledRule) {
75        self.by_phase.entry(phase).or_default().push(rule);
76    }
77
78    /// Record a marker at the current end of every phase's rule list.
79    pub fn add_marker(&mut self, name: String) {
80        let positions = Phase::ALL
81            .iter()
82            .map(|&phase| (phase, self.by_phase.get(&phase).map_or(0, |v| v.len())))
83            .collect();
84        self.markers.insert(name, positions);
85    }
86
87    /// Get rules for a phase.
88    pub fn for_phase(&self, phase: Phase) -> &[CompiledRule] {
89        self.by_phase.get(&phase).map(|v| v.as_slice()).unwrap_or(&[])
90    }
91
92    /// Get a marker's position within one phase.
93    pub fn marker(&self, name: &str, phase: Phase) -> Option<usize> {
94        self.markers.get(name).and_then(|p| p.get(&phase)).copied()
95    }
96
97    /// Get total rule count.
98    pub fn count(&self) -> usize {
99        self.by_phase.values().map(|v| v.len()).sum()
100    }
101}
102
103impl Default for Rules {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// A fully compiled ruleset ready for transaction processing.
110pub struct CompiledRuleset {
111    /// Compiled rules.
112    rules: Rules,
113    /// Rule engine mode.
114    engine_mode: RuleEngineMode,
115}
116
117/// Rule engine operating mode.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RuleEngineMode {
120    /// Rules are enabled and will block.
121    On,
122    /// Rules are enabled but will only detect.
123    DetectionOnly,
124    /// Rules are disabled.
125    Off,
126}
127
128impl Default for RuleEngineMode {
129    fn default() -> Self {
130        RuleEngineMode::On
131    }
132}
133
134impl CompiledRuleset {
135    /// Create an empty ruleset.
136    pub fn new() -> Self {
137        Self {
138            rules: Rules::new(),
139            engine_mode: RuleEngineMode::default(),
140        }
141    }
142
143    /// Load and compile rules from a file.
144    pub fn from_file(path: &str) -> Result<Self> {
145        let mut parser = Parser::new();
146        parser.parse_file(std::path::Path::new(path))?;
147        Self::compile(parser.into_directives())
148    }
149
150    /// Load and compile rules from a string.
151    pub fn from_string(rules: &str) -> Result<Self> {
152        let mut parser = Parser::new();
153        parser.parse(rules)?;
154        Self::compile(parser.into_directives())
155    }
156
157    /// Compile parsed directives into a ruleset.
158    pub fn compile(directives: Vec<Directive>) -> Result<Self> {
159        let mut ruleset = Self::new();
160        let mut pending_chain: Option<(Phase, usize)> = None;
161
162        // Report ctl: directives this engine cannot honour, once, at load time.
163        //
164        // This is a warning rather than an error on purpose: CRS ships
165        // ctl:requestBodyProcessor=JSON and ctl:auditLogParts, so rejecting
166        // them would make CRS unloadable. But an operator has to be told at
167        // startup, because the alternative -- finding out from a rule that
168        // never fired -- is the failure this reporting exists to prevent.
169        report_unsupported_controls(&directives);
170
171        // SecRuleRemoveById and SecRuleUpdateTargetById are applied against
172        // the whole ruleset once loading completes (like ModSecurity, where
173        // they modify already-defined rules). Removals are collected up front
174        // so removed rules are never compiled; target updates are applied
175        // after the compile loop.
176        let removals: Vec<RuleIdSelector> = directives
177            .iter()
178            .filter_map(|d| match d {
179                Directive::SecRuleRemoveById(ids) => Some(ids.iter().copied()),
180                _ => None,
181            })
182            .flatten()
183            .collect();
184        let mut target_updates: Vec<UpdateTargetById> = Vec::new();
185        // When a removed rule is a chain head, its continuation rules (which
186        // usually carry no id of their own) must be dropped with it.
187        let mut skipping_removed_chain = false;
188
189        for directive in directives {
190            match directive {
191                Directive::SecRuleEngine(mode) => {
192                    ruleset.engine_mode = match mode {
193                        ParserRuleEngineMode::On => RuleEngineMode::On,
194                        ParserRuleEngineMode::Off => RuleEngineMode::Off,
195                        ParserRuleEngineMode::DetectionOnly => RuleEngineMode::DetectionOnly,
196                    };
197                }
198                Directive::SecRule(rule) => {
199                    // A chained rule inherits the phase of its chain starter.
200                    // ModSecurity does not allow a `phase` action on
201                    // continuation rules, so deriving it from the rule's own
202                    // actions would drop every continuation into the default
203                    // phase and split the chain across two phases.
204                    let phase = match pending_chain {
205                        Some((chain_phase, _)) => chain_phase,
206                        None => extract_phase(&rule.actions),
207                    };
208                    let id = extract_id(&rule.actions);
209                    let is_chain = has_chain(&rule.actions);
210
211                    if skipping_removed_chain {
212                        // Continuation of a removed chained rule.
213                        skipping_removed_chain = is_chain;
214                        continue;
215                    }
216                    if id_is_removed(&id, &removals) {
217                        skipping_removed_chain = is_chain;
218                        continue;
219                    }
220
221                    let transformations = extract_transformations(&rule.actions)?;
222
223                    report_unimplemented_variables(&rule.variables, &id);
224
225                    let operator_spec = rule.operator.clone();
226                    let (operator, operator_negated) =
227                        compile_operator_reporting(&rule.operator, &id)?;
228
229                    let compiled = CompiledRule {
230                        id,
231                        phase,
232                        variables: rule.variables,
233                        operator,
234                        operator_negated,
235                        operator_spec,
236                        transformations,
237                        actions: rule.actions,
238                        is_chain,
239                        chain_next: None,
240                    };
241
242                    let rules_for_phase = ruleset.rules.by_phase.entry(phase).or_default();
243                    let idx = rules_for_phase.len();
244                    rules_for_phase.push(compiled);
245
246                    // Handle chaining
247                    if let Some((chain_phase, chain_idx)) = pending_chain.take() {
248                        if chain_phase == phase {
249                            if let Some(prev_rule) = ruleset.rules.by_phase
250                                .get_mut(&chain_phase)
251                                .and_then(|r| r.get_mut(chain_idx))
252                            {
253                                prev_rule.chain_next = Some(idx);
254                            }
255                        }
256                    }
257
258                    if is_chain {
259                        pending_chain = Some((phase, idx));
260                    }
261                }
262                Directive::SecAction(sec_action) => {
263                    // SecAction is like a rule that always matches
264                    let phase = extract_phase(&sec_action.actions);
265                    let id = extract_id(&sec_action.actions);
266                    let transformations = extract_transformations(&sec_action.actions)?;
267
268                    // Create a rule with unconditional match operator
269                    let operator_spec = OperatorSpec {
270                        negated: false,
271                        name: OperatorName::UnconditionalMatch,
272                        argument: String::new(),
273                    };
274                    let operator = compile_operator(&operator_spec)?;
275
276                    let compiled = CompiledRule {
277                        id,
278                        phase,
279                        variables: vec![],
280                        operator,
281                        operator_negated: false,
282                        operator_spec,
283                        transformations,
284                        actions: sec_action.actions,
285                        is_chain: false,
286                        chain_next: None,
287                    };
288
289                    ruleset.rules.add(phase, compiled);
290                }
291                Directive::SecRuleUpdateTargetById(update) => {
292                    // Applied after every rule is compiled: the directive may
293                    // appear before or after the rule it targets, and CRS
294                    // exclusion files are conventionally included last.
295                    target_updates.push(update.clone());
296                }
297                Directive::SecMarker(marker) => {
298                    ruleset.rules.add_marker(marker.name);
299                }
300                _ => {
301                    // Other directives (SecDefaultAction, etc.) handled elsewhere
302                }
303            }
304        }
305
306        apply_target_updates(&mut ruleset, &target_updates);
307
308        Ok(ruleset)
309    }
310
311    /// Get rules for a phase.
312    pub fn rules_for_phase(&self, phase: Phase) -> &[CompiledRule] {
313        self.rules.for_phase(phase)
314    }
315
316    /// Get total rule count.
317    pub fn rule_count(&self) -> usize {
318        self.rules.count()
319    }
320
321    /// Get engine mode.
322    pub fn engine_mode(&self) -> RuleEngineMode {
323        self.engine_mode
324    }
325
326    /// Get a marker's position within one phase.
327    pub fn marker(&self, name: &str, phase: Phase) -> Option<usize> {
328        self.rules.marker(name, phase)
329    }
330}
331
332impl Default for CompiledRuleset {
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338/// Extract phase from actions, defaulting to Phase 2.
339fn extract_phase(actions: &[Action]) -> Phase {
340    for action in actions {
341        if let Action::Metadata(MetadataAction::Phase(p)) = action {
342            return Phase::from_number(*p).unwrap_or(Phase::RequestBody);
343        }
344    }
345    Phase::RequestBody // ModSecurity default
346}
347
348/// Extract rule ID from actions.
349
350/// Check whether a rule's ID is covered by any `SecRuleRemoveById` selector.
351///
352/// Rules without an ID cannot be targeted by ID, and an ID that does not parse
353/// as a number never matches a numeric selector.
354fn id_is_removed(id: &Option<String>, removals: &[RuleIdSelector]) -> bool {
355    let Some(numeric) = id.as_ref().and_then(|s| s.parse::<u64>().ok()) else {
356        return false;
357    };
358    removals.iter().any(|selector| selector.matches(numeric))
359}
360
361/// Apply `SecRuleUpdateTargetById` directives to the compiled rules.
362///
363/// ModSecurity semantics, as CRS exclusion files rely on them:
364///
365/// - `SecRuleUpdateTargetById 942100 "!ARGS:password"` adds a target exclusion,
366///   so the rule stops inspecting that target. The exclusion is pushed onto
367///   every variable of the rule, because the resolver applies exclusions
368///   per-variable when expanding collections.
369/// - `SecRuleUpdateTargetById 942100 "ARGS:foo"` appends a target.
370/// - `SecRuleUpdateTargetById 942100 "ARGS:foo" "ARGS:bar"` replaces the
371///   `ARGS:bar` target with `ARGS:foo`.
372///
373/// The update applies to the rule carrying the ID. For a chained rule that is
374/// the chain starter, matching ModSecurity, which identifies a chain by the
375/// starter's ID.
376fn apply_target_updates(ruleset: &mut CompiledRuleset, updates: &[UpdateTargetById]) {
377    if updates.is_empty() {
378        return;
379    }
380    for rules in ruleset.rules.by_phase.values_mut() {
381        for rule in rules.iter_mut() {
382            let Some(numeric) = rule.id.as_ref().and_then(|s| s.parse::<u64>().ok()) else {
383                continue;
384            };
385            for update in updates {
386                if !update.ids.iter().any(|selector| selector.matches(numeric)) {
387                    continue;
388                }
389                if let Some(replaced) = &update.replaced {
390                    rule.variables
391                        .retain(|var| !variable_matches_target(var, replaced));
392                }
393                for exclusion in &update.exclusions {
394                    for var in rule.variables.iter_mut() {
395                        if !var.exclusions.iter().any(|e| e == exclusion) {
396                            var.exclusions.push(exclusion.clone());
397                        }
398                    }
399                }
400                rule.variables.extend(update.additions.iter().cloned());
401            }
402        }
403    }
404}
405
406/// Whether a rule variable refers to the target named by a directive argument
407/// such as `ARGS:bar` or `REQUEST_HEADERS`.
408fn variable_matches_target(var: &VariableSpec, target: &str) -> bool {
409    // Parse the directive's target with the same parser used for rule
410    // variables, so collection names round-trip correctly. Comparing the
411    // enum's Debug output instead would work only for single-word names:
412    // SecLang writes REQUEST_HEADERS where the variant renders as
413    // "RequestHeaders".
414    let Ok(parsed) = crate::parser::parse_single_variable(target) else {
415        return false;
416    };
417    if var.name != parsed.name {
418        return false;
419    }
420    match (&var.selection, &parsed.selection) {
421        (None, None) => true,
422        (Some(Selection::Key(existing)), Some(Selection::Key(wanted))) => {
423            existing.eq_ignore_ascii_case(wanted.as_str())
424        }
425        _ => false,
426    }
427}
428
429
430/// Compile a rule's operator, reporting an unusable `@rx` pattern rather than
431/// failing the whole load.
432///
433/// An invalid regex used to be discovered lazily at match time and swallowed
434/// into a no-match, so the rule was dead and nothing said so. Rejecting the
435/// pattern outright would fix the silence but introduce a worse failure: a
436/// single pattern this engine cannot parse — a PCRE construct the `regex`
437/// crate does not implement, say — would stop the entire ruleset from
438/// loading, and with it the WAF.
439///
440/// So the rule is kept and can never match, exactly as before, but the
441/// operator is told at load time which rule is dead and why. Every other
442/// operator keeps failing the load, as it did before: those arguments come
443/// from the same config the operator is editing, not from a third-party
444/// ruleset.
445/// Warn about rule targets this engine parses but cannot resolve.
446///
447/// Such a variable always resolves to nothing, so the rule is dead. Under a
448/// negated operator it used to be worse than dead: an empty result was reported
449/// as a match, so `SecRule REQUEST_LINE "!@rx ..."` -- CRS 920100 -- denied
450/// every request. The evaluator no longer inverts absence into a match, but a
451/// rule that can never fire is still worth saying out loud at load time rather
452/// than leaving it to be inferred from traffic that was never inspected.
453///
454/// Only rules whose targets are *all* unresolvable are reported. CRS routinely
455/// writes `ARGS|ARGS_NAMES|XML:/*`, where the unsupported target costs nothing
456/// because the rule still inspects the others.
457fn report_unimplemented_variables(variables: &[VariableSpec], rule_id: &Option<String>) {
458    report_unsupported_xml_selectors(variables, rule_id);
459
460    if variables.is_empty() || variables.iter().any(|v| v.name.is_implemented()) {
461        return;
462    }
463    let targets: Vec<String> = variables.iter().map(|v| format!("{:?}", v.name)).collect();
464    tracing::warn!(
465        rule_id = %rule_id.as_deref().unwrap_or("(no id)"),
466        targets = %targets.join("|"),
467        "rule targets only variables this engine does not implement and can \
468         never match; the rest of the ruleset was loaded"
469    );
470}
471
472/// Warn about `XML:` targets that name an XPath expression this engine cannot
473/// express.
474///
475/// `XML:/*` and `XML://@*` are answered from the flattened body and cover every
476/// `XML:` target in the stock OWASP CRS. Anything richer needs a real XPath
477/// evaluator, and a rule asking for one inspects nothing through that target --
478/// worth saying at load time rather than leaving to be inferred from traffic.
479fn report_unsupported_xml_selectors(variables: &[VariableSpec], rule_id: &Option<String>) {
480    for var in variables {
481        if var.name != VariableName::Xml {
482            continue;
483        }
484        if XmlTarget::from_selection(var.selection.as_ref()).is_some() {
485            continue;
486        }
487        let selector = match &var.selection {
488            Some(Selection::Key(k)) => k.clone(),
489            Some(Selection::Regex(r)) => format!("/{r}/"),
490            None => String::new(),
491        };
492        tracing::warn!(
493            rule_id = %rule_id.as_deref().unwrap_or("(no id)"),
494            selector = %selector,
495            "rule selects XML with an XPath expression this engine cannot \
496             evaluate; only XML:/* and XML://@* are supported, and this target \
497             will match nothing"
498        );
499    }
500}
501
502fn compile_operator_reporting(
503    spec: &OperatorSpec,
504    rule_id: &Option<String>,
505) -> Result<(Arc<dyn Operator>, bool)> {
506    match compile_operator(spec) {
507        Ok(operator) => Ok((operator, spec.negated)),
508        Err(e) if spec.name == OperatorName::Rx => {
509            tracing::error!(
510                rule_id = %rule_id.as_deref().unwrap_or("(no id)"),
511                pattern = %spec.argument,
512                error = %e,
513                "rule has an invalid @rx pattern and can never match; \
514                 the rest of the ruleset was loaded"
515            );
516            let never = compile_operator(&OperatorSpec {
517                negated: false,
518                name: OperatorName::NoMatch,
519                argument: String::new(),
520            })?;
521            // Negation is dropped deliberately. `!@rx <invalid>` with the
522            // negation preserved would invert "never matches" into "matches
523            // every request", turning a dead rule into one that blocks all
524            // traffic -- far worse than the silence being fixed here.
525            Ok((never, false))
526        }
527        Err(e) => Err(e),
528    }
529}
530
531/// Warn once per distinct unsupported `ctl:` directive found in a ruleset.
532///
533/// Deduplicated by `directive=value`: CRS applies the same `ctl:` to hundreds
534/// of rules, and one line per rule would bury the message it is trying to
535/// deliver.
536fn report_unsupported_controls(directives: &[Directive]) {
537    let mut seen: std::collections::BTreeMap<String, (&'static str, usize)> =
538        std::collections::BTreeMap::new();
539
540    for directive in directives {
541        let actions = match directive {
542            Directive::SecRule(rule) => &rule.actions,
543            Directive::SecAction(action) => &action.actions,
544            _ => continue,
545        };
546        for (name, value, reason) in super::control::unsupported_controls(actions) {
547            let key = if value.is_empty() {
548                name
549            } else {
550                format!("{name}={value}")
551            };
552            let entry = seen.entry(key).or_insert((reason, 0));
553            entry.1 += 1;
554        }
555    }
556
557    for (spec, (reason, count)) in seen {
558        tracing::warn!(
559            directive = %spec,
560            rules_affected = count,
561            reason = %reason,
562            "ctl: directive is not implemented and will have no effect"
563        );
564    }
565}
566
567fn extract_id(actions: &[Action]) -> Option<String> {
568    for action in actions {
569        if let Action::Metadata(MetadataAction::Id(id)) = action {
570            return Some(id.to_string());
571        }
572    }
573    None
574}
575
576/// Check if chain action is present.
577fn has_chain(actions: &[Action]) -> bool {
578    actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain)))
579}
580
581/// Extract and compile transformation pipeline.
582fn extract_transformations(actions: &[Action]) -> Result<TransformationPipeline> {
583    let mut names = Vec::new();
584    for action in actions {
585        if let Action::Transformation(t) = action {
586            names.push(t.clone());
587        }
588    }
589    if names.is_empty() {
590        Ok(TransformationPipeline::new())
591    } else {
592        TransformationPipeline::from_names(&names)
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_compile_simple_rule() {
602        let rules = r#"
603            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
604        "#;
605        let ruleset = CompiledRuleset::from_string(rules).unwrap();
606        assert_eq!(ruleset.rule_count(), 1);
607
608        let phase1_rules = ruleset.rules_for_phase(Phase::RequestHeaders);
609        assert_eq!(phase1_rules.len(), 1);
610        assert_eq!(phase1_rules[0].id, Some("1".to_string()));
611    }
612
613    #[test]
614    fn test_compile_multiple_phases() {
615        let rules = r#"
616            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
617            SecRule REQUEST_BODY "@rx attack" "id:2,phase:2,deny"
618        "#;
619        let ruleset = CompiledRuleset::from_string(rules).unwrap();
620        assert_eq!(ruleset.rule_count(), 2);
621
622        assert_eq!(ruleset.rules_for_phase(Phase::RequestHeaders).len(), 1);
623        assert_eq!(ruleset.rules_for_phase(Phase::RequestBody).len(), 1);
624    }
625
626    #[test]
627    fn test_engine_mode() {
628        let rules = r#"
629            SecRuleEngine DetectionOnly
630            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
631        "#;
632        let ruleset = CompiledRuleset::from_string(rules).unwrap();
633        assert_eq!(ruleset.engine_mode(), RuleEngineMode::DetectionOnly);
634    }
635}