sentinel_modsec/engine/
intervention.rs

1//! Intervention tracking for blocked requests.
2
3use super::phase::Phase;
4use crate::actions::RuleMetadata;
5
6/// An intervention (blocking decision) from rule processing.
7#[derive(Debug, Clone)]
8pub struct Intervention {
9    /// HTTP status code to return.
10    pub status: u16,
11    /// Redirect URL (if applicable).
12    pub url: Option<String>,
13    /// Log message.
14    pub log: Option<String>,
15    /// Rule IDs that triggered the intervention.
16    pub rule_ids: Vec<String>,
17    /// Phase in which intervention occurred.
18    pub phase: Phase,
19    /// Whether to drop the connection.
20    pub drop_connection: bool,
21    /// Matched rule metadata.
22    pub metadata: Vec<RuleMetadata>,
23}
24
25impl Intervention {
26    /// Create a new intervention.
27    pub fn new(status: u16, phase: Phase) -> Self {
28        Self {
29            status,
30            url: None,
31            log: None,
32            rule_ids: Vec::new(),
33            phase,
34            drop_connection: false,
35            metadata: Vec::new(),
36        }
37    }
38
39    /// Create a deny intervention.
40    pub fn deny(status: u16, phase: Phase, rule_id: Option<String>) -> Self {
41        let mut intervention = Self::new(status, phase);
42        if let Some(id) = rule_id {
43            intervention.rule_ids.push(id);
44        }
45        intervention
46    }
47
48    /// Create a redirect intervention.
49    pub fn redirect(url: String, phase: Phase, rule_id: Option<String>) -> Self {
50        let mut intervention = Self::new(302, phase);
51        intervention.url = Some(url);
52        if let Some(id) = rule_id {
53            intervention.rule_ids.push(id);
54        }
55        intervention
56    }
57
58    /// Create a drop intervention.
59    pub fn drop(phase: Phase, rule_id: Option<String>) -> Self {
60        let mut intervention = Self::new(444, phase);
61        intervention.drop_connection = true;
62        if let Some(id) = rule_id {
63            intervention.rule_ids.push(id);
64        }
65        intervention
66    }
67
68    /// Add a rule ID.
69    pub fn add_rule_id(&mut self, id: String) {
70        self.rule_ids.push(id);
71    }
72
73    /// Add metadata from a matched rule.
74    pub fn add_metadata(&mut self, metadata: RuleMetadata) {
75        if let Some(ref id) = metadata.id {
76            self.rule_ids.push(id.clone());
77        }
78        if let Some(ref msg) = metadata.msg {
79            if self.log.is_none() {
80                self.log = Some(msg.clone());
81            }
82        }
83        self.metadata.push(metadata);
84    }
85
86    /// Set log message.
87    pub fn set_log(&mut self, log: String) {
88        self.log = Some(log);
89    }
90
91    /// Format as a log entry.
92    pub fn format_log(&self) -> String {
93        let mut parts = vec![format!("[status {}]", self.status)];
94
95        if !self.rule_ids.is_empty() {
96            parts.push(format!("[rule_ids: {}]", self.rule_ids.join(", ")));
97        }
98
99        if let Some(ref log) = self.log {
100            parts.push(format!("[msg: {}]", log));
101        }
102
103        if let Some(ref url) = self.url {
104            parts.push(format!("[redirect: {}]", url));
105        }
106
107        parts.push(format!("[phase: {}]", self.phase.name()));
108
109        parts.join(" ")
110    }
111}
112
113impl Default for Intervention {
114    fn default() -> Self {
115        Self::new(403, Phase::RequestHeaders)
116    }
117}
118
119/// Builder for creating interventions.
120#[derive(Debug, Clone)]
121pub struct InterventionBuilder {
122    intervention: Intervention,
123}
124
125impl InterventionBuilder {
126    /// Create a new builder.
127    pub fn new() -> Self {
128        Self {
129            intervention: Intervention::default(),
130        }
131    }
132
133    /// Set status code.
134    pub fn status(mut self, status: u16) -> Self {
135        self.intervention.status = status;
136        self
137    }
138
139    /// Set phase.
140    pub fn phase(mut self, phase: Phase) -> Self {
141        self.intervention.phase = phase;
142        self
143    }
144
145    /// Set redirect URL.
146    pub fn redirect(mut self, url: String) -> Self {
147        self.intervention.status = 302;
148        self.intervention.url = Some(url);
149        self
150    }
151
152    /// Set drop connection flag.
153    pub fn drop_connection(mut self) -> Self {
154        self.intervention.drop_connection = true;
155        self.intervention.status = 444;
156        self
157    }
158
159    /// Add rule ID.
160    pub fn rule_id(mut self, id: String) -> Self {
161        self.intervention.rule_ids.push(id);
162        self
163    }
164
165    /// Set log message.
166    pub fn log(mut self, msg: String) -> Self {
167        self.intervention.log = Some(msg);
168        self
169    }
170
171    /// Build the intervention.
172    pub fn build(self) -> Intervention {
173        self.intervention
174    }
175}
176
177impl Default for InterventionBuilder {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_deny_intervention() {
189        let intervention = Intervention::deny(403, Phase::RequestHeaders, Some("12345".to_string()));
190        assert_eq!(intervention.status, 403);
191        assert_eq!(intervention.rule_ids, vec!["12345".to_string()]);
192        assert!(!intervention.drop_connection);
193    }
194
195    #[test]
196    fn test_redirect_intervention() {
197        let intervention = Intervention::redirect(
198            "https://example.com/blocked".to_string(),
199            Phase::RequestHeaders,
200            Some("12345".to_string()),
201        );
202        assert_eq!(intervention.status, 302);
203        assert_eq!(
204            intervention.url,
205            Some("https://example.com/blocked".to_string())
206        );
207    }
208
209    #[test]
210    fn test_builder() {
211        let intervention = InterventionBuilder::new()
212            .status(403)
213            .phase(Phase::RequestBody)
214            .rule_id("100".to_string())
215            .log("SQL Injection detected".to_string())
216            .build();
217
218        assert_eq!(intervention.status, 403);
219        assert_eq!(intervention.phase, Phase::RequestBody);
220        assert_eq!(intervention.rule_ids, vec!["100".to_string()]);
221    }
222
223    #[test]
224    fn test_format_log() {
225        let intervention = Intervention::deny(403, Phase::RequestHeaders, Some("942100".to_string()));
226        let log = intervention.format_log();
227        assert!(log.contains("[status 403]"));
228        assert!(log.contains("942100"));
229    }
230}