use crate::error::Result;
use crate::operators::{compile_operator, Operator};
use crate::parser::{
Action, Directive, FlowAction, MetadataAction, OperatorName, OperatorSpec, Parser,
RuleEngineMode as ParserRuleEngineMode, RuleIdSelector, Selection, UpdateTargetById,
VariableSpec,
};
use crate::transformations::TransformationPipeline;
use super::phase::Phase;
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone)]
pub struct CompiledRule {
pub id: Option<String>,
pub phase: Phase,
pub variables: Vec<VariableSpec>,
pub operator: Arc<dyn Operator>,
pub operator_spec: OperatorSpec,
pub operator_negated: bool,
pub transformations: TransformationPipeline,
pub actions: Vec<Action>,
pub is_chain: bool,
pub chain_next: Option<usize>,
}
impl std::fmt::Debug for CompiledRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledRule")
.field("id", &self.id)
.field("phase", &self.phase)
.field("variables", &self.variables)
.field("operator_negated", &self.operator_negated)
.field("is_chain", &self.is_chain)
.finish()
}
}
pub struct Rules {
by_phase: HashMap<Phase, Vec<CompiledRule>>,
markers: HashMap<String, (Phase, usize)>,
}
impl Rules {
pub fn new() -> Self {
Self {
by_phase: HashMap::new(),
markers: HashMap::new(),
}
}
pub fn add(&mut self, phase: Phase, rule: CompiledRule) {
self.by_phase.entry(phase).or_default().push(rule);
}
pub fn add_marker(&mut self, name: String, phase: Phase, index: usize) {
self.markers.insert(name, (phase, index));
}
pub fn for_phase(&self, phase: Phase) -> &[CompiledRule] {
self.by_phase.get(&phase).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn marker(&self, name: &str) -> Option<(Phase, usize)> {
self.markers.get(name).copied()
}
pub fn count(&self) -> usize {
self.by_phase.values().map(|v| v.len()).sum()
}
}
impl Default for Rules {
fn default() -> Self {
Self::new()
}
}
pub struct CompiledRuleset {
rules: Rules,
engine_mode: RuleEngineMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuleEngineMode {
On,
DetectionOnly,
Off,
}
impl Default for RuleEngineMode {
fn default() -> Self {
RuleEngineMode::On
}
}
impl CompiledRuleset {
pub fn new() -> Self {
Self {
rules: Rules::new(),
engine_mode: RuleEngineMode::default(),
}
}
pub fn from_file(path: &str) -> Result<Self> {
let mut parser = Parser::new();
parser.parse_file(std::path::Path::new(path))?;
Self::compile(parser.into_directives())
}
pub fn from_string(rules: &str) -> Result<Self> {
let mut parser = Parser::new();
parser.parse(rules)?;
Self::compile(parser.into_directives())
}
pub fn compile(directives: Vec<Directive>) -> Result<Self> {
let mut ruleset = Self::new();
let mut pending_chain: Option<(Phase, usize)> = None;
let removals: Vec<RuleIdSelector> = directives
.iter()
.filter_map(|d| match d {
Directive::SecRuleRemoveById(ids) => Some(ids.iter().copied()),
_ => None,
})
.flatten()
.collect();
let mut target_updates: Vec<UpdateTargetById> = Vec::new();
let mut skipping_removed_chain = false;
for directive in directives {
match directive {
Directive::SecRuleEngine(mode) => {
ruleset.engine_mode = match mode {
ParserRuleEngineMode::On => RuleEngineMode::On,
ParserRuleEngineMode::Off => RuleEngineMode::Off,
ParserRuleEngineMode::DetectionOnly => RuleEngineMode::DetectionOnly,
};
}
Directive::SecRule(rule) => {
let phase = match pending_chain {
Some((chain_phase, _)) => chain_phase,
None => extract_phase(&rule.actions),
};
let id = extract_id(&rule.actions);
let is_chain = has_chain(&rule.actions);
if skipping_removed_chain {
skipping_removed_chain = is_chain;
continue;
}
if id_is_removed(&id, &removals) {
skipping_removed_chain = is_chain;
continue;
}
let transformations = extract_transformations(&rule.actions)?;
let operator_spec = rule.operator.clone();
let operator = compile_operator(&rule.operator)?;
let compiled = CompiledRule {
id,
phase,
variables: rule.variables,
operator,
operator_negated: operator_spec.negated,
operator_spec,
transformations,
actions: rule.actions,
is_chain,
chain_next: None,
};
let rules_for_phase = ruleset.rules.by_phase.entry(phase).or_default();
let idx = rules_for_phase.len();
rules_for_phase.push(compiled);
if let Some((chain_phase, chain_idx)) = pending_chain.take() {
if chain_phase == phase {
if let Some(prev_rule) = ruleset.rules.by_phase
.get_mut(&chain_phase)
.and_then(|r| r.get_mut(chain_idx))
{
prev_rule.chain_next = Some(idx);
}
}
}
if is_chain {
pending_chain = Some((phase, idx));
}
}
Directive::SecAction(sec_action) => {
let phase = extract_phase(&sec_action.actions);
let id = extract_id(&sec_action.actions);
let transformations = extract_transformations(&sec_action.actions)?;
let operator_spec = OperatorSpec {
negated: false,
name: OperatorName::UnconditionalMatch,
argument: String::new(),
};
let operator = compile_operator(&operator_spec)?;
let compiled = CompiledRule {
id,
phase,
variables: vec![],
operator,
operator_negated: false,
operator_spec,
transformations,
actions: sec_action.actions,
is_chain: false,
chain_next: None,
};
ruleset.rules.add(phase, compiled);
}
Directive::SecRuleUpdateTargetById(update) => {
target_updates.push(update.clone());
}
Directive::SecMarker(marker) => {
let phase = Phase::RequestHeaders;
let idx = ruleset.rules.by_phase.get(&phase).map(|v| v.len()).unwrap_or(0);
ruleset.rules.add_marker(marker.name, phase, idx);
}
_ => {
}
}
}
apply_target_updates(&mut ruleset, &target_updates);
Ok(ruleset)
}
pub fn rules_for_phase(&self, phase: Phase) -> &[CompiledRule] {
self.rules.for_phase(phase)
}
pub fn rule_count(&self) -> usize {
self.rules.count()
}
pub fn engine_mode(&self) -> RuleEngineMode {
self.engine_mode
}
pub fn marker(&self, name: &str) -> Option<(Phase, usize)> {
self.rules.marker(name)
}
}
impl Default for CompiledRuleset {
fn default() -> Self {
Self::new()
}
}
fn extract_phase(actions: &[Action]) -> Phase {
for action in actions {
if let Action::Metadata(MetadataAction::Phase(p)) = action {
return Phase::from_number(*p).unwrap_or(Phase::RequestBody);
}
}
Phase::RequestBody }
fn id_is_removed(id: &Option<String>, removals: &[RuleIdSelector]) -> bool {
let Some(numeric) = id.as_ref().and_then(|s| s.parse::<u64>().ok()) else {
return false;
};
removals.iter().any(|selector| selector.matches(numeric))
}
fn apply_target_updates(ruleset: &mut CompiledRuleset, updates: &[UpdateTargetById]) {
if updates.is_empty() {
return;
}
for rules in ruleset.rules.by_phase.values_mut() {
for rule in rules.iter_mut() {
let Some(numeric) = rule.id.as_ref().and_then(|s| s.parse::<u64>().ok()) else {
continue;
};
for update in updates {
if !update.ids.iter().any(|selector| selector.matches(numeric)) {
continue;
}
if let Some(replaced) = &update.replaced {
rule.variables
.retain(|var| !variable_matches_target(var, replaced));
}
for exclusion in &update.exclusions {
for var in rule.variables.iter_mut() {
if !var.exclusions.iter().any(|e| e == exclusion) {
var.exclusions.push(exclusion.clone());
}
}
}
rule.variables.extend(update.additions.iter().cloned());
}
}
}
}
fn variable_matches_target(var: &VariableSpec, target: &str) -> bool {
let Ok(parsed) = crate::parser::parse_single_variable(target) else {
return false;
};
if var.name != parsed.name {
return false;
}
match (&var.selection, &parsed.selection) {
(None, None) => true,
(Some(Selection::Key(existing)), Some(Selection::Key(wanted))) => {
existing.eq_ignore_ascii_case(wanted.as_str())
}
_ => false,
}
}
fn extract_id(actions: &[Action]) -> Option<String> {
for action in actions {
if let Action::Metadata(MetadataAction::Id(id)) = action {
return Some(id.to_string());
}
}
None
}
fn has_chain(actions: &[Action]) -> bool {
actions.iter().any(|a| matches!(a, Action::Flow(FlowAction::Chain)))
}
fn extract_transformations(actions: &[Action]) -> Result<TransformationPipeline> {
let mut names = Vec::new();
for action in actions {
if let Action::Transformation(t) = action {
names.push(t.clone());
}
}
if names.is_empty() {
Ok(TransformationPipeline::new())
} else {
TransformationPipeline::from_names(&names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compile_simple_rule() {
let rules = r#"
SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
"#;
let ruleset = CompiledRuleset::from_string(rules).unwrap();
assert_eq!(ruleset.rule_count(), 1);
let phase1_rules = ruleset.rules_for_phase(Phase::RequestHeaders);
assert_eq!(phase1_rules.len(), 1);
assert_eq!(phase1_rules[0].id, Some("1".to_string()));
}
#[test]
fn test_compile_multiple_phases() {
let rules = r#"
SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
SecRule REQUEST_BODY "@rx attack" "id:2,phase:2,deny"
"#;
let ruleset = CompiledRuleset::from_string(rules).unwrap();
assert_eq!(ruleset.rule_count(), 2);
assert_eq!(ruleset.rules_for_phase(Phase::RequestHeaders).len(), 1);
assert_eq!(ruleset.rules_for_phase(Phase::RequestBody).len(), 1);
}
#[test]
fn test_engine_mode() {
let rules = r#"
SecRuleEngine DetectionOnly
SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
"#;
let ruleset = CompiledRuleset::from_string(rules).unwrap();
assert_eq!(ruleset.engine_mode(), RuleEngineMode::DetectionOnly);
}
}