1use 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#[derive(Clone)]
18pub struct CompiledRule {
19 pub id: Option<String>,
21 pub phase: Phase,
23 pub variables: Vec<VariableSpec>,
25 pub operator: Arc<dyn Operator>,
27 pub operator_spec: OperatorSpec,
29 pub operator_negated: bool,
31 pub transformations: TransformationPipeline,
33 pub actions: Vec<Action>,
35 pub is_chain: bool,
37 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
53pub struct Rules {
55 by_phase: HashMap<Phase, Vec<CompiledRule>>,
57 markers: HashMap<String, HashMap<Phase, usize>>,
62}
63
64impl Rules {
65 pub fn new() -> Self {
67 Self {
68 by_phase: HashMap::new(),
69 markers: HashMap::new(),
70 }
71 }
72
73 pub fn add(&mut self, phase: Phase, rule: CompiledRule) {
75 self.by_phase.entry(phase).or_default().push(rule);
76 }
77
78 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 pub fn for_phase(&self, phase: Phase) -> &[CompiledRule] {
89 self.by_phase.get(&phase).map(|v| v.as_slice()).unwrap_or(&[])
90 }
91
92 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 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
109pub struct CompiledRuleset {
111 rules: Rules,
113 engine_mode: RuleEngineMode,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RuleEngineMode {
120 On,
122 DetectionOnly,
124 Off,
126}
127
128impl Default for RuleEngineMode {
129 fn default() -> Self {
130 RuleEngineMode::On
131 }
132}
133
134impl CompiledRuleset {
135 pub fn new() -> Self {
137 Self {
138 rules: Rules::new(),
139 engine_mode: RuleEngineMode::default(),
140 }
141 }
142
143 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 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 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_unsupported_controls(&directives);
170
171 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 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 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 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 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 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 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 target_updates.push(update.clone());
296 }
297 Directive::SecMarker(marker) => {
298 ruleset.rules.add_marker(marker.name);
299 }
300 _ => {
301 }
303 }
304 }
305
306 apply_target_updates(&mut ruleset, &target_updates);
307
308 Ok(ruleset)
309 }
310
311 pub fn rules_for_phase(&self, phase: Phase) -> &[CompiledRule] {
313 self.rules.for_phase(phase)
314 }
315
316 pub fn rule_count(&self) -> usize {
318 self.rules.count()
319 }
320
321 pub fn engine_mode(&self) -> RuleEngineMode {
323 self.engine_mode
324 }
325
326 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
338fn 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 }
347
348fn 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
361fn 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
406fn variable_matches_target(var: &VariableSpec, target: &str) -> bool {
409 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
430fn 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
472fn 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 Ok((never, false))
526 }
527 Err(e) => Err(e),
528 }
529}
530
531fn 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
576fn has_chain(actions: &[Action]) -> bool {
578 actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain)))
579}
580
581fn 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}