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
100#[derive(Default)]
105#[allow(clippy::struct_field_names)]
106pub struct BehaviorTracker {
107 return_patterns: HashMap<String, HashMap<String, Vec<f64>>>,
108 status_patterns: HashMap<String, u16>,
109 regex_patterns: HashMap<String, Regex>,
110}
111
112pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
115
116impl BehaviorTracker {
117 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn reset(&mut self) {
126 self.return_patterns.clear();
127 self.status_patterns.clear();
128 self.regex_patterns.clear();
129 }
130
131 #[allow(clippy::too_many_arguments)]
143 pub fn track_return_pattern(
144 &mut self,
145 endpoint_id: &str,
146 client_ip: &str,
147 status: u16,
148 body_prefix: Option<&[u8]>,
149 rule: &BehaviorRule,
150 max_inspect_bytes: usize,
151 now: SystemTime,
152 ) -> bool {
153 if rule.rule_type != "return_pattern" {
154 return false;
155 }
156 let matched =
157 self.check_response_pattern(status, body_prefix, &rule.pattern, max_inspect_bytes);
158 if matched != Some(true) {
159 return false;
164 }
165 let now = unix_now(now);
166 let window_start = now - f64::from(u32::try_from(rule.window).unwrap_or(u32::MAX));
167 let pattern_key = format!("{endpoint_id}:{}", rule.pattern);
168 let timestamps = self
169 .return_patterns
170 .entry(pattern_key)
171 .or_default()
172 .entry(client_ip.to_owned())
173 .or_default();
174 timestamps.retain(|ts| *ts >= window_start);
175 timestamps.push(now);
176 timestamps.len() > rule.threshold as usize
177 }
178
179 #[must_use]
183 #[allow(clippy::too_many_lines)]
184 pub fn check_response_pattern(
185 &mut self,
186 status: u16,
187 body_prefix: Option<&[u8]>,
188 pattern: &str,
189 max_inspect_bytes: usize,
190 ) -> Option<bool> {
191 if let Some(expected) = pattern.strip_prefix("status:") {
192 let expected: u16 = expected.trim().parse().ok()?;
193 return Some(status == expected);
194 }
195 let body = body_prefix?;
199 let body_str = String::from_utf8_lossy(&body[..body.len().min(max_inspect_bytes)]);
200 if let Some(json_pattern) = pattern.strip_prefix("json:") {
201 let parsed = serde_json::from_str::<serde_json::Value>(&body_str).ok()?;
202 return Some(match_json_pattern(&parsed, json_pattern));
203 }
204 if let Some(regex_pattern) = pattern.strip_prefix("regex:") {
205 if !self.regex_patterns.contains_key(regex_pattern) {
206 let Ok(regex) = Regex::new(&format!("(?i){regex_pattern}")) else {
207 return None;
208 };
209 self.regex_patterns.insert(regex_pattern.to_owned(), regex);
210 }
211 let regex = &self.regex_patterns[regex_pattern];
212 return Some(regex.is_match(&body_str));
213 }
214 Some(body_str.to_lowercase().contains(&pattern.to_lowercase()))
215 }
216
217 #[must_use]
223 pub fn dispatch_action(
224 rule: &BehaviorRule,
225 client_ip: &str,
226 passive_mode: bool,
227 ) -> BehaviorAction {
228 let _ = client_ip;
229 if passive_mode {
230 return BehaviorAction::LoggedOnly;
231 }
232 if rule.action == "ban" {
233 return BehaviorAction::Ban {
234 duration: rule.ban_duration.unwrap_or(3600),
235 };
236 }
237 BehaviorAction::Note {
238 action: rule.action.clone(),
239 }
240 }
241}
242
243#[must_use]
245pub fn rule_from_config(cfg: &serde_json::Value) -> Option<BehaviorRule> {
246 let obj = cfg.as_object()?;
247 Some(BehaviorRule {
248 rule_type: obj.get("rule_type")?.as_str()?.to_owned(),
249 threshold: u32::try_from(obj.get("threshold")?.as_u64()?).ok()?,
250 window: obj
251 .get("window")
252 .and_then(serde_json::Value::as_u64)
253 .unwrap_or(3600),
254 pattern: obj
255 .get("pattern")
256 .and_then(serde_json::Value::as_str)
257 .unwrap_or_default()
258 .to_owned(),
259 action: obj
260 .get("action")
261 .and_then(serde_json::Value::as_str)
262 .unwrap_or("log")
263 .to_owned(),
264 ban_duration: obj.get("ban_duration").and_then(serde_json::Value::as_u64),
265 correlate_with_detection: obj
266 .get("correlate_with_detection")
267 .and_then(serde_json::Value::as_bool)
268 .unwrap_or(false),
269 })
270}
271
272#[must_use]
274pub fn unix_now(now: SystemTime) -> f64 {
275 now.duration_since(SystemTime::UNIX_EPOCH)
276 .map(|duration| duration.as_secs_f64())
277 .unwrap_or_default()
278}
279
280#[must_use]
285fn match_json_pattern(data: &serde_json::Value, pattern: &str) -> bool {
286 let Some((path, expected)) = pattern.split_once("==") else {
287 return false;
288 };
289 let expected = expected.trim().trim_matches(['\'', '"']);
290 let mut current = data;
291 for part in path.trim().split('.') {
292 if let Some(field) = part.strip_suffix("[]") {
293 return match_json_array(current, field, expected);
294 }
295 let Some(next) = current.get(part) else {
296 return false;
297 };
298 current = next;
299 }
300 json_scalar_to_string(current).eq_ignore_ascii_case(expected)
301}
302
303fn match_json_array(current: &serde_json::Value, field: &str, expected: &str) -> bool {
304 let Some(list) = current.get(field).and_then(serde_json::Value::as_array) else {
305 return false;
306 };
307 list.iter()
308 .any(|item| json_scalar_to_string(item).eq_ignore_ascii_case(expected))
309}
310
311#[must_use]
314fn json_scalar_to_string(value: &serde_json::Value) -> String {
315 match value {
316 serde_json::Value::String(s) => s.clone(),
317 serde_json::Value::Bool(true) => "true".to_owned(),
318 serde_json::Value::Bool(false) => "false".to_owned(),
319 serde_json::Value::Null => "None".to_owned(),
320 serde_json::Value::Number(n) => n.to_string(),
321 other => other.to_string(),
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn rule(pattern: &str, threshold: u32) -> BehaviorRule {
330 BehaviorRule {
331 rule_type: "return_pattern".to_owned(),
332 threshold,
333 window: 3600,
334 pattern: pattern.to_owned(),
335 action: "ban".to_owned(),
336 ban_duration: Some(900),
337 correlate_with_detection: false,
338 }
339 }
340
341 #[test]
342 fn status_pattern_trips_above_the_threshold_only() {
343 let mut tracker = BehaviorTracker::new();
344 let rule = rule("status:404", 2);
345 let now = SystemTime::now();
346 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
347 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
348 assert!(tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 1, now));
349 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
351 assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 200, None, &rule, 1, now));
353 }
354
355 #[test]
356 fn sliding_window_forgotten_timestamps_do_not_count() {
357 let mut tracker = BehaviorTracker::new();
358 let rule = rule("status:404", 1);
359 let epoch = SystemTime::UNIX_EPOCH;
360 let t = |secs: u64| epoch + std::time::Duration::from_secs(secs);
361 assert!(!tracker.track_return_pattern(
362 "GET:/api",
363 "192.0.2.10",
364 404,
365 None,
366 &rule,
367 1,
368 t(10_000)
369 ));
370 assert!(tracker.track_return_pattern(
372 "GET:/api",
373 "192.0.2.10",
374 404,
375 None,
376 &rule,
377 1,
378 t(10_100)
379 ));
380 assert!(!tracker.track_return_pattern(
383 "GET:/api",
384 "192.0.2.10",
385 404,
386 None,
387 &rule,
388 1,
389 t(50_000)
390 ));
391 assert!(tracker.track_return_pattern(
392 "GET:/api",
393 "192.0.2.10",
394 404,
395 None,
396 &rule,
397 1,
398 t(50_100)
399 ));
400 }
401
402 #[test]
403 fn body_patterns_match_like_the_reference() {
404 let mut tracker = BehaviorTracker::new();
405 let body = b"{\"error\": \"not found\", \"items\": [1, 2]}".as_slice();
406 let checks = [
407 ("regex:NOT FOUND", Some(true)),
408 ("not found", Some(true)),
409 ("missing", Some(false)),
410 ("json:error==Not Found", Some(true)),
411 ("json:items[]==2", Some(true)),
412 ("json:items[]==3", Some(false)),
413 ("json:nope==x", Some(false)),
414 ("json:broken", Some(false)),
415 ];
416 for (pattern, want) in checks {
417 assert_eq!(
418 tracker.check_response_pattern(
419 200,
420 Some(body),
421 pattern,
422 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
423 ),
424 want,
425 "pattern {pattern}"
426 );
427 }
428 assert_eq!(
430 tracker.check_response_pattern(
431 200,
432 None,
433 "not found",
434 DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES
435 ),
436 None
437 );
438 }
439
440 #[test]
441 fn dispatch_matches_the_reference_action_semantics() {
442 let ban = rule("status:404", 1);
443 assert_eq!(
444 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", false),
445 BehaviorAction::Ban { duration: 900 }
446 );
447 let no_duration = BehaviorRule {
448 ban_duration: None,
449 ..rule("status:404", 1)
450 };
451 assert_eq!(
452 BehaviorTracker::dispatch_action(&no_duration, "192.0.2.10", false),
453 BehaviorAction::Ban { duration: 3600 }
454 );
455 assert_eq!(
456 BehaviorTracker::dispatch_action(&ban, "192.0.2.10", true),
457 BehaviorAction::LoggedOnly,
458 "passive mode never executes the action"
459 );
460 let log_rule = BehaviorRule {
461 action: "log".to_owned(),
462 ..rule("status:404", 1)
463 };
464 assert_eq!(
465 BehaviorTracker::dispatch_action(&log_rule, "192.0.2.10", false),
466 BehaviorAction::Note {
467 action: "log".to_owned()
468 }
469 );
470 }
471
472 #[test]
473 fn rule_from_config_reads_the_corpus_shape() {
474 let cfg = serde_json::json!({
475 "rule_type": "return_pattern",
476 "threshold": 2,
477 "window": 3600,
478 "pattern": "status:404",
479 "action": "ban",
480 "ban_duration": 900
481 });
482 assert_eq!(rule_from_config(&cfg), Some(rule("status:404", 2)));
483 }
484
485 #[test]
486 fn malformed_configs_answer_none_instead_of_erroring() {
487 assert_eq!(rule_from_config(&serde_json::json!([])), None);
489 assert_eq!(rule_from_config(&serde_json::json!({})), None);
491 assert_eq!(
492 rule_from_config(&serde_json::json!({ "rule_type": 7 })),
493 None
494 );
495 assert_eq!(
497 rule_from_config(&serde_json::json!({ "rule_type": "return_pattern" })),
498 None
499 );
500 assert_eq!(
501 rule_from_config(&serde_json::json!({
502 "rule_type": "return_pattern",
503 "threshold": "many",
504 })),
505 None
506 );
507 assert_eq!(
508 rule_from_config(&serde_json::json!({
509 "rule_type": "return_pattern",
510 "threshold": 5_000_000_000u64,
511 })),
512 None
513 );
514 }
515}
516
517#[cfg(test)]
518mod coverage_tests {
519 use super::*;
520 use std::sync::Arc;
521 use std::sync::atomic::{AtomicU64, Ordering};
522
523 #[derive(Default)]
524 struct FakeClock(Arc<AtomicU64>);
525
526 impl FakeClock {
527 fn now(&self) -> SystemTime {
528 let secs = self.0.load(Ordering::Relaxed);
529 std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)
530 }
531 }
532
533 fn return_rule(threshold: u32) -> BehaviorRule {
534 BehaviorRule {
535 rule_type: "return_pattern".to_owned(),
536 threshold,
537 window: 60,
538 pattern: "status:403".to_owned(),
539 action: "log".to_owned(),
540 ban_duration: None,
541 correlate_with_detection: false,
542 }
543 }
544
545 #[test]
546 fn reset_forgets_every_window() {
547 let fake = FakeClock::default();
548 let mut tracker = BehaviorTracker::new();
549 let rule = return_rule(1);
550 assert!(!tracker.track_return_pattern("ep", "1.2.3.4", 403, None, &rule, 1024, fake.now()));
551 tracker.reset();
552 assert!(!tracker.track_return_pattern("ep", "1.2.3.4", 403, None, &rule, 1024, fake.now()));
555 }
556
557 #[test]
558 fn non_return_rules_and_unevaluated_patterns_never_trip() {
559 let fake = FakeClock::default();
560 let mut tracker = BehaviorTracker::new();
561 let frequency = BehaviorRule {
562 rule_type: "frequency".to_owned(),
563 threshold: 0,
564 window: 60,
565 pattern: String::new(),
566 action: "log".to_owned(),
567 ban_duration: None,
568 correlate_with_detection: false,
569 };
570 let body_rule = BehaviorRule {
572 pattern: "regex:secret".to_owned(),
573 ..return_rule(0)
574 };
575 assert!(!tracker.track_return_pattern(
576 "ep",
577 "1.2.3.4",
578 403,
579 None,
580 &frequency,
581 1024,
582 fake.now()
583 ));
584 assert!(!tracker.track_return_pattern(
585 "ep",
586 "1.2.3.4",
587 403,
588 None,
589 &body_rule,
590 1024,
591 fake.now()
592 ));
593 }
594
595 #[test]
596 fn json_scalars_render_like_python_str() {
597 use crate::behavior::json_scalar_to_string;
598 assert_eq!(json_scalar_to_string(&serde_json::json!(true)), "true");
599 assert_eq!(json_scalar_to_string(&serde_json::json!(false)), "false");
600 assert_eq!(json_scalar_to_string(&serde_json::json!(null)), "None");
601 assert_eq!(json_scalar_to_string(&serde_json::json!(1)), "1");
602 }
603
604 #[test]
605 fn json_array_membership_matches_any_element() {
606 use crate::behavior::match_json_array;
607 let value = serde_json::json!({"tags": ["a", "b"]});
608 assert!(match_json_array(&value, "tags", "B"));
609 assert!(!match_json_array(&value, "tags", "c"));
610 assert!(!match_json_array(&value, "missing", "a"));
611 }
612}
613
614#[cfg(test)]
615mod gap_tests {
616 use super::*;
617
618 fn rule(pattern: &str) -> BehaviorRule {
619 BehaviorRule {
620 rule_type: "return_pattern".to_owned(),
621 threshold: 1,
622 window: 3600,
623 pattern: pattern.to_owned(),
624 action: "log".to_owned(),
625 ban_duration: None,
626 correlate_with_detection: false,
627 }
628 }
629
630 #[test]
631 fn an_uncompilable_regex_pattern_cannot_be_evaluated() {
632 let mut tracker = BehaviorTracker::new();
633 assert_eq!(
634 tracker.check_response_pattern(200, Some(b"body"), "regex:([unclosed", 1024),
635 None
636 );
637 let now = SystemTime::now();
639 assert!(!tracker.track_return_pattern(
640 "GET:/x",
641 "192.0.2.9",
642 200,
643 Some(b"body"),
644 &rule("regex:([unclosed"),
645 1024,
646 now,
647 ));
648 }
649
650 #[test]
651 fn a_compiled_regex_pattern_is_cached_across_calls() {
652 let mut tracker = BehaviorTracker::new();
653 let first = tracker.check_response_pattern(200, Some(b"error text"), "regex:err\\w+", 1024);
654 assert_eq!(first, Some(true));
655 let second =
657 tracker.check_response_pattern(200, Some(b"error text"), "regex:err\\w+", 1024);
658 assert_eq!(second, Some(true));
659 }
660
661 #[test]
662 fn an_object_valued_json_field_never_matches_a_scalar_expectation() {
663 let mut tracker = BehaviorTracker::new();
664 let body = br#"{"meta":{"a":1}}"#;
665 assert_eq!(
666 tracker.check_response_pattern(200, Some(body), "json:meta==zzz", 1024),
667 Some(false)
668 );
669 }
670
671 #[test]
672 fn unevaluable_patterns_answer_none() {
673 let mut tracker = BehaviorTracker::new();
674 assert_eq!(
676 tracker.check_response_pattern(200, Some(b"b"), "status:soon", 1024),
677 None
678 );
679 assert_eq!(
681 tracker.check_response_pattern(200, Some(b"not json"), "json:a==1", 1024),
682 None
683 );
684 }
685}