1use std::collections::HashMap;
48use std::time::SystemTime;
49
50use regex::Regex;
51
52#[derive(Debug, Clone, PartialEq, Eq)]
57#[allow(clippy::struct_field_names)]
58pub struct BehaviorRule {
59 pub rule_type: String,
61 pub threshold: u32,
62 pub window: u64,
64 pub pattern: String,
67 pub action: String,
69 pub ban_duration: Option<u64>,
72 pub correlate_with_detection: bool,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum BehaviorAction {
82 Ban {
85 duration: u64,
87 },
88 Note {
92 action: String,
94 },
95 LoggedOnly,
98}
99
100impl BehaviorAction {
101 #[must_use]
104 pub const fn is_ban(&self) -> bool {
105 matches!(self, Self::Ban { .. })
106 }
107}
108
109#[derive(Default)]
114#[allow(clippy::struct_field_names)]
115pub struct BehaviorTracker {
116 return_patterns: HashMap<String, HashMap<String, Vec<f64>>>,
117 status_patterns: HashMap<String, u16>,
118 regex_patterns: HashMap<String, Regex>,
119 usage_counts: HashMap<String, HashMap<String, Vec<f64>>>,
123}
124
125pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
128
129impl BehaviorTracker {
130 #[must_use]
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 pub fn reset(&mut self) {
139 self.return_patterns.clear();
140 self.status_patterns.clear();
141 self.regex_patterns.clear();
142 self.usage_counts.clear();
143 }
144
145 pub fn track_endpoint_usage(
151 &mut self,
152 endpoint_id: &str,
153 client_ip: &str,
154 rule: &BehaviorRule,
155 now: f64,
156 ) -> bool {
157 let window_start = now - f64::from(u32::try_from(rule.window).unwrap_or(u32::MAX));
158 let bucket = self.usage_counts.entry(endpoint_id.to_owned()).or_default();
159 let timestamps = bucket.entry(client_ip.to_owned()).or_default();
160 timestamps.retain(|ts| *ts >= window_start);
161 timestamps.push(now);
162 timestamps.len() > usize::try_from(rule.threshold).unwrap_or(usize::MAX)
163 }
164
165 #[must_use]
169 pub fn get_recent_event_count(&self, ip: &str, window_seconds: u64, now: f64) -> usize {
170 if ip.is_empty() {
171 return 0;
172 }
173 let cutoff = now - f64::from(u32::try_from(window_seconds).unwrap_or(u32::MAX));
174 self.usage_counts
175 .values()
176 .filter_map(|bucket| bucket.get(ip))
177 .map(|timestamps| timestamps.iter().filter(|ts| **ts >= cutoff).count())
178 .sum()
179 }
180
181 #[allow(clippy::too_many_arguments)]
193 pub fn track_return_pattern(
194 &mut self,
195 endpoint_id: &str,
196 client_ip: &str,
197 status: u16,
198 body_prefix: Option<&[u8]>,
199 rule: &BehaviorRule,
200 max_inspect_bytes: usize,
201 now: SystemTime,
202 ) -> bool {
203 if rule.rule_type != "return_pattern" {
204 return false;
205 }
206 let matched =
207 self.check_response_pattern(status, body_prefix, &rule.pattern, max_inspect_bytes);
208 if matched != Some(true) {
209 return false;
214 }
215 let now = unix_now(now);
216 let window_start = now - f64::from(u32::try_from(rule.window).unwrap_or(u32::MAX));
217 let pattern_key = format!("{endpoint_id}:{}", rule.pattern);
218 let timestamps = self
219 .return_patterns
220 .entry(pattern_key)
221 .or_default()
222 .entry(client_ip.to_owned())
223 .or_default();
224 timestamps.retain(|ts| *ts >= window_start);
225 timestamps.push(now);
226 timestamps.len() > rule.threshold as usize
227 }
228
229 #[must_use]
233 #[allow(clippy::too_many_lines)]
234 pub fn check_response_pattern(
235 &mut self,
236 status: u16,
237 body_prefix: Option<&[u8]>,
238 pattern: &str,
239 max_inspect_bytes: usize,
240 ) -> Option<bool> {
241 if let Some(expected) = pattern.strip_prefix("status:") {
242 let expected: u16 = expected.trim().parse().ok()?;
243 return Some(status == expected);
244 }
245 let body = body_prefix?;
249 let body_str = String::from_utf8_lossy(&body[..body.len().min(max_inspect_bytes)]);
250 if let Some(json_pattern) = pattern.strip_prefix("json:") {
251 let parsed = serde_json::from_str::<serde_json::Value>(&body_str).ok()?;
252 return Some(match_json_pattern(&parsed, json_pattern));
253 }
254 if let Some(regex_pattern) = pattern.strip_prefix("regex:") {
255 if !self.regex_patterns.contains_key(regex_pattern) {
256 let Ok(regex) = Regex::new(&format!("(?i){regex_pattern}")) else {
257 return None;
258 };
259 self.regex_patterns.insert(regex_pattern.to_owned(), regex);
260 }
261 let regex = &self.regex_patterns[regex_pattern];
262 return Some(regex.is_match(&body_str));
263 }
264 Some(body_str.to_lowercase().contains(&pattern.to_lowercase()))
265 }
266
267 #[must_use]
273 pub fn dispatch_action(
274 rule: &BehaviorRule,
275 client_ip: &str,
276 passive_mode: bool,
277 ) -> BehaviorAction {
278 let _ = client_ip;
279 if passive_mode {
280 return BehaviorAction::LoggedOnly;
281 }
282 if rule.action == "ban" {
283 return BehaviorAction::Ban {
284 duration: rule.ban_duration.unwrap_or(3600),
285 };
286 }
287 BehaviorAction::Note {
288 action: rule.action.clone(),
289 }
290 }
291}
292
293#[must_use]
295pub fn rule_from_config(cfg: &serde_json::Value) -> Option<BehaviorRule> {
296 let obj = cfg.as_object()?;
297 Some(BehaviorRule {
298 rule_type: obj.get("rule_type")?.as_str()?.to_owned(),
299 threshold: u32::try_from(obj.get("threshold")?.as_u64()?).ok()?,
300 window: obj
301 .get("window")
302 .and_then(serde_json::Value::as_u64)
303 .unwrap_or(3600),
304 pattern: obj
305 .get("pattern")
306 .and_then(serde_json::Value::as_str)
307 .unwrap_or_default()
308 .to_owned(),
309 action: obj
310 .get("action")
311 .and_then(serde_json::Value::as_str)
312 .unwrap_or("log")
313 .to_owned(),
314 ban_duration: obj.get("ban_duration").and_then(serde_json::Value::as_u64),
315 correlate_with_detection: obj
316 .get("correlate_with_detection")
317 .and_then(serde_json::Value::as_bool)
318 .unwrap_or(false),
319 })
320}
321
322#[must_use]
324pub fn unix_now(now: SystemTime) -> f64 {
325 now.duration_since(SystemTime::UNIX_EPOCH)
326 .map(|duration| duration.as_secs_f64())
327 .unwrap_or_default()
328}
329
330#[must_use]
335fn match_json_pattern(data: &serde_json::Value, pattern: &str) -> bool {
336 let Some((path, expected)) = pattern.split_once("==") else {
337 return false;
338 };
339 let expected = expected.trim().trim_matches(['\'', '"']);
340 let mut current = data;
341 for part in path.trim().split('.') {
342 if let Some(field) = part.strip_suffix("[]") {
343 return match_json_array(current, field, expected);
344 }
345 let Some(next) = current.get(part) else {
346 return false;
347 };
348 current = next;
349 }
350 json_scalar_to_string(current).eq_ignore_ascii_case(expected)
351}
352
353fn match_json_array(current: &serde_json::Value, field: &str, expected: &str) -> bool {
354 let Some(list) = current.get(field).and_then(serde_json::Value::as_array) else {
355 return false;
356 };
357 list.iter()
358 .any(|item| json_scalar_to_string(item).eq_ignore_ascii_case(expected))
359}
360
361#[must_use]
364fn json_scalar_to_string(value: &serde_json::Value) -> String {
365 match value {
366 serde_json::Value::String(s) => s.clone(),
367 serde_json::Value::Bool(true) => "true".to_owned(),
368 serde_json::Value::Bool(false) => "false".to_owned(),
369 serde_json::Value::Null => "None".to_owned(),
370 serde_json::Value::Number(n) => n.to_string(),
371 other => other.to_string(),
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 fn rule(pattern: &str, threshold: u32) -> BehaviorRule {
380 BehaviorRule {
381 rule_type: "return_pattern".to_owned(),
382 threshold,
383 window: 3600,
384 pattern: pattern.to_owned(),
385 action: "ban".to_owned(),
386 ban_duration: Some(900),
387 correlate_with_detection: false,
388 }
389 }
390
391 #[test]
392 fn the_action_dispatch_predicate_names_the_ban() {
393 assert!(BehaviorAction::Ban { duration: 3600 }.is_ban());
394 assert!(
395 !BehaviorAction::Note {
396 action: String::from("log")
397 }
398 .is_ban()
399 );
400 assert!(!BehaviorAction::LoggedOnly.is_ban());
401 }
402
403 #[test]
404 fn usage_windows_count_per_identity_and_trip_at_the_threshold() {
405 let base = BehaviorRule {
406 rule_type: "usage".to_owned(),
407 threshold: 2,
408 window: 60,
409 pattern: String::new(),
410 action: "ban".to_owned(),
411 ban_duration: None,
412 correlate_with_detection: false,
413 };
414 let mut tracker = BehaviorTracker::new();
415 assert!(!tracker.track_endpoint_usage("GET:/api", "192.0.2.10", &base, 1_000.0));
417 assert!(!tracker.track_endpoint_usage("GET:/api", "192.0.2.10", &base, 1_001.0));
418 assert!(tracker.track_endpoint_usage("GET:/api", "192.0.2.10", &base, 1_002.0));
420 assert!(!tracker.track_endpoint_usage("GET:/api", "192.0.2.11", &base, 1_003.0));
422 assert!(!tracker.track_endpoint_usage("POST:/api", "192.0.2.10", &base, 1_004.0));
424
425 assert_eq!(
427 tracker.get_recent_event_count("192.0.2.10", 3_600, 1_050.0),
428 4
429 );
430 assert_eq!(tracker.get_recent_event_count("192.0.2.10", 60, 1_062.0), 2);
432 assert_eq!(tracker.get_recent_event_count("", 60, 1_050.0), 0);
434
435 assert!(!tracker.track_endpoint_usage("GET:/api", "192.0.2.10", &base, 1_070.0));
437 }
438
439 #[test]
440 fn status_pattern_trips_above_the_threshold_only() {
441 let mut tracker = BehaviorTracker::new();
442 let rule = rule("status:404", 2);
443 let now = SystemTime::now();
444 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
445 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
446 assert!(tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
447 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
449 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 200, None, &rule, 1, now));
451 }
452
453 #[test]
454 fn sliding_window_forgotten_timestamps_do_not_count() {
455 let mut tracker = BehaviorTracker::new();
456 let rule = rule("status:404", 1);
457 let epoch = SystemTime::UNIX_EPOCH;
458 let t = |secs: u64| epoch + std::time::Duration::from_secs(secs);
459 assert!(!tracker.track_return_pattern(
460 "GET:/api",
461 "192.0.2.10",
462 404,
463 None,
464 &rule,
465 1,
466 t(10_000)
467 ));
468 assert!(tracker.track_return_pattern(
470 "GET:/api",
471 "192.0.2.10",
472 404,
473 None,
474 &rule,
475 1,
476 t(10_100)
477 ));
478 assert!(!tracker.track_return_pattern(
481 "GET:/api",
482 "192.0.2.10",
483 404,
484 None,
485 &rule,
486 1,
487 t(50_000)
488 ));
489 assert!(tracker.track_return_pattern(
490 "GET:/api",
491 "192.0.2.10",
492 404,
493 None,
494 &rule,
495 1,
496 t(50_100)
497 ));
498 }
499
500 #[test]
501 fn body_patterns_match_like_the_reference() {
502 let mut tracker = BehaviorTracker::new();
503 let body = b"{\"error\": \"not found\", \"items\": [1, 2]}".as_slice();
504 let checks = [
505 ("regex:NOT FOUND", Some(true)),
506 ("not found", Some(true)),
507 ("missing", Some(false)),
508 ("json:error==Not Found", Some(true)),
509 ("json:items[]==2", Some(true)),
510 ("json:items[]==3", Some(false)),
511 ("json:nope==x", Some(false)),
512 ("json:broken", Some(false)),
513 ];
514 for (pattern, want) in checks {
515 assert_eq!(
516 tracker.check_response_pattern(
517 200,
518 Some(body),
519 pattern,
520 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
521 ),
522 want,
523 "pattern {pattern}"
524 );
525 }
526 assert_eq!(
528 tracker.check_response_pattern(
529 200,
530 None,
531 "not found",
532 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
533 ),
534 None
535 );
536 }
537
538 #[test]
539 fn dispatch_matches_the_reference_action_semantics() {
540 let ban = rule("status:404", 1);
541 assert_eq!(
542 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", false),
543 BehaviorAction::Ban { duration: 900 }
544 );
545 let no_duration = BehaviorRule {
546 ban_duration: None,
547 ..rule("status:404", 1)
548 };
549 assert_eq!(
550 BehaviorTracker::dispatch_action(&no_duration, "192.0.2.10", false),
551 BehaviorAction::Ban { duration: 3600 }
552 );
553 assert_eq!(
554 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", true),
555 BehaviorAction::LoggedOnly,
556 "passive mode never executes the action"
557 );
558 let log_rule = BehaviorRule {
559 action: "log".to_owned(),
560 ..rule("status:404", 1)
561 };
562 assert_eq!(
563 BehaviorTracker::dispatch_action(&log_rule, "192.0.2.10", false),
564 BehaviorAction::Note {
565 action: "log".to_owned()
566 }
567 );
568 }
569
570 #[test]
571 fn rule_from_config_reads_the_corpus_shape() {
572 let cfg = serde_json::json!({
573 "rule_type": "return_pattern",
574 "threshold": 2,
575 "window": 3600,
576 "pattern": "status:404",
577 "action": "ban",
578 "ban_duration": 900
579 });
580 assert_eq!(rule_from_config(&cfg), Some(rule("status:404", 2)));
581 }
582
583 #[test]
584 fn malformed_configs_answer_none_instead_of_erroring() {
585 assert_eq!(rule_from_config(&serde_json::json!([])), None);
587 assert_eq!(rule_from_config(&serde_json::json!({})), None);
589 assert_eq!(
590 rule_from_config(&serde_json::json!({ "rule_type": 7 })),
591 None
592 );
593 assert_eq!(
595 rule_from_config(&serde_json::json!({ "rule_type": "return_pattern" })),
596 None
597 );
598 assert_eq!(
599 rule_from_config(&serde_json::json!({
600 "rule_type": "return_pattern",
601 "threshold": "many",
602 })),
603 None
604 );
605 assert_eq!(
606 rule_from_config(&serde_json::json!({
607 "rule_type": "return_pattern",
608 "threshold": 5_000_000_000u64,
609 })),
610 None
611 );
612 }
613}
614
615#[cfg(test)]
616mod coverage_tests {
617 use super::*;
618 use std::sync::Arc;
619 use std::sync::atomic::{AtomicU64, Ordering};
620
621 #[derive(Default)]
622 struct FakeClock(Arc<AtomicU64>);
623
624 impl FakeClock {
625 fn now(&self) -> SystemTime {
626 let secs = self.0.load(Ordering::Relaxed);
627 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)
628 }
629 }
630
631 fn return_rule(threshold: u32) -> BehaviorRule {
632 BehaviorRule {
633 rule_type: "return_pattern".to_owned(),
634 threshold,
635 window: 60,
636 pattern: "status:403".to_owned(),
637 action: "log".to_owned(),
638 ban_duration: None,
639 correlate_with_detection: false,
640 }
641 }
642
643 #[test]
644 fn reset_forgets_every_window() {
645 let fake = FakeClock::default();
646 let mut tracker = BehaviorTracker::new();
647 let rule = return_rule(1);
648 assert!(!tracker.track_return_pattern("ep", "1.2.3.4", 403, None, &rule, 1024, fake.now()));
649 tracker.reset();
650 assert!(!tracker.track_return_pattern("ep", "1.2.3.4", 403, None, &rule, 1024, fake.now()));
653 }
654
655 #[test]
656 fn non_return_rules_and_unevaluated_patterns_never_trip() {
657 let fake = FakeClock::default();
658 let mut tracker = BehaviorTracker::new();
659 let frequency = BehaviorRule {
660 rule_type: "frequency".to_owned(),
661 threshold: 0,
662 window: 60,
663 pattern: String::new(),
664 action: "log".to_owned(),
665 ban_duration: None,
666 correlate_with_detection: false,
667 };
668 let body_rule = BehaviorRule {
670 pattern: "regex:secret".to_owned(),
671 ..return_rule(0)
672 };
673 assert!(!tracker.track_return_pattern(
674 "ep",
675 "1.2.3.4",
676 403,
677 None,
678 &frequency,
679 1024,
680 fake.now()
681 ));
682 assert!(!tracker.track_return_pattern(
683 "ep",
684 "1.2.3.4",
685 403,
686 None,
687 &body_rule,
688 1024,
689 fake.now()
690 ));
691 }
692
693 #[test]
694 fn json_scalars_render_like_python_str() {
695 use crate::behavior::json_scalar_to_string;
696 assert_eq!(json_scalar_to_string(&serde_json::json!(true)), "true");
697 assert_eq!(json_scalar_to_string(&serde_json::json!(false)), "false");
698 assert_eq!(json_scalar_to_string(&serde_json::json!(null)), "None");
699 assert_eq!(json_scalar_to_string(&serde_json::json!(1)), "1");
700 }
701
702 #[test]
703 fn json_array_membership_matches_any_element() {
704 use crate::behavior::match_json_array;
705 let value = serde_json::json!({"tags": ["a", "b"]});
706 assert!(match_json_array(&value, "tags", "B"));
707 assert!(!match_json_array(&value, "tags", "c"));
708 assert!(!match_json_array(&value, "missing", "a"));
709 }
710}
711
712#[cfg(test)]
713mod gap_tests {
714 use super::*;
715
716 fn rule(pattern: &str) -> BehaviorRule {
717 BehaviorRule {
718 rule_type: "return_pattern".to_owned(),
719 threshold: 1,
720 window: 3600,
721 pattern: pattern.to_owned(),
722 action: "log".to_owned(),
723 ban_duration: None,
724 correlate_with_detection: false,
725 }
726 }
727
728 #[test]
729 fn an_uncompilable_regex_pattern_cannot_be_evaluated() {
730 let mut tracker = BehaviorTracker::new();
731 assert_eq!(
732 tracker.check_response_pattern(200, Some(b"body"), "regex:([unclosed", 1024),
733 None
734 );
735 let now = SystemTime::now();
737 assert!(!tracker.track_return_pattern(
738 "GET:/x",
739 "192.0.2.9",
740 200,
741 Some(b"body"),
742 &rule("regex:([unclosed"),
743 1024,
744 now,
745 ));
746 }
747
748 #[test]
749 fn a_compiled_regex_pattern_is_cached_across_calls() {
750 let mut tracker = BehaviorTracker::new();
751 let first = tracker.check_response_pattern(200, Some(b"error text"), "regex:err\\w+", 1024);
752 assert_eq!(first, Some(true));
753 let second =
755 tracker.check_response_pattern(200, Some(b"error text"), "regex:err\\w+", 1024);
756 assert_eq!(second, Some(true));
757 }
758
759 #[test]
760 fn an_object_valued_json_field_never_matches_a_scalar_expectation() {
761 let mut tracker = BehaviorTracker::new();
762 let body = br#"{"meta":{"a":1}}"#;
763 assert_eq!(
764 tracker.check_response_pattern(200, Some(body), "json:meta==zzz", 1024),
765 Some(false)
766 );
767 }
768
769 #[test]
770 fn unevaluable_patterns_answer_none() {
771 let mut tracker = BehaviorTracker::new();
772 assert_eq!(
774 tracker.check_response_pattern(200, Some(b"b"), "status:soon", 1024),
775 None
776 );
777 assert_eq!(
779 tracker.check_response_pattern(200, Some(b"not json"), "json:a==1", 1024),
780 None
781 );
782 }
783}