1use 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
14pub struct Transaction {
16 ruleset: Arc<CompiledRuleset>,
18 request: RequestData,
20 response: ResponseData,
22 tx: TxCollection,
24 phase: Phase,
26 intervention: Option<Intervention>,
28 anomaly_score: AnomalyScore,
30 default_status: u16,
32 matched_rules: Vec<String>,
34 allowed: bool,
36 matched_vars: Vec<(String, String)>,
38 captures: Vec<String>,
40}
41
42impl Transaction {
43 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 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 pub fn add_request_header(&mut self, name: &str, value: &str) -> Result<()> {
71 self.request.add_header(name, value);
72 Ok(())
73 }
74
75 pub fn process_request_headers(&mut self) -> Result<()> {
77 self.phase = Phase::RequestHeaders;
78 self.run_phase(Phase::RequestHeaders)?;
79 Ok(())
80 }
81
82 pub fn append_request_body(&mut self, data: &[u8]) -> Result<()> {
84 self.request.append_body(data);
85 Ok(())
86 }
87
88 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 pub fn add_response_header(&mut self, name: &str, value: &str) -> Result<()> {
98 self.response.add_header(name, value);
99 Ok(())
100 }
101
102 pub fn process_response_headers(&mut self) -> Result<()> {
104 self.phase = Phase::ResponseHeaders;
105 self.run_phase(Phase::ResponseHeaders)?;
106 Ok(())
107 }
108
109 pub fn append_response_body(&mut self, data: &[u8]) -> Result<()> {
111 self.response.append_body(data);
112 Ok(())
113 }
114
115 pub fn process_response_body(&mut self) -> Result<()> {
117 self.phase = Phase::ResponseBody;
118 self.run_phase(Phase::ResponseBody)?;
119 Ok(())
120 }
121
122 pub fn process_logging(&mut self) -> Result<()> {
124 self.phase = Phase::Logging;
125 self.run_phase(Phase::Logging)?;
126 Ok(())
127 }
128
129 pub fn intervention(&self) -> Option<&Intervention> {
131 self.intervention.as_ref()
132 }
133
134 pub fn has_intervention(&self) -> bool {
136 self.intervention.is_some()
137 }
138
139 pub fn matched_rules(&self) -> &[String] {
141 &self.matched_rules
142 }
143
144 pub fn anomaly_score(&self) -> i32 {
146 self.anomaly_score.inbound
147 }
148
149 pub fn tx(&self) -> &TxCollection {
151 &self.tx
152 }
153
154 pub fn tx_mut(&mut self) -> &mut TxCollection {
156 &mut self.tx
157 }
158
159 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 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 if skip_count > 0 {
183 skip_count -= 1;
184 idx += 1;
185 continue;
186 }
187
188 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 idx += 1;
199 continue;
200 }
201
202 let rule = &rules[idx];
203
204 if chain_state.in_chain && !rule.is_chain && rule.chain_next.is_none() {
206 if !chain_state.chain_matched {
208 chain_state.reset();
209 idx += 1;
210 continue;
211 }
212 }
213
214 let (matched, captures) = self.evaluate_rule(rule)?;
216
217 if matched {
218 let action_result = execute_actions(&rule.actions, None, &captures);
220
221 if let Some(ref id) = rule.id {
223 self.matched_rules.push(id.clone());
224 }
225
226 for op in &action_result.setvar_ops {
228 self.apply_setvar(op);
229 }
230
231 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 if let Some(outcome) = action_result.disruptive {
250 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 }
293 }
294 }
295 } else {
296 if chain_state.in_chain {
298 chain_state.chain_matched = false;
299 }
300 }
301
302 if chain_state.in_chain && !rule.is_chain {
304 chain_state.end_chain();
305 }
306
307 idx += 1;
308 }
309
310 self.anomaly_score.sync_to_tx(&mut self.tx);
312
313 Ok(())
314 }
315
316 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 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 return Ok((rule.operator_negated, Vec::new()));
336 }
337
338 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 fn apply_setvar(&mut self, op: &SetVarOp) {
355 crate::actions::apply_setvar(&mut self.tx, op);
356
357 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 assert!(!tx.has_intervention());
441 assert!(tx.matched_rules().contains(&"1".to_string()));
442 }
443}