sentinel_agent_sentinelsec/
lib.rs

1//! Sentinel SentinelSec Agent Library
2//!
3//! A pure Rust ModSecurity-compatible WAF agent for Sentinel proxy.
4//! Provides full OWASP Core Rule Set (CRS) support without any C dependencies.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use sentinel_agent_sentinelsec::{SentinelSecAgent, SentinelSecConfig};
10//! use sentinel_agent_protocol::AgentServer;
11//!
12//! let config = SentinelSecConfig {
13//!     rules_paths: vec!["/etc/modsecurity/crs/rules/*.conf".to_string()],
14//!     ..Default::default()
15//! };
16//! let agent = SentinelSecAgent::new(config)?;
17//! let server = AgentServer::new("sentinelsec", "/tmp/sentinelsec.sock", Box::new(agent));
18//! server.run().await?;
19//! ```
20
21use anyhow::Result;
22use base64::Engine;
23use serde::{Deserialize, Serialize};
24use std::collections::HashMap;
25use std::fs;
26use std::sync::Arc;
27use tokio::sync::RwLock;
28use tracing::{debug, info, warn};
29
30use sentinel_agent_protocol::{
31    AgentHandler, AgentResponse, AuditMetadata, ConfigureEvent, HeaderOp, RequestBodyChunkEvent,
32    RequestHeadersEvent, ResponseBodyChunkEvent, ResponseHeadersEvent,
33};
34
35use sentinel_modsec::ModSecurity;
36
37/// SentinelSec configuration
38#[derive(Debug, Clone)]
39pub struct SentinelSecConfig {
40    /// Paths to ModSecurity rule files (glob patterns supported)
41    pub rules_paths: Vec<String>,
42    /// Block mode (true) or detect-only mode (false)
43    pub block_mode: bool,
44    /// Paths to exclude from inspection
45    pub exclude_paths: Vec<String>,
46    /// Enable request body inspection
47    pub body_inspection_enabled: bool,
48    /// Maximum body size to inspect in bytes
49    pub max_body_size: usize,
50    /// Enable response body inspection
51    pub response_inspection_enabled: bool,
52}
53
54impl Default for SentinelSecConfig {
55    fn default() -> Self {
56        Self {
57            rules_paths: vec![],
58            block_mode: true,
59            exclude_paths: vec![],
60            body_inspection_enabled: true,
61            max_body_size: 1048576, // 1MB
62            response_inspection_enabled: false,
63        }
64    }
65}
66
67/// JSON-serializable configuration for SentinelSec agent
68///
69/// Used for parsing configuration from the proxy's agent config.
70/// Field names use kebab-case to match YAML/JSON config conventions.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub struct SentinelSecConfigJson {
74    /// Paths to ModSecurity rule files (glob patterns supported)
75    #[serde(default)]
76    pub rules_paths: Vec<String>,
77    /// Block mode (true) or detect-only mode (false)
78    #[serde(default = "default_block_mode")]
79    pub block_mode: bool,
80    /// Paths to exclude from inspection
81    #[serde(default)]
82    pub exclude_paths: Vec<String>,
83    /// Enable request body inspection
84    #[serde(default = "default_body_inspection")]
85    pub body_inspection_enabled: bool,
86    /// Maximum body size to inspect in bytes
87    #[serde(default = "default_max_body_size")]
88    pub max_body_size: usize,
89    /// Enable response body inspection
90    #[serde(default)]
91    pub response_inspection_enabled: bool,
92}
93
94fn default_block_mode() -> bool {
95    true
96}
97
98fn default_body_inspection() -> bool {
99    true
100}
101
102fn default_max_body_size() -> usize {
103    1048576 // 1MB
104}
105
106impl From<SentinelSecConfigJson> for SentinelSecConfig {
107    fn from(json: SentinelSecConfigJson) -> Self {
108        Self {
109            rules_paths: json.rules_paths,
110            block_mode: json.block_mode,
111            exclude_paths: json.exclude_paths,
112            body_inspection_enabled: json.body_inspection_enabled,
113            max_body_size: json.max_body_size,
114            response_inspection_enabled: json.response_inspection_enabled,
115        }
116    }
117}
118
119/// Detection result from SentinelSec
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Detection {
122    /// Rule ID that triggered the detection
123    pub rule_id: String,
124    /// Detection message
125    pub message: String,
126    /// Severity level
127    pub severity: Option<String>,
128}
129
130/// SentinelSec engine wrapper
131pub struct SentinelSecEngine {
132    modsec: ModSecurity,
133    /// Agent configuration
134    pub config: SentinelSecConfig,
135}
136
137impl SentinelSecEngine {
138    /// Create a new SentinelSec engine with the given configuration
139    pub fn new(config: SentinelSecConfig) -> Result<Self> {
140        // Build rules string from all rule files
141        let mut rules_content = String::new();
142
143        // Always enable the rule engine
144        rules_content.push_str("SecRuleEngine On\n");
145
146        // Load rules from configured paths
147        let mut loaded_count = 0;
148        for path_pattern in &config.rules_paths {
149            // Handle glob patterns
150            let paths = glob::glob(path_pattern)
151                .map_err(|e| anyhow::anyhow!("Invalid glob pattern '{}': {}", path_pattern, e))?;
152
153            for entry in paths {
154                match entry {
155                    Ok(path) => {
156                        if path.is_file() {
157                            let content = fs::read_to_string(&path).map_err(|e| {
158                                anyhow::anyhow!("Failed to read rule file {:?}: {}", path, e)
159                            })?;
160                            rules_content.push_str(&content);
161                            rules_content.push('\n');
162                            loaded_count += 1;
163                            debug!(path = ?path, "Loaded rule file");
164                        }
165                    }
166                    Err(e) => {
167                        warn!(error = %e, "Error reading glob entry");
168                    }
169                }
170            }
171        }
172
173        // Create the ModSecurity engine
174        let modsec = if rules_content.trim().is_empty() || loaded_count == 0 {
175            // No rules loaded, create with just SecRuleEngine On
176            ModSecurity::from_string("SecRuleEngine On")
177                .map_err(|e| anyhow::anyhow!("Failed to initialize SentinelSec engine: {}", e))?
178        } else {
179            ModSecurity::from_string(&rules_content)
180                .map_err(|e| anyhow::anyhow!("Failed to parse rules: {}", e))?
181        };
182
183        info!(rules_files = loaded_count, rule_count = modsec.rule_count(), "SentinelSec engine initialized");
184
185        Ok(Self { modsec, config })
186    }
187
188    /// Check if path should be excluded
189    pub fn is_excluded(&self, path: &str) -> bool {
190        self.config
191            .exclude_paths
192            .iter()
193            .any(|p| path.starts_with(p))
194    }
195}
196
197/// Body accumulator for tracking in-progress bodies
198#[derive(Debug, Default)]
199struct BodyAccumulator {
200    data: Vec<u8>,
201}
202
203/// Pending transaction for body accumulation
204struct PendingTransaction {
205    body: BodyAccumulator,
206    method: String,
207    uri: String,
208    headers: HashMap<String, Vec<String>>,
209    #[allow(dead_code)]
210    client_ip: String,
211}
212
213/// SentinelSec agent
214pub struct SentinelSecAgent {
215    engine: Arc<RwLock<SentinelSecEngine>>,
216    pending_requests: Arc<RwLock<HashMap<String, PendingTransaction>>>,
217}
218
219impl SentinelSecAgent {
220    /// Create a new SentinelSec agent with the given configuration
221    pub fn new(config: SentinelSecConfig) -> Result<Self> {
222        let engine = SentinelSecEngine::new(config)?;
223        Ok(Self {
224            engine: Arc::new(RwLock::new(engine)),
225            pending_requests: Arc::new(RwLock::new(HashMap::new())),
226        })
227    }
228
229    /// Reconfigure the agent with new settings
230    ///
231    /// This rebuilds the SentinelSec engine with the new configuration.
232    /// In-flight requests using the old engine will complete normally.
233    pub async fn reconfigure(&self, config: SentinelSecConfig) -> Result<()> {
234        info!("Reconfiguring SentinelSec engine");
235        let new_engine = SentinelSecEngine::new(config)?;
236        let mut engine = self.engine.write().await;
237        *engine = new_engine;
238        // Clear pending requests since rules may have changed
239        let mut pending = self.pending_requests.write().await;
240        pending.clear();
241        info!("SentinelSec engine reconfigured successfully");
242        Ok(())
243    }
244
245    /// Process a complete request through SentinelSec
246    async fn process_request(
247        &self,
248        correlation_id: &str,
249        method: &str,
250        uri: &str,
251        headers: &HashMap<String, Vec<String>>,
252        body: Option<&[u8]>,
253    ) -> Result<Option<(u16, String, Vec<String>)>> {
254        let engine = self.engine.read().await;
255
256        // Create a new transaction
257        let mut tx = engine.modsec.new_transaction();
258
259        // Process URI
260        tx.process_uri(uri, method, "HTTP/1.1")
261            .map_err(|e| anyhow::anyhow!("process_uri failed: {}", e))?;
262
263        // Add headers
264        for (name, values) in headers {
265            for value in values {
266                tx.add_request_header(name, value)
267                    .map_err(|e| anyhow::anyhow!("add_request_header failed: {}", e))?;
268            }
269        }
270
271        // Process request headers (phase 1)
272        tx.process_request_headers()
273            .map_err(|e| anyhow::anyhow!("process_request_headers failed: {}", e))?;
274
275        // Check for intervention after headers
276        if let Some(intervention) = tx.intervention() {
277            let status = intervention.status;
278            if status != 0 && status != 200 {
279                debug!(
280                    correlation_id = correlation_id,
281                    status = status,
282                    "SentinelSec intervention (headers)"
283                );
284                let rule_ids = tx.matched_rules().iter().map(|s| s.to_string()).collect();
285                return Ok(Some((status, "Blocked by SentinelSec".to_string(), rule_ids)));
286            }
287        }
288
289        // Process body if provided (phase 2)
290        if let Some(body_data) = body {
291            if !body_data.is_empty() {
292                tx.append_request_body(body_data)
293                    .map_err(|e| anyhow::anyhow!("append_request_body failed: {}", e))?;
294                tx.process_request_body()
295                    .map_err(|e| anyhow::anyhow!("process_request_body failed: {}", e))?;
296
297                // Check for intervention after body
298                if let Some(intervention) = tx.intervention() {
299                    let status = intervention.status;
300                    if status != 0 && status != 200 {
301                        debug!(
302                            correlation_id = correlation_id,
303                            status = status,
304                            "SentinelSec intervention (body)"
305                        );
306                        let rule_ids = tx.matched_rules().iter().map(|s| s.to_string()).collect();
307                        return Ok(Some((status, "Blocked by SentinelSec".to_string(), rule_ids)));
308                    }
309                }
310            }
311        }
312
313        Ok(None)
314    }
315}
316
317#[async_trait::async_trait]
318impl AgentHandler for SentinelSecAgent {
319    async fn on_configure(&self, event: ConfigureEvent) -> AgentResponse {
320        debug!(agent_id = %event.agent_id, "Received configure event");
321
322        // Parse the JSON config into SentinelSecConfigJson
323        let config_json: SentinelSecConfigJson = match serde_json::from_value(event.config) {
324            Ok(config) => config,
325            Err(e) => {
326                warn!(error = %e, "Failed to parse SentinelSec configuration");
327                // Return allow but log the error - agent can still work with existing config
328                return AgentResponse::default_allow();
329            }
330        };
331
332        // Convert to internal config and reconfigure the engine
333        let config: SentinelSecConfig = config_json.into();
334        if let Err(e) = self.reconfigure(config).await {
335            warn!(error = %e, "Failed to reconfigure SentinelSec engine");
336            // Return allow but log the error
337            return AgentResponse::default_allow();
338        }
339
340        info!(agent_id = %event.agent_id, "SentinelSec agent configured successfully");
341        AgentResponse::default_allow()
342    }
343
344    async fn on_request_headers(&self, event: RequestHeadersEvent) -> AgentResponse {
345        let path = &event.uri;
346        let correlation_id = &event.metadata.correlation_id;
347
348        // Check exclusions
349        {
350            let engine = self.engine.read().await;
351            if engine.is_excluded(path) {
352                debug!(path = path, "Path excluded from SentinelSec");
353                return AgentResponse::default_allow();
354            }
355        }
356
357        // Always process headers immediately (phase 1)
358        // This detects attacks in URI, query string, and headers
359        match self
360            .process_request(
361                correlation_id,
362                &event.method,
363                &event.uri,
364                &event.headers,
365                None,
366            )
367            .await
368        {
369            Ok(Some((status, message, rule_ids))) => {
370                let engine = self.engine.read().await;
371                if engine.config.block_mode {
372                    info!(
373                        correlation_id = correlation_id,
374                        status = status,
375                        rules = ?rule_ids,
376                        "Request blocked by SentinelSec"
377                    );
378                    let rule_id = rule_ids.first().cloned().unwrap_or_default();
379                    AgentResponse::block(status, Some("Forbidden".to_string()))
380                        .add_response_header(HeaderOp::Set {
381                            name: "X-WAF-Blocked".to_string(),
382                            value: "true".to_string(),
383                        })
384                        .add_response_header(HeaderOp::Set {
385                            name: "X-WAF-Rule".to_string(),
386                            value: rule_id,
387                        })
388                        .add_response_header(HeaderOp::Set {
389                            name: "X-WAF-Message".to_string(),
390                            value: message.clone(),
391                        })
392                        .with_audit(AuditMetadata {
393                            tags: vec!["sentinelsec".to_string(), "blocked".to_string()],
394                            rule_ids,
395                            reason_codes: vec![message],
396                            ..Default::default()
397                        })
398                } else {
399                    info!(
400                        correlation_id = correlation_id,
401                        rules = ?rule_ids,
402                        "SentinelSec detection (detect-only mode)"
403                    );
404                    AgentResponse::default_allow()
405                        .add_request_header(HeaderOp::Set {
406                            name: "X-WAF-Detected".to_string(),
407                            value: message.clone(),
408                        })
409                        .with_audit(AuditMetadata {
410                            tags: vec!["sentinelsec".to_string(), "detected".to_string()],
411                            rule_ids,
412                            reason_codes: vec![message],
413                            ..Default::default()
414                        })
415                }
416            }
417            Ok(None) => {
418                // Headers passed - if body inspection enabled, store for body processing
419                let engine = self.engine.read().await;
420                if engine.config.body_inspection_enabled {
421                    let mut pending = self.pending_requests.write().await;
422                    pending.insert(
423                        correlation_id.clone(),
424                        PendingTransaction {
425                            body: BodyAccumulator::default(),
426                            method: event.method.clone(),
427                            uri: event.uri.clone(),
428                            headers: event.headers.clone(),
429                            client_ip: event.metadata.client_ip.clone(),
430                        },
431                    );
432                }
433                AgentResponse::default_allow()
434            }
435            Err(e) => {
436                warn!(error = %e, "SentinelSec processing error");
437                AgentResponse::default_allow()
438            }
439        }
440    }
441
442    async fn on_response_headers(&self, _event: ResponseHeadersEvent) -> AgentResponse {
443        AgentResponse::default_allow()
444    }
445
446    async fn on_request_body_chunk(&self, event: RequestBodyChunkEvent) -> AgentResponse {
447        let correlation_id = &event.correlation_id;
448
449        // Check if we have a pending request
450        let pending_exists = {
451            let pending = self.pending_requests.read().await;
452            pending.contains_key(correlation_id)
453        };
454
455        if !pending_exists {
456            // No pending request - body inspection might be disabled
457            return AgentResponse::default_allow();
458        }
459
460        // Decode base64 chunk
461        let chunk = match base64::engine::general_purpose::STANDARD.decode(&event.data) {
462            Ok(data) => data,
463            Err(e) => {
464                warn!(error = %e, "Failed to decode body chunk");
465                return AgentResponse::default_allow();
466            }
467        };
468
469        // Accumulate chunk
470        let should_process = {
471            let mut pending = self.pending_requests.write().await;
472            if let Some(tx) = pending.get_mut(correlation_id) {
473                let engine = self.engine.read().await;
474
475                // Check size limit
476                if tx.body.data.len() + chunk.len() > engine.config.max_body_size {
477                    debug!(
478                        correlation_id = correlation_id,
479                        "Body exceeds max size, skipping inspection"
480                    );
481                    pending.remove(correlation_id);
482                    return AgentResponse::default_allow();
483                }
484
485                tx.body.data.extend(chunk);
486                event.is_last
487            } else {
488                false
489            }
490        };
491
492        // If this is the last chunk, process the complete request
493        if should_process {
494            let pending_tx = {
495                let mut pending = self.pending_requests.write().await;
496                pending.remove(correlation_id)
497            };
498
499            if let Some(tx) = pending_tx {
500                match self
501                    .process_request(
502                        correlation_id,
503                        &tx.method,
504                        &tx.uri,
505                        &tx.headers,
506                        Some(&tx.body.data),
507                    )
508                    .await
509                {
510                    Ok(Some((status, message, rule_ids))) => {
511                        let engine = self.engine.read().await;
512                        if engine.config.block_mode {
513                            info!(
514                                correlation_id = correlation_id,
515                                status = status,
516                                rules = ?rule_ids,
517                                "Request blocked by SentinelSec (body inspection)"
518                            );
519                            let rule_id = rule_ids.first().cloned().unwrap_or_default();
520                            return AgentResponse::block(status, Some("Forbidden".to_string()))
521                                .add_response_header(HeaderOp::Set {
522                                    name: "X-WAF-Blocked".to_string(),
523                                    value: "true".to_string(),
524                                })
525                                .add_response_header(HeaderOp::Set {
526                                    name: "X-WAF-Rule".to_string(),
527                                    value: rule_id,
528                                })
529                                .add_response_header(HeaderOp::Set {
530                                    name: "X-WAF-Message".to_string(),
531                                    value: message.clone(),
532                                })
533                                .with_audit(AuditMetadata {
534                                    tags: vec![
535                                        "sentinelsec".to_string(),
536                                        "blocked".to_string(),
537                                        "body".to_string(),
538                                    ],
539                                    rule_ids,
540                                    reason_codes: vec![message],
541                                    ..Default::default()
542                                });
543                        } else {
544                            info!(
545                                correlation_id = correlation_id,
546                                rules = ?rule_ids,
547                                "SentinelSec detection in body (detect-only mode)"
548                            );
549                            return AgentResponse::default_allow()
550                                .add_request_header(HeaderOp::Set {
551                                    name: "X-WAF-Detected".to_string(),
552                                    value: message.clone(),
553                                })
554                                .with_audit(AuditMetadata {
555                                    tags: vec![
556                                        "sentinelsec".to_string(),
557                                        "detected".to_string(),
558                                        "body".to_string(),
559                                    ],
560                                    rule_ids,
561                                    reason_codes: vec![message],
562                                    ..Default::default()
563                                });
564                        }
565                    }
566                    Ok(None) => {}
567                    Err(e) => {
568                        warn!(error = %e, "SentinelSec body processing error");
569                    }
570                }
571            }
572        }
573
574        AgentResponse::default_allow()
575    }
576
577    async fn on_response_body_chunk(&self, event: ResponseBodyChunkEvent) -> AgentResponse {
578        // Response body inspection not yet implemented
579        let _ = event;
580        AgentResponse::default_allow()
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn test_default_config() {
590        let config = SentinelSecConfig::default();
591        assert!(config.rules_paths.is_empty());
592        assert!(config.block_mode);
593        assert!(config.body_inspection_enabled);
594        assert!(!config.response_inspection_enabled);
595        assert_eq!(config.max_body_size, 1048576);
596    }
597
598    #[test]
599    fn test_engine_initialization() {
600        let config = SentinelSecConfig::default();
601        let engine = SentinelSecEngine::new(config);
602        assert!(engine.is_ok());
603    }
604
605    #[test]
606    fn test_engine_with_inline_rule() {
607        // Test with a simple inline rule
608        let config = SentinelSecConfig::default();
609        let engine = SentinelSecEngine::new(config).unwrap();
610
611        // Verify engine is working
612        let mut tx = engine.modsec.new_transaction();
613        tx.process_uri("/test", "GET", "HTTP/1.1").unwrap();
614        tx.process_request_headers().unwrap();
615
616        // Should not block clean requests
617        assert!(tx.intervention().is_none());
618    }
619
620    #[test]
621    fn test_path_exclusion() {
622        let config = SentinelSecConfig {
623            exclude_paths: vec!["/health".to_string(), "/metrics".to_string()],
624            ..Default::default()
625        };
626        let engine = SentinelSecEngine::new(config).unwrap();
627
628        assert!(engine.is_excluded("/health"));
629        assert!(engine.is_excluded("/health/live"));
630        assert!(engine.is_excluded("/metrics"));
631        assert!(!engine.is_excluded("/api/users"));
632    }
633
634    #[test]
635    fn test_sql_injection_blocked() {
636        // Create a ModSecurity engine with a SQL injection detection rule
637        let rules = r#"
638            SecRuleEngine On
639            SecRule ARGS "@detectSQLi" "id:942100,phase:2,deny,status:403,msg:'SQL Injection Attack Detected'"
640            SecRule QUERY_STRING "@detectSQLi" "id:942101,phase:1,deny,status:403,msg:'SQL Injection in Query String'"
641            SecRule REQUEST_URI "@contains union select" "id:942102,phase:1,deny,status:403,msg:'UNION SELECT detected'"
642        "#;
643
644        let modsec = sentinel_modsec::ModSecurity::from_string(rules).unwrap();
645
646        // Test 1: Classic SQL injection in query string
647        let mut tx = modsec.new_transaction();
648        tx.process_uri("/api/users?id=1' OR '1'='1", "GET", "HTTP/1.1").unwrap();
649        tx.process_request_headers().unwrap();
650
651        let intervention = tx.intervention();
652        assert!(
653            intervention.is_some(),
654            "Expected SQL injection to be blocked: 1' OR '1'='1"
655        );
656        if let Some(i) = intervention {
657            assert_eq!(i.status, 403);
658            println!("Blocked with status {}: {:?}", i.status, i.rule_ids);
659        }
660
661        // Test 2: UNION-based SQL injection
662        let mut tx2 = modsec.new_transaction();
663        tx2.process_uri("/api/users?id=1 union select * from users--", "GET", "HTTP/1.1").unwrap();
664        tx2.process_request_headers().unwrap();
665
666        let intervention2 = tx2.intervention();
667        assert!(
668            intervention2.is_some(),
669            "Expected UNION SELECT injection to be blocked"
670        );
671
672        // Test 3: Clean request should pass
673        let mut tx3 = modsec.new_transaction();
674        tx3.process_uri("/api/users?id=123", "GET", "HTTP/1.1").unwrap();
675        tx3.process_request_headers().unwrap();
676
677        assert!(
678            tx3.intervention().is_none(),
679            "Clean request should not be blocked"
680        );
681    }
682
683    #[test]
684    fn test_xss_blocked() {
685        // Create a ModSecurity engine with XSS detection rule
686        let rules = r#"
687            SecRuleEngine On
688            SecRule ARGS "@detectXSS" "id:941100,phase:2,deny,status:403,msg:'XSS Attack Detected'"
689            SecRule QUERY_STRING "@detectXSS" "id:941101,phase:1,deny,status:403,msg:'XSS in Query String'"
690            SecRule REQUEST_URI "@contains <script" "id:941102,phase:1,deny,status:403,msg:'Script tag detected'"
691        "#;
692
693        let modsec = sentinel_modsec::ModSecurity::from_string(rules).unwrap();
694
695        // Test 1: Script tag injection
696        let mut tx = modsec.new_transaction();
697        tx.process_uri("/search?q=<script>alert(1)</script>", "GET", "HTTP/1.1").unwrap();
698        tx.process_request_headers().unwrap();
699
700        let intervention = tx.intervention();
701        assert!(
702            intervention.is_some(),
703            "Expected XSS to be blocked: <script>alert(1)</script>"
704        );
705        if let Some(i) = intervention {
706            assert_eq!(i.status, 403);
707            println!("XSS blocked with status {}: {:?}", i.status, i.rule_ids);
708        }
709
710        // Test 2: Event handler injection
711        let mut tx2 = modsec.new_transaction();
712        tx2.process_uri("/search?q=<img src=x onerror=alert(1)>", "GET", "HTTP/1.1").unwrap();
713        tx2.process_request_headers().unwrap();
714
715        let intervention2 = tx2.intervention();
716        assert!(
717            intervention2.is_some(),
718            "Expected event handler XSS to be blocked"
719        );
720
721        // Test 3: Clean request should pass
722        let mut tx3 = modsec.new_transaction();
723        tx3.process_uri("/search?q=hello+world", "GET", "HTTP/1.1").unwrap();
724        tx3.process_request_headers().unwrap();
725
726        assert!(
727            tx3.intervention().is_none(),
728            "Clean request should not be blocked"
729        );
730    }
731
732    #[test]
733    fn test_request_body_sql_injection() {
734        // Test SQL injection in POST body
735        let rules = r#"
736            SecRuleEngine On
737            SecRequestBodyAccess On
738            SecRule ARGS "@detectSQLi" "id:942200,phase:2,deny,status:403,msg:'SQL Injection in Body'"
739        "#;
740
741        let modsec = sentinel_modsec::ModSecurity::from_string(rules).unwrap();
742
743        let mut tx = modsec.new_transaction();
744        tx.process_uri("/api/login", "POST", "HTTP/1.1").unwrap();
745        tx.add_request_header("Content-Type", "application/x-www-form-urlencoded").unwrap();
746        tx.process_request_headers().unwrap();
747
748        // Add malicious body
749        let body = b"username=admin&password=' OR '1'='1";
750        tx.append_request_body(body).unwrap();
751        tx.process_request_body().unwrap();
752
753        let intervention = tx.intervention();
754        assert!(
755            intervention.is_some(),
756            "Expected SQL injection in POST body to be blocked"
757        );
758        if let Some(i) = intervention {
759            assert_eq!(i.status, 403);
760            println!("Body SQLi blocked: {:?}", i.rule_ids);
761        }
762    }
763}