sentinel_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::{Action, MetadataAction, Directive, Parser, VariableSpec, OperatorSpec, OperatorName, FlowAction, RuleEngineMode as ParserRuleEngineMode};
6use crate::transformations::TransformationPipeline;
7
8use super::phase::Phase;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// A parsed SecRule ready for execution.
13#[derive(Clone)]
14pub struct CompiledRule {
15    /// Rule ID.
16    pub id: Option<String>,
17    /// Rule phase.
18    pub phase: Phase,
19    /// Variable specifications.
20    pub variables: Vec<VariableSpec>,
21    /// Compiled operator.
22    pub operator: Arc<dyn Operator>,
23    /// Whether operator is negated.
24    pub operator_negated: bool,
25    /// Transformation pipeline.
26    pub transformations: TransformationPipeline,
27    /// Actions to execute on match.
28    pub actions: Vec<Action>,
29    /// Whether this rule is part of a chain.
30    pub is_chain: bool,
31    /// Index of next rule in chain (if any).
32    pub chain_next: Option<usize>,
33}
34
35impl std::fmt::Debug for CompiledRule {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("CompiledRule")
38            .field("id", &self.id)
39            .field("phase", &self.phase)
40            .field("variables", &self.variables)
41            .field("operator_negated", &self.operator_negated)
42            .field("is_chain", &self.is_chain)
43            .finish()
44    }
45}
46
47/// Rules grouped by phase for efficient processing.
48pub struct Rules {
49    /// Rules organized by phase.
50    by_phase: HashMap<Phase, Vec<CompiledRule>>,
51    /// Markers for skipAfter.
52    markers: HashMap<String, (Phase, usize)>,
53}
54
55impl Rules {
56    /// Create empty rules.
57    pub fn new() -> Self {
58        Self {
59            by_phase: HashMap::new(),
60            markers: HashMap::new(),
61        }
62    }
63
64    /// Add a rule to a specific phase.
65    pub fn add(&mut self, phase: Phase, rule: CompiledRule) {
66        self.by_phase.entry(phase).or_default().push(rule);
67    }
68
69    /// Add a marker.
70    pub fn add_marker(&mut self, name: String, phase: Phase, index: usize) {
71        self.markers.insert(name, (phase, index));
72    }
73
74    /// Get rules for a phase.
75    pub fn for_phase(&self, phase: Phase) -> &[CompiledRule] {
76        self.by_phase.get(&phase).map(|v| v.as_slice()).unwrap_or(&[])
77    }
78
79    /// Get marker position.
80    pub fn marker(&self, name: &str) -> Option<(Phase, usize)> {
81        self.markers.get(name).copied()
82    }
83
84    /// Get total rule count.
85    pub fn count(&self) -> usize {
86        self.by_phase.values().map(|v| v.len()).sum()
87    }
88}
89
90impl Default for Rules {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96/// A fully compiled ruleset ready for transaction processing.
97pub struct CompiledRuleset {
98    /// Compiled rules.
99    rules: Rules,
100    /// Rule engine mode.
101    engine_mode: RuleEngineMode,
102}
103
104/// Rule engine operating mode.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum RuleEngineMode {
107    /// Rules are enabled and will block.
108    On,
109    /// Rules are enabled but will only detect.
110    DetectionOnly,
111    /// Rules are disabled.
112    Off,
113}
114
115impl Default for RuleEngineMode {
116    fn default() -> Self {
117        RuleEngineMode::On
118    }
119}
120
121impl CompiledRuleset {
122    /// Create an empty ruleset.
123    pub fn new() -> Self {
124        Self {
125            rules: Rules::new(),
126            engine_mode: RuleEngineMode::default(),
127        }
128    }
129
130    /// Load and compile rules from a file.
131    pub fn from_file(path: &str) -> Result<Self> {
132        let mut parser = Parser::new();
133        parser.parse_file(std::path::Path::new(path))?;
134        Self::compile(parser.into_directives())
135    }
136
137    /// Load and compile rules from a string.
138    pub fn from_string(rules: &str) -> Result<Self> {
139        let mut parser = Parser::new();
140        parser.parse(rules)?;
141        Self::compile(parser.into_directives())
142    }
143
144    /// Compile parsed directives into a ruleset.
145    pub fn compile(directives: Vec<Directive>) -> Result<Self> {
146        let mut ruleset = Self::new();
147        let mut pending_chain: Option<(Phase, usize)> = None;
148
149        for directive in directives {
150            match directive {
151                Directive::SecRuleEngine(mode) => {
152                    ruleset.engine_mode = match mode {
153                        ParserRuleEngineMode::On => RuleEngineMode::On,
154                        ParserRuleEngineMode::Off => RuleEngineMode::Off,
155                        ParserRuleEngineMode::DetectionOnly => RuleEngineMode::DetectionOnly,
156                    };
157                }
158                Directive::SecRule(rule) => {
159                    let phase = extract_phase(&rule.actions);
160                    let id = extract_id(&rule.actions);
161                    let is_chain = has_chain(&rule.actions);
162                    let transformations = extract_transformations(&rule.actions)?;
163
164                    let operator = compile_operator(&rule.operator)?;
165
166                    let compiled = CompiledRule {
167                        id,
168                        phase,
169                        variables: rule.variables,
170                        operator,
171                        operator_negated: rule.operator.negated,
172                        transformations,
173                        actions: rule.actions,
174                        is_chain,
175                        chain_next: None,
176                    };
177
178                    let rules_for_phase = ruleset.rules.by_phase.entry(phase).or_default();
179                    let idx = rules_for_phase.len();
180                    rules_for_phase.push(compiled);
181
182                    // Handle chaining
183                    if let Some((chain_phase, chain_idx)) = pending_chain.take() {
184                        if chain_phase == phase {
185                            if let Some(prev_rule) = ruleset.rules.by_phase
186                                .get_mut(&chain_phase)
187                                .and_then(|r| r.get_mut(chain_idx))
188                            {
189                                prev_rule.chain_next = Some(idx);
190                            }
191                        }
192                    }
193
194                    if is_chain {
195                        pending_chain = Some((phase, idx));
196                    }
197                }
198                Directive::SecAction(sec_action) => {
199                    // SecAction is like a rule that always matches
200                    let phase = extract_phase(&sec_action.actions);
201                    let id = extract_id(&sec_action.actions);
202                    let transformations = extract_transformations(&sec_action.actions)?;
203
204                    // Create a rule with unconditional match operator
205                    let operator = compile_operator(&OperatorSpec {
206                        negated: false,
207                        name: OperatorName::UnconditionalMatch,
208                        argument: String::new(),
209                    })?;
210
211                    let compiled = CompiledRule {
212                        id,
213                        phase,
214                        variables: vec![],
215                        operator,
216                        operator_negated: false,
217                        transformations,
218                        actions: sec_action.actions,
219                        is_chain: false,
220                        chain_next: None,
221                    };
222
223                    ruleset.rules.add(phase, compiled);
224                }
225                Directive::SecMarker(marker) => {
226                    // Add marker at current position in default phase
227                    let phase = Phase::RequestHeaders;
228                    let idx = ruleset.rules.by_phase.get(&phase).map(|v| v.len()).unwrap_or(0);
229                    ruleset.rules.add_marker(marker.name, phase, idx);
230                }
231                _ => {
232                    // Other directives (SecDefaultAction, etc.) handled elsewhere
233                }
234            }
235        }
236
237        Ok(ruleset)
238    }
239
240    /// Get rules for a phase.
241    pub fn rules_for_phase(&self, phase: Phase) -> &[CompiledRule] {
242        self.rules.for_phase(phase)
243    }
244
245    /// Get total rule count.
246    pub fn rule_count(&self) -> usize {
247        self.rules.count()
248    }
249
250    /// Get engine mode.
251    pub fn engine_mode(&self) -> RuleEngineMode {
252        self.engine_mode
253    }
254
255    /// Get marker position.
256    pub fn marker(&self, name: &str) -> Option<(Phase, usize)> {
257        self.rules.marker(name)
258    }
259}
260
261impl Default for CompiledRuleset {
262    fn default() -> Self {
263        Self::new()
264    }
265}
266
267/// Extract phase from actions, defaulting to Phase 2.
268fn extract_phase(actions: &[Action]) -> Phase {
269    for action in actions {
270        if let Action::Metadata(MetadataAction::Phase(p)) = action {
271            return Phase::from_number(*p).unwrap_or(Phase::RequestBody);
272        }
273    }
274    Phase::RequestBody // ModSecurity default
275}
276
277/// Extract rule ID from actions.
278fn extract_id(actions: &[Action]) -> Option<String> {
279    for action in actions {
280        if let Action::Metadata(MetadataAction::Id(id)) = action {
281            return Some(id.to_string());
282        }
283    }
284    None
285}
286
287/// Check if chain action is present.
288fn has_chain(actions: &[Action]) -> bool {
289    actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain)))
290}
291
292/// Extract and compile transformation pipeline.
293fn extract_transformations(actions: &[Action]) -> Result<TransformationPipeline> {
294    let mut names = Vec::new();
295    for action in actions {
296        if let Action::Transformation(t) = action {
297            names.push(t.clone());
298        }
299    }
300    if names.is_empty() {
301        Ok(TransformationPipeline::new())
302    } else {
303        TransformationPipeline::from_names(&names)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_compile_simple_rule() {
313        let rules = r#"
314            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
315        "#;
316        let ruleset = CompiledRuleset::from_string(rules).unwrap();
317        assert_eq!(ruleset.rule_count(), 1);
318
319        let phase1_rules = ruleset.rules_for_phase(Phase::RequestHeaders);
320        assert_eq!(phase1_rules.len(), 1);
321        assert_eq!(phase1_rules[0].id, Some("1".to_string()));
322    }
323
324    #[test]
325    fn test_compile_multiple_phases() {
326        let rules = r#"
327            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
328            SecRule REQUEST_BODY "@rx attack" "id:2,phase:2,deny"
329        "#;
330        let ruleset = CompiledRuleset::from_string(rules).unwrap();
331        assert_eq!(ruleset.rule_count(), 2);
332
333        assert_eq!(ruleset.rules_for_phase(Phase::RequestHeaders).len(), 1);
334        assert_eq!(ruleset.rules_for_phase(Phase::RequestBody).len(), 1);
335    }
336
337    #[test]
338    fn test_engine_mode() {
339        let rules = r#"
340            SecRuleEngine DetectionOnly
341            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
342        "#;
343        let ruleset = CompiledRuleset::from_string(rules).unwrap();
344        assert_eq!(ruleset.engine_mode(), RuleEngineMode::DetectionOnly);
345    }
346}