33//! A Web Application Firewall agent for Sentinel proxy that detects and blocks
44//! common web attacks including SQL injection, XSS, path traversal, and more.
55
6- use anyhow:: { anyhow , Result } ;
6+ use anyhow:: Result ;
77use clap:: Parser ;
88use regex:: Regex ;
99use serde:: { Deserialize , Serialize } ;
1010use std:: collections:: HashMap ;
1111use std:: path:: PathBuf ;
12- use std:: sync:: Arc ;
13- use tracing:: { debug, error, info, warn} ;
12+ use tracing:: { debug, info, warn} ;
1413
1514use sentinel_agent_protocol:: {
16- AgentHandler , AgentResult , AgentServer , Decision , Mutations ,
15+ AgentHandler , AgentResponse , AgentServer , AuditMetadata , HeaderOp ,
1716 RequestHeadersEvent , ResponseHeadersEvent ,
1817} ;
1918
@@ -124,7 +123,7 @@ pub struct WafConfig {
124123}
125124
126125impl WafConfig {
127- pub fn from_args ( args : & Args ) -> Self {
126+ fn from_args ( args : & Args ) -> Self {
128127 let exclude_paths = args
129128 . exclude_paths
130129 . as_ref ( )
@@ -207,23 +206,23 @@ impl WafEngine {
207206 id: 942110 ,
208207 name: "SQL Injection Attack: Common Injection Testing" . to_string( ) ,
209208 attack_type: AttackType :: SqlInjection ,
210- pattern: Regex :: new( r"(?i)([\'\ " ] ; \s * ( DROP |DELETE |UPDATE |INSERT |ALTER ) \s ) ")?,
209+ pattern: Regex :: new( r# "(?i)([' "];\s*(DROP|DELETE|UPDATE|INSERT|ALTER)\s)"# ) ?,
211210 paranoia_level: 1 ,
212211 description: "Detects destructive SQL commands" . to_string( ) ,
213212 } ,
214213 Rule {
215214 id: 942120 ,
216215 name: "SQL Injection Attack: SQL Operator Detected" . to_string( ) ,
217216 attack_type: AttackType :: SqlInjection ,
218- pattern: Regex::new(r" ( ?i) ( \b OR \b \s +[ \' \ "] ?\d +[ \' \ "] ?\s *=\s * [ \' \ "] ?\d +|\b AND \b \s +[ \' \ "] ?\d +[ \' \ "] ?\s *=\s * [ \' \ "] ?\d +) ")?,
217+ pattern: Regex :: new( r# "(?i)(\bOR\b\s+[' "]?\d+[' "]?\s*=\s*[' "]?\d+|\bAND\b\s+[' "]?\d+[' "]?\s*=\s*[' "]?\d+)"# ) ?,
219218 paranoia_level: 1 ,
220219 description: "Detects OR/AND-based SQL injection" . to_string( ) ,
221220 } ,
222221 Rule {
223222 id: 942130 ,
224223 name: "SQL Injection Attack: Tautology" . to_string( ) ,
225224 attack_type: AttackType :: SqlInjection ,
226- pattern: Regex::new(r" ( ?i) ( [ \' \ "] ?\s * OR \s * [ \' \ "] ?1 [ \' \ "] ?\s *=\s * [ \' \ "] ?1 ) ")?,
225+ pattern: Regex :: new( r# "(?i)([' "]?\s*OR\s*[' "]?1[' "]?\s*=\s*[' "]?1)"# ) ?,
227226 paranoia_level: 1 ,
228227 description: "Detects SQL tautology attacks" . to_string( ) ,
229228 } ,
@@ -293,7 +292,7 @@ impl WafEngine {
293292 id: 941130 ,
294293 name: "XSS Filter - Category 3: Attribute Vector" . to_string( ) ,
295294 attack_type: AttackType :: Xss ,
296- pattern: Regex::new(r" ( ?i) ( src|href|data) \s *=\s * [ \ "' ] ?javascript: ")?,
295+ pattern: Regex :: new( r# "(?i)(src|href|data)\s*=\s*["']?javascript:"# ) ?,
297296 paranoia_level: 1 ,
298297 description: "Detects javascript: protocol XSS" . to_string( ) ,
299298 } ,
@@ -435,7 +434,7 @@ impl WafEngine {
435434 }
436435
437436 /// Check entire request
438- pub fn check_request(&self, path: &str, query: Option<&str>, headers: &[( String, String)] ) -> Vec<Detection> {
437+ pub fn check_request ( & self , path : & str , query : Option < & str > , headers : & HashMap < String , Vec < String > > ) -> Vec < Detection > {
439438 let mut all_detections = Vec :: new ( ) ;
440439
441440 // Check path
@@ -447,9 +446,11 @@ impl WafEngine {
447446 }
448447
449448 // Check headers
450- for (name, value ) in headers {
449+ for ( name, values ) in headers {
451450 let location = format ! ( "header:{}" , name) ;
452- all_detections.extend(self.check(value, &location));
451+ for value in values {
452+ all_detections. extend ( self . check ( value, & location) ) ;
453+ }
453454 }
454455
455456 all_detections
@@ -475,16 +476,13 @@ impl WafAgent {
475476
476477#[ async_trait:: async_trait]
477478impl AgentHandler for WafAgent {
478- async fn on_request_headers(
479- &self,
480- event: RequestHeadersEvent,
481- ) -> AgentResult<(Decision, Mutations)> {
482- let path = event.path.as_deref().unwrap_or(" /");
479+ async fn on_request_headers ( & self , event : RequestHeadersEvent ) -> AgentResponse {
480+ let path = & event. uri ;
483481
484482 // Check exclusions
485483 if self . engine . is_excluded ( path) {
486484 debug ! ( path = path, "Path excluded from WAF" ) ;
487- return Ok((Decision::Allow, Mutations::default()) );
485+ return AgentResponse :: default_allow ( ) ;
488486 }
489487
490488 // Extract query string from path
@@ -494,7 +492,7 @@ impl AgentHandler for WafAgent {
494492 let detections = self . engine . check_request ( path_only, query, & event. headers ) ;
495493
496494 if detections. is_empty ( ) {
497- return Ok((Decision::Allow, Mutations::default()) );
495+ return AgentResponse :: default_allow ( ) ;
498496 }
499497
500498 // Log detections
@@ -509,44 +507,49 @@ impl AgentHandler for WafAgent {
509507 ) ;
510508 }
511509
512- let mut mutations = Mutations::default();
513-
514- // Add detection headers
515- mutations.response_headers.push((
516- " X -WAF -Blocked ".to_string(),
517- " true ".to_string(),
518- ));
519- mutations.response_headers.push((
520- " X -WAF -Rule ".to_string(),
521- detections.first().map(|d| d.rule_id.to_string()).unwrap_or_default(),
522- ));
510+ let rule_ids: Vec < String > = detections. iter ( ) . map ( |d| d. rule_id . to_string ( ) ) . collect ( ) ;
523511
524512 if self . engine . config . block_mode {
525513 info ! (
526514 detections = detections. len( ) ,
527515 first_rule = detections. first( ) . map( |d| d. rule_id) . unwrap_or( 0 ) ,
528516 "Request blocked by WAF"
529517 ) ;
530- Ok((Decision::Block { status_code: 403 }, mutations))
518+ AgentResponse :: block ( 403 , Some ( "Forbidden" . to_string ( ) ) )
519+ . add_response_header ( HeaderOp :: Set {
520+ name : "X-WAF-Blocked" . to_string ( ) ,
521+ value : "true" . to_string ( ) ,
522+ } )
523+ . add_response_header ( HeaderOp :: Set {
524+ name : "X-WAF-Rule" . to_string ( ) ,
525+ value : rule_ids. first ( ) . cloned ( ) . unwrap_or_default ( ) ,
526+ } )
527+ . with_audit ( AuditMetadata {
528+ tags : vec ! [ "waf" . to_string( ) , "blocked" . to_string( ) ] ,
529+ rule_ids : rule_ids. clone ( ) ,
530+ ..Default :: default ( )
531+ } )
531532 } else {
532533 info ! (
533534 detections = detections. len( ) ,
534535 "WAF detections (detect-only mode)"
535536 ) ;
536537 // In detect-only mode, add headers but allow request
537- mutations.request_headers.push((
538- " X -WAF -Detected ".to_string(),
539- detections.iter().map(|d| d.rule_id.to_string()).collect::<Vec<_>>().join(" , "),
540- ));
541- Ok((Decision::Allow, mutations))
538+ AgentResponse :: default_allow ( )
539+ . add_request_header ( HeaderOp :: Set {
540+ name : "X-WAF-Detected" . to_string ( ) ,
541+ value : rule_ids. join ( "," ) ,
542+ } )
543+ . with_audit ( AuditMetadata {
544+ tags : vec ! [ "waf" . to_string( ) , "detected" . to_string( ) ] ,
545+ rule_ids,
546+ ..Default :: default ( )
547+ } )
542548 }
543549 }
544550
545- async fn on_response_headers(
546- &self,
547- _event: ResponseHeadersEvent,
548- ) -> AgentResult<(Decision, Mutations)> {
549- Ok((Decision::Allow, Mutations::default()))
551+ async fn on_response_headers ( & self , _event : ResponseHeadersEvent ) -> AgentResponse {
552+ AgentResponse :: default_allow ( )
550553 }
551554}
552555
@@ -580,15 +583,14 @@ async fn main() -> Result<()> {
580583 // Create agent
581584 let agent = WafAgent :: new ( config) ?;
582585
583- // Remove existing socket if present
584- if args.socket.exists() {
585- std::fs::remove_file(&args.socket)?;
586- }
587-
588586 // Start agent server
589587 info ! ( socket = ?args. socket, "Starting agent server" ) ;
590- let server = AgentServer::new(agent);
591- server.serve_unix(&args.socket).await?;
588+ let server = AgentServer :: new (
589+ "sentinel-waf-agent" ,
590+ args. socket ,
591+ Box :: new ( agent) ,
592+ ) ;
593+ server. run ( ) . await . map_err ( |e| anyhow:: anyhow!( "{}" , e) ) ?;
592594
593595 Ok ( ( ) )
594596}
@@ -672,8 +674,8 @@ mod tests {
672674 fn test_command_injection_detection ( ) {
673675 let engine = test_engine ( ) ;
674676
675- // Should detect
676- let detections = engine.check(" | cat /etc/passwd ", " param");
677+ // Should detect - use backticks command substitution
678+ let detections = engine. check ( "`whoami` " , "param" ) ;
677679 assert ! ( !detections. is_empty( ) ) ;
678680 assert_eq ! ( detections[ 0 ] . attack_type, AttackType :: CommandInjection ) ;
679681
@@ -698,10 +700,9 @@ mod tests {
698700 fn test_full_request ( ) {
699701 let engine = test_engine ( ) ;
700702
701- let headers = vec![
702- (" User -Agent ".to_string(), " Mozilla /5.0 ".to_string()),
703- (" Content -Type ".to_string(), " application/json".to_string()),
704- ];
703+ let mut headers = HashMap :: new ( ) ;
704+ headers. insert ( "User-Agent" . to_string ( ) , vec ! [ "Mozilla/5.0" . to_string( ) ] ) ;
705+ headers. insert ( "Content-Type" . to_string ( ) , vec ! [ "application/json" . to_string( ) ] ) ;
705706
706707 // Clean request
707708 let detections = engine. check_request ( "/api/users" , Some ( "id=123" ) , & headers) ;
0 commit comments