1use 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#[derive(Debug, Clone)]
39pub struct SentinelSecConfig {
40 pub rules_paths: Vec<String>,
42 pub block_mode: bool,
44 pub exclude_paths: Vec<String>,
46 pub body_inspection_enabled: bool,
48 pub max_body_size: usize,
50 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, response_inspection_enabled: false,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "kebab-case")]
73pub struct SentinelSecConfigJson {
74 #[serde(default)]
76 pub rules_paths: Vec<String>,
77 #[serde(default = "default_block_mode")]
79 pub block_mode: bool,
80 #[serde(default)]
82 pub exclude_paths: Vec<String>,
83 #[serde(default = "default_body_inspection")]
85 pub body_inspection_enabled: bool,
86 #[serde(default = "default_max_body_size")]
88 pub max_body_size: usize,
89 #[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 }
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#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Detection {
122 pub rule_id: String,
124 pub message: String,
126 pub severity: Option<String>,
128}
129
130pub struct SentinelSecEngine {
132 modsec: ModSecurity,
133 pub config: SentinelSecConfig,
135}
136
137impl SentinelSecEngine {
138 pub fn new(config: SentinelSecConfig) -> Result<Self> {
140 let mut rules_content = String::new();
142
143 rules_content.push_str("SecRuleEngine On\n");
145
146 let mut loaded_count = 0;
148 for path_pattern in &config.rules_paths {
149 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 let modsec = if rules_content.trim().is_empty() || loaded_count == 0 {
175 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 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#[derive(Debug, Default)]
199struct BodyAccumulator {
200 data: Vec<u8>,
201}
202
203struct 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
213pub struct SentinelSecAgent {
215 engine: Arc<RwLock<SentinelSecEngine>>,
216 pending_requests: Arc<RwLock<HashMap<String, PendingTransaction>>>,
217}
218
219impl SentinelSecAgent {
220 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 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 let mut pending = self.pending_requests.write().await;
240 pending.clear();
241 info!("SentinelSec engine reconfigured successfully");
242 Ok(())
243 }
244
245 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 let mut tx = engine.modsec.new_transaction();
258
259 tx.process_uri(uri, method, "HTTP/1.1")
261 .map_err(|e| anyhow::anyhow!("process_uri failed: {}", e))?;
262
263 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 tx.process_request_headers()
273 .map_err(|e| anyhow::anyhow!("process_request_headers failed: {}", e))?;
274
275 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 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 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 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 AgentResponse::default_allow();
329 }
330 };
331
332 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 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 {
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 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 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 let pending_exists = {
451 let pending = self.pending_requests.read().await;
452 pending.contains_key(correlation_id)
453 };
454
455 if !pending_exists {
456 return AgentResponse::default_allow();
458 }
459
460 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 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 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 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 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 let config = SentinelSecConfig::default();
609 let engine = SentinelSecEngine::new(config).unwrap();
610
611 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 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 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 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 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 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 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 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 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 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 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 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}