Skip to main content

guard_core_engine/
behavior.rs

1//! The behavior-rule engine, ported from the reference behavior handler
2//! and its mixins. The go port is the structural template.
3//!
4//! Reference sources: `guard_core/handlers/behavior_handler.py`
5//! (`BehaviorTracker`, `BehaviorRule`, `config_to_rule`), the
6//! response-pattern mixin (`_behavior_response_pattern.py` +
7//! `_behavior_json_pattern.py`), and the action dispatch
8//! (`_behavior_action_dispatch.py`).
9//!
10//! The engine owns the tracking and the verdict; the caller supplies the
11//! response facts (status code and the already-captured body prefix) and
12//! receives the action to dispatch. The body prefix is the caller's
13//! capture, bounded by the reference
14//! `behavior_max_response_body_inspect_bytes` budget: `status:` patterns
15//! never touch it, and the other pattern kinds see only the leading
16//! prefix.
17//!
18//! # Example
19//!
20//! ```
21//! use guard_core_engine::behavior::{BehaviorAction, BehaviorRule, BehaviorTracker};
22//! use std::time::SystemTime;
23//!
24//! let mut tracker = BehaviorTracker::new();
25//! let rule = BehaviorRule {
26//!     rule_type: "return_pattern".to_owned(),
27//!     threshold: 2,
28//!     window: 3600,
29//!     pattern: "status:404".to_owned(),
30//!     action: "ban".to_owned(),
31//!     ban_duration: Some(900),
32//!     correlate_with_detection: false,
33//! };
34//! let now = SystemTime::now();
35//! // Two 404s stay below a threshold of 2 (`len(timestamps) > threshold`).
36//! assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 262_144, now));
37//! assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 262_144, now));
38//! // The third crossing trips the rule ...
39//! assert!(tracker.track_return_pattern("GET:/api", "192.0.2.10", 404, None, &rule, 262_144, now));
40//! // ... and the dispatch decides what happens: a ban for 900s.
41//! assert_eq!(
42//!     BehaviorTracker::dispatch_action(&rule, "192.0.2.10", false),
43//!     BehaviorAction::Ban { duration: 900 }
44//! );
45//! ```
46
47use std::collections::HashMap;
48use std::time::SystemTime;
49
50use regex::Regex;
51
52/// The reference `BehaviorRule`, field for field.
53///
54/// Only `return_pattern` rules are tracked today, like the go port;
55/// `usage` and `frequency` rules construct but the caller drives them.
56#[derive(Debug, Clone, PartialEq, Eq)]
57#[allow(clippy::struct_field_names)]
58pub struct BehaviorRule {
59    /// `usage`, `return_pattern`, or `frequency`.
60    pub rule_type: String,
61    pub threshold: u32,
62    /// Sliding-window size in seconds.
63    pub window: u64,
64    /// `status:<code>`, `json:<path>==<expected>`, `regex:<pattern>`, or a
65    /// bare (case-insensitive) substring.
66    pub pattern: String,
67    /// `ban`, `log`, `throttle`, or `alert`.
68    pub action: String,
69    /// The ban length override for the `ban` action; `None` falls back to
70    /// the reference 3600s default.
71    pub ban_duration: Option<u64>,
72    /// The reference `correlate_with_detection`: halves the effective
73    /// threshold while the caller reports live detection categories for
74    /// the IP (not modeled yet, matching the go port's fail-closed note).
75    pub correlate_with_detection: bool,
76}
77
78/// What `apply_action` decided for one tripped rule: the observable part
79/// of the reference `BehaviorActionDispatchMixin`.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum BehaviorAction {
82    /// The active-mode `ban`: hold the IP for this many seconds (the rule's
83    /// `ban_duration`, else the reference 3600s default).
84    Ban {
85        /// The ban length in seconds.
86        duration: u64,
87    },
88    /// `alert`, `log`, and `throttle` reduce to an observable note today,
89    /// exactly like the go port: the reference logs at the suspicious
90    /// level and (for alert) critical, which this port does not model.
91    Note {
92        /// The configured action string.
93        action: String,
94    },
95    /// Passive mode never executes the action: the reference logs
96    /// `[PASSIVE MODE]` lines only, so no ban is ever recorded.
97    LoggedOnly,
98}
99
100impl BehaviorAction {
101    /// Whether the action is the active-mode `ban` (the usage-rule
102    /// consumer's dispatch check).
103    #[must_use]
104    pub const fn is_ban(&self) -> bool {
105        matches!(self, Self::Ban { .. })
106    }
107}
108
109/// The reference `BehaviorTracker`'s in-memory return-pattern windows.
110///
111/// The redis-free fallback: `pattern_key -> client_ip -> timestamps`,
112/// with the same sliding-window arithmetic (`len(timestamps) > threshold`).
113#[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    /// The usage windows: `endpoint_id -> client_ip -> timestamps` (the
120    /// reference `usage_counts` bucket shape, `track_endpoint_usage`'s
121    /// in-memory store).
122    usage_counts: HashMap<String, HashMap<String, Vec<f64>>>,
123}
124
125/// The default response-body prefix the reference reads for a non-`status:`
126/// pattern (`behavior_max_response_body_inspect_bytes` default, 262144).
127pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
128
129impl BehaviorTracker {
130    /// A fresh tracker, the harness `BehaviorTracker(config)` with clean
131    /// windows.
132    #[must_use]
133    pub fn new() -> Self {
134        Self::default()
135    }
136
137    /// Forget every window (the per-case reset the harness performs).
138    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    /// `track_endpoint_usage`: record one request observation for
146    /// `(endpoint_id, client_ip)` inside the rule's window, and answer
147    /// whether the rule tripped (`len(timestamps) > threshold`, the
148    /// reference's strict comparison). `now` is the caller's clock (the
149    /// reference `time.time()` float; [`unix_now`] converts).
150    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    /// `get_recent_event_count`: how many tracked events any endpoint
166    /// recorded for `ip` inside `window_seconds` (the reference walks
167    /// every endpoint bucket's timestamps for the identity).
168    #[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    /// `track_return_pattern`: record one response observation for
182    /// `(endpoint_id, client_ip)` when the response matches the rule's
183    /// pattern, and answer whether the rule tripped
184    /// (`len(timestamps) > threshold` inside the rule's window).
185    ///
186    /// `status` and `body_prefix` are the response facts: `body_prefix` is
187    /// the caller's capture bounded by the inspect-bytes budget and only
188    /// read when the pattern needs the body and the caller has
189    /// `behavior_scan_response_body` enabled. `now` is the caller's clock
190    /// (the reference `time.time()` float); [`SystemTime`] converts via
191    /// [`unix_now`].
192    #[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            // `None` (the pattern could not be evaluated, e.g. a body
210            // pattern with no captured prefix) is a no-match, exactly like
211            // the reference `_check_response_pattern` returning None and
212            // the mixin treating it as no violation.
213            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    /// `_check_response_pattern`: `Some(true/false)` when the pattern was
230    /// evaluated, `None` when it could not be (a body pattern while no
231    /// body prefix was captured).
232    #[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        // Everything below needs the response body: without the caller's
246        // scan flag the reference never reads it and the rule never
247        // matches, so a missing prefix is "could not evaluate".
248        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    /// `BehaviorActionDispatchMixin.apply_action`: passive mode only logs
268    /// (so [`BehaviorAction::LoggedOnly`]), active mode bans with the rule
269    /// duration override (else the reference 3600s default) or reduces to
270    /// a note. The caller applies the ban to its IP-ban store; the
271    /// reference ban reason is `behavioral_violation`.
272    #[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/// The reference `config_to_rule`: the corpus config dict shape.
294#[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/// `time.time()` as the tracker's float Unix timestamp.
323#[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/// `BehaviorJsonPatternMixin._match_json_pattern`: `path.to.field ==
331/// expected` with case-insensitive comparison, a `[]` segment matching any
332/// array element, and any structural mismatch or parse failure counting as
333/// no-match.
334#[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/// Python `str()` over the JSON-decoded scalar: booleans lowercase, null
362/// as `None`, numbers via shortest round-trip formatting.
363#[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        // Two observations: under the strict threshold.
416        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        // The third observation crosses (len > threshold).
419        assert!(tracker.track_endpoint_usage("GET:/api", "192.0.2.10", &base, 1_002.0));
420        // A second identity counts independently.
421        assert!(!tracker.track_endpoint_usage("GET:/api", "192.0.2.11", &base, 1_003.0));
422        // A second endpoint counts independently for the same identity.
423        assert!(!tracker.track_endpoint_usage("POST:/api", "192.0.2.10", &base, 1_004.0));
424
425        // The full history answers inside a wide window.
426        assert_eq!(
427            tracker.get_recent_event_count("192.0.2.10", 3_600, 1_050.0),
428            4
429        );
430        // Sliding: a 60s window read at 1062 drops the 1000/1001 stamps.
431        assert_eq!(tracker.get_recent_event_count("192.0.2.10", 60, 1_062.0), 2);
432        // The reference's empty-identity guard.
433        assert_eq!(tracker.get_recent_event_count("", 60, 1_050.0), 0);
434
435        // Re-crossing after the slide: the stale stamps drop first.
436        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        // Another IP counts separately.
448        assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
449        // A non-matching status never records.
450        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        // Inside the window the second hit crosses `> 1`.
469        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        // Outside the 3600s window both hits no longer count, so this
479        // is hit number one again: below `> 1`.
480        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        // Body patterns without a captured prefix cannot evaluate.
527        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        // a bare array is not an object
586        assert_eq!(rule_from_config(&serde_json::json!([])), None);
587        // the rule_type key is missing or not a string
588        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        // the threshold key is missing, not a number, or out of u32 range
594        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        // after the reset the window is gone: the same observation is the
651        // first hit again, not a threshold cross
652        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        // a body pattern with no captured prefix cannot be evaluated
669        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        // and the rule treats "could not evaluate" as a no-match
736        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        // the second call reuses the cached compile
754        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        // a status rule with a non-numeric target cannot be evaluated
773        assert_eq!(
774            tracker.check_response_pattern(200, Some(b"b"), "status:soon", 1024),
775            None
776        );
777        // a json rule over a non-JSON body cannot be evaluated
778        assert_eq!(
779            tracker.check_response_pattern(200, Some(b"not json"), "json:a==1", 1024),
780            None
781        );
782    }
783}