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
100/// The reference `BehaviorTracker`'s in-memory return-pattern windows.
101///
102/// The redis-free fallback: `pattern_key -> client_ip -> timestamps`,
103/// with the same sliding-window arithmetic (`len(timestamps) > threshold`).
104#[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
112/// The default response-body prefix the reference reads for a non-`status:`
113/// pattern (`behavior_max_response_body_inspect_bytes` default, 262144).
114pub const DEFAULT_MAX_RESPONSE_BODY_INSPECT_BYTES: usize = 262_144;
115
116impl BehaviorTracker {
117    /// A fresh tracker, the harness `BehaviorTracker(config)` with clean
118    /// windows.
119    #[must_use]
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Forget every window (the per-case reset the harness performs).
125    pub fn reset(&mut self) {
126        self.return_patterns.clear();
127        self.status_patterns.clear();
128        self.regex_patterns.clear();
129    }
130
131    /// `track_return_pattern`: record one response observation for
132    /// `(endpoint_id, client_ip)` when the response matches the rule's
133    /// pattern, and answer whether the rule tripped
134    /// (`len(timestamps) > threshold` inside the rule's window).
135    ///
136    /// `status` and `body_prefix` are the response facts: `body_prefix` is
137    /// the caller's capture bounded by the inspect-bytes budget and only
138    /// read when the pattern needs the body and the caller has
139    /// `behavior_scan_response_body` enabled. `now` is the caller's clock
140    /// (the reference `time.time()` float); [`SystemTime`] converts via
141    /// [`unix_now`].
142    #[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            // `None` (the pattern could not be evaluated, e.g. a body
160            // pattern with no captured prefix) is a no-match, exactly like
161            // the reference `_check_response_pattern` returning None and
162            // the mixin treating it as no violation.
163            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    /// `_check_response_pattern`: `Some(true/false)` when the pattern was
180    /// evaluated, `None` when it could not be (a body pattern while no
181    /// body prefix was captured).
182    #[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        // Everything below needs the response body: without the caller's
196        // scan flag the reference never reads it and the rule never
197        // matches, so a missing prefix is "could not evaluate".
198        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    /// `BehaviorActionDispatchMixin.apply_action`: passive mode only logs
218    /// (so [`BehaviorAction::LoggedOnly`]), active mode bans with the rule
219    /// duration override (else the reference 3600s default) or reduces to
220    /// a note. The caller applies the ban to its IP-ban store; the
221    /// reference ban reason is `behavioral_violation`.
222    #[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/// The reference `config_to_rule`: the corpus config dict shape.
244#[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/// `time.time()` as the tracker's float Unix timestamp.
273#[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/// `BehaviorJsonPatternMixin._match_json_pattern`: `path.to.field ==
281/// expected` with case-insensitive comparison, a `[]` segment matching any
282/// array element, and any structural mismatch or parse failure counting as
283/// no-match.
284#[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/// Python `str()` over the JSON-decoded scalar: booleans lowercase, null
312/// as `None`, numbers via shortest round-trip formatting.
313#[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        // Another IP counts separately.
350        assert!(!tracker.track_return_pattern("GET:/api", "192.0.2.11", 404, None, &rule, 1, now));
351        // A non-matching status never records.
352        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        // Inside the window the second hit crosses `> 1`.
371        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        // Outside the 3600s window both hits no longer count, so this
381        // is hit number one again: below `> 1`.
382        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        // Body patterns without a captured prefix cannot evaluate.
429        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        // a bare array is not an object
488        assert_eq!(rule_from_config(&serde_json::json!([])), None);
489        // the rule_type key is missing or not a string
490        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        // the threshold key is missing, not a number, or out of u32 range
496        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        // after the reset the window is gone: the same observation is the
553        // first hit again, not a threshold cross
554        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        // a body pattern with no captured prefix cannot be evaluated
571        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        // and the rule treats "could not evaluate" as a no-match
638        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        // the second call reuses the cached compile
656        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        // a status rule with a non-numeric target cannot be evaluated
675        assert_eq!(
676            tracker.check_response_pattern(200, Some(b"b"), "status:soon", 1024),
677            None
678        );
679        // a json rule over a non-JSON body cannot be evaluated
680        assert_eq!(
681            tracker.check_response_pattern(200, Some(b"not json"), "json:a==1", 1024),
682            None
683        );
684    }
685}