sentinel_modsec/engine/
transaction.rs

1//! Transaction processing for ModSecurity.
2
3use std::sync::Arc;
4
5use super::chain::ChainState;
6use super::intervention::Intervention;
7use super::phase::Phase;
8use super::ruleset::{CompiledRule, CompiledRuleset, RuleEngineMode};
9use super::scoring::AnomalyScore;
10use crate::actions::{execute_actions, DisruptiveOutcome, FlowOutcome, SetVarOp};
11use crate::error::Result;
12use crate::variables::{RequestData, ResponseData, TxCollection, VariableResolver};
13
14/// A ModSecurity transaction for processing a single request.
15pub struct Transaction {
16    /// Compiled ruleset reference.
17    ruleset: Arc<CompiledRuleset>,
18    /// Request data.
19    request: RequestData,
20    /// Response data.
21    response: ResponseData,
22    /// TX collection (mutable variables).
23    tx: TxCollection,
24    /// Current phase.
25    phase: Phase,
26    /// Intervention (if any).
27    intervention: Option<Intervention>,
28    /// Anomaly score tracker.
29    anomaly_score: AnomalyScore,
30    /// Default block status.
31    default_status: u16,
32    /// Matched rules.
33    matched_rules: Vec<String>,
34    /// Allow flag (skip further processing).
35    allowed: bool,
36    /// Matched variables for current rule evaluation.
37    matched_vars: Vec<(String, String)>,
38    /// Regex captures from last match.
39    captures: Vec<String>,
40}
41
42impl Transaction {
43    /// Create a new transaction.
44    pub fn new(ruleset: Arc<CompiledRuleset>, default_status: u16) -> Self {
45        Self {
46            ruleset,
47            request: RequestData::new(),
48            response: ResponseData::new(),
49            tx: TxCollection::new(),
50            phase: Phase::RequestHeaders,
51            intervention: None,
52            anomaly_score: AnomalyScore::new(),
53            default_status,
54            matched_rules: Vec::new(),
55            allowed: false,
56            matched_vars: Vec::new(),
57            captures: Vec::new(),
58        }
59    }
60
61    /// Process the request URI.
62    pub fn process_uri(&mut self, uri: &str, method: &str, protocol: &str) -> Result<()> {
63        self.request.set_uri(uri);
64        self.request.set_method(method);
65        self.request.set_protocol(protocol);
66        Ok(())
67    }
68
69    /// Add a request header.
70    pub fn add_request_header(&mut self, name: &str, value: &str) -> Result<()> {
71        self.request.add_header(name, value);
72        Ok(())
73    }
74
75    /// Process request headers (Phase 1).
76    pub fn process_request_headers(&mut self) -> Result<()> {
77        self.phase = Phase::RequestHeaders;
78        self.run_phase(Phase::RequestHeaders)?;
79        Ok(())
80    }
81
82    /// Append data to request body.
83    pub fn append_request_body(&mut self, data: &[u8]) -> Result<()> {
84        self.request.append_body(data);
85        Ok(())
86    }
87
88    /// Process request body (Phase 2).
89    pub fn process_request_body(&mut self) -> Result<()> {
90        self.phase = Phase::RequestBody;
91        self.request.parse_form_body();
92        self.run_phase(Phase::RequestBody)?;
93        Ok(())
94    }
95
96    /// Add a response header.
97    pub fn add_response_header(&mut self, name: &str, value: &str) -> Result<()> {
98        self.response.add_header(name, value);
99        Ok(())
100    }
101
102    /// Process response headers (Phase 3).
103    pub fn process_response_headers(&mut self) -> Result<()> {
104        self.phase = Phase::ResponseHeaders;
105        self.run_phase(Phase::ResponseHeaders)?;
106        Ok(())
107    }
108
109    /// Append data to response body.
110    pub fn append_response_body(&mut self, data: &[u8]) -> Result<()> {
111        self.response.append_body(data);
112        Ok(())
113    }
114
115    /// Process response body (Phase 4).
116    pub fn process_response_body(&mut self) -> Result<()> {
117        self.phase = Phase::ResponseBody;
118        self.run_phase(Phase::ResponseBody)?;
119        Ok(())
120    }
121
122    /// Process logging phase (Phase 5).
123    pub fn process_logging(&mut self) -> Result<()> {
124        self.phase = Phase::Logging;
125        self.run_phase(Phase::Logging)?;
126        Ok(())
127    }
128
129    /// Get current intervention (if any).
130    pub fn intervention(&self) -> Option<&Intervention> {
131        self.intervention.as_ref()
132    }
133
134    /// Check if there's an intervention.
135    pub fn has_intervention(&self) -> bool {
136        self.intervention.is_some()
137    }
138
139    /// Get matched rule IDs.
140    pub fn matched_rules(&self) -> &[String] {
141        &self.matched_rules
142    }
143
144    /// Get the anomaly score.
145    pub fn anomaly_score(&self) -> i32 {
146        self.anomaly_score.inbound
147    }
148
149    /// Get the TX collection.
150    pub fn tx(&self) -> &TxCollection {
151        &self.tx
152    }
153
154    /// Get mutable TX collection.
155    pub fn tx_mut(&mut self) -> &mut TxCollection {
156        &mut self.tx
157    }
158
159    /// Run rules for a specific phase.
160    fn run_phase(&mut self, phase: Phase) -> Result<()> {
161        if self.allowed || self.intervention.is_some() {
162            return Ok(());
163        }
164
165        if self.ruleset.engine_mode() == RuleEngineMode::Off {
166            return Ok(());
167        }
168
169        // Clone rules to avoid borrow conflicts with mutable self
170        let rules: Vec<CompiledRule> = self.ruleset.rules_for_phase(phase).to_vec();
171        if rules.is_empty() {
172            return Ok(());
173        }
174
175        let mut chain_state = ChainState::new();
176        let mut skip_count: u32 = 0;
177        let mut skip_after: Option<String> = None;
178
179        let mut idx = 0;
180        while idx < rules.len() {
181            // Handle skip
182            if skip_count > 0 {
183                skip_count -= 1;
184                idx += 1;
185                continue;
186            }
187
188            // Handle skipAfter
189            if let Some(ref marker) = skip_after {
190                if let Some((marker_phase, marker_idx)) = self.ruleset.marker(marker) {
191                    if marker_phase == phase && marker_idx > idx {
192                        idx = marker_idx;
193                        skip_after = None;
194                        continue;
195                    }
196                }
197                // Marker not found or in different phase, continue
198                idx += 1;
199                continue;
200            }
201
202            let rule = &rules[idx];
203
204            // Handle chain continuation
205            if chain_state.in_chain && !rule.is_chain && rule.chain_next.is_none() {
206                // End of chain, check if previous rules in chain matched
207                if !chain_state.chain_matched {
208                    chain_state.reset();
209                    idx += 1;
210                    continue;
211                }
212            }
213
214            // Evaluate rule
215            let (matched, captures) = self.evaluate_rule(rule)?;
216
217            if matched {
218                // Execute actions
219                let action_result = execute_actions(&rule.actions, None, &captures);
220
221                // Track matched rule
222                if let Some(ref id) = rule.id {
223                    self.matched_rules.push(id.clone());
224                }
225
226                // Apply setvar operations
227                for op in &action_result.setvar_ops {
228                    self.apply_setvar(op);
229                }
230
231                // Handle flow control
232                match action_result.flow {
233                    FlowOutcome::Chain => {
234                        if !chain_state.in_chain {
235                            chain_state.start_chain(idx);
236                        }
237                        chain_state.continue_chain(true, &captures);
238                    }
239                    FlowOutcome::Skip(n) => {
240                        skip_count = n;
241                    }
242                    FlowOutcome::SkipAfter(marker) => {
243                        skip_after = Some(marker);
244                    }
245                    FlowOutcome::Continue => {}
246                }
247
248                // Handle disruptive action
249                if let Some(outcome) = action_result.disruptive {
250                    // Only apply if not in detection-only mode
251                    let should_block = self.ruleset.engine_mode() == RuleEngineMode::On;
252
253                    match outcome {
254                        DisruptiveOutcome::Deny(status) => {
255                            if should_block {
256                                let mut intervention = Intervention::deny(status, phase, rule.id.clone());
257                                intervention.add_metadata(action_result.metadata);
258                                self.intervention = Some(intervention);
259                                return Ok(());
260                            }
261                        }
262                        DisruptiveOutcome::Block => {
263                            if should_block {
264                                let mut intervention = Intervention::deny(self.default_status, phase, rule.id.clone());
265                                intervention.add_metadata(action_result.metadata);
266                                self.intervention = Some(intervention);
267                                return Ok(());
268                            }
269                        }
270                        DisruptiveOutcome::Allow => {
271                            self.allowed = true;
272                            return Ok(());
273                        }
274                        DisruptiveOutcome::Redirect(url) => {
275                            if should_block {
276                                let mut intervention = Intervention::redirect(url, phase, rule.id.clone());
277                                intervention.add_metadata(action_result.metadata);
278                                self.intervention = Some(intervention);
279                                return Ok(());
280                            }
281                        }
282                        DisruptiveOutcome::Drop => {
283                            if should_block {
284                                let mut intervention = Intervention::drop(phase, rule.id.clone());
285                                intervention.add_metadata(action_result.metadata);
286                                self.intervention = Some(intervention);
287                                return Ok(());
288                            }
289                        }
290                        DisruptiveOutcome::Pass => {
291                            // Continue processing
292                        }
293                    }
294                }
295            } else {
296                // Rule didn't match
297                if chain_state.in_chain {
298                    chain_state.chain_matched = false;
299                }
300            }
301
302            // End chain if this is the last rule in chain
303            if chain_state.in_chain && !rule.is_chain {
304                chain_state.end_chain();
305            }
306
307            idx += 1;
308        }
309
310        // Sync anomaly score to TX
311        self.anomaly_score.sync_to_tx(&mut self.tx);
312
313        Ok(())
314    }
315
316    /// Evaluate a single rule.
317    fn evaluate_rule(&self, rule: &CompiledRule) -> Result<(bool, Vec<String>)> {
318        let resolver = VariableResolver::new(
319            &self.request,
320            &self.response,
321            &self.tx,
322            None,
323            &self.matched_vars,
324            &self.captures,
325        );
326
327        // Resolve variables from all specs
328        let mut all_values = Vec::new();
329        for spec in &rule.variables {
330            all_values.extend(resolver.resolve(spec));
331        }
332
333        if all_values.is_empty() {
334            // No values to match
335            return Ok((rule.operator_negated, Vec::new()));
336        }
337
338        // Apply transformations and match
339        for (_name, value) in all_values {
340            let transformed = rule.transformations.apply(&value);
341            let result = rule.operator.execute(&transformed);
342
343            let final_match = if rule.operator_negated { !result.matched } else { result.matched };
344
345            if final_match {
346                return Ok((true, result.captures));
347            }
348        }
349
350        Ok((false, Vec::new()))
351    }
352
353    /// Apply a setvar operation.
354    fn apply_setvar(&mut self, op: &SetVarOp) {
355        crate::actions::apply_setvar(&mut self.tx, op);
356
357        // Sync anomaly score from TX if relevant
358        if op.name == "anomaly_score" {
359            self.anomaly_score.sync_from_tx(&self.tx);
360        }
361    }
362}
363
364impl std::fmt::Debug for Transaction {
365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366        f.debug_struct("Transaction")
367            .field("phase", &self.phase)
368            .field("has_intervention", &self.intervention.is_some())
369            .field("anomaly_score", &self.anomaly_score.inbound)
370            .field("matched_rules", &self.matched_rules)
371            .finish()
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::variables::Collection;
379
380    fn make_ruleset(rules: &str) -> Arc<CompiledRuleset> {
381        Arc::new(CompiledRuleset::from_string(rules).unwrap())
382    }
383
384    #[test]
385    fn test_basic_match() {
386        let ruleset = make_ruleset(r#"
387            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
388        "#);
389        let mut tx = Transaction::new(ruleset, 403);
390        tx.process_uri("/admin/dashboard", "GET", "HTTP/1.1").unwrap();
391        tx.process_request_headers().unwrap();
392
393        assert!(tx.has_intervention());
394        let intervention = tx.intervention().unwrap();
395        assert_eq!(intervention.status, 403);
396    }
397
398    #[test]
399    fn test_no_match() {
400        let ruleset = make_ruleset(r#"
401            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
402        "#);
403        let mut tx = Transaction::new(ruleset, 403);
404        tx.process_uri("/public/index.html", "GET", "HTTP/1.1").unwrap();
405        tx.process_request_headers().unwrap();
406
407        assert!(!tx.has_intervention());
408    }
409
410    #[test]
411    fn test_setvar() {
412        let ruleset = make_ruleset(r#"
413            SecRule REQUEST_URI "@contains /test" "id:1,phase:1,pass,setvar:TX.score=5"
414        "#);
415        let mut tx = Transaction::new(ruleset, 403);
416        tx.process_uri("/test/page", "GET", "HTTP/1.1").unwrap();
417        tx.process_request_headers().unwrap();
418
419        assert!(!tx.has_intervention());
420        let score = tx.tx().get("score").and_then(|v| v.first().map(|s| s.to_string()));
421        assert_eq!(score, Some("5".to_string()));
422    }
423
424    #[test]
425    fn test_detection_only_mode() {
426        let ruleset = make_ruleset(r#"
427            SecRuleEngine DetectionOnly
428            SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
429        "#);
430        let mut tx = Transaction::new(Arc::new(
431            CompiledRuleset::from_string(r#"
432                SecRuleEngine DetectionOnly
433                SecRule REQUEST_URI "@contains /admin" "id:1,phase:1,deny"
434            "#).unwrap()
435        ), 403);
436        tx.process_uri("/admin/dashboard", "GET", "HTTP/1.1").unwrap();
437        tx.process_request_headers().unwrap();
438
439        // Should match but not block
440        assert!(!tx.has_intervention());
441        assert!(tx.matched_rules().contains(&"1".to_string()));
442    }
443}