Skip to main content

guard_core_engine/
scan_budget.rs

1//! Per-request regex scan time budget: the reference
2//! `detection_max_scan_seconds` (`guard_core/_utils/detection_scan.py`,
3//! `_scan_time_budget_exhausted`).
4//!
5//! The budget sums the CPU time of the pattern scans of one request, across
6//! every value and every view pass, and is checked between patterns. It is
7//! CPU time of the scanning thread, not wall time: wall time made loaded or
8//! CPU-starved hosts exhaust it falsely on ordinary requests. Once spent,
9//! the remaining patterns and values are skipped and the scan records a
10//! weighted `pattern_timeout` threat named
11//! [`SCAN_TIME_BUDGET_EXHAUSTED_PATTERN`] (it counts toward the anomaly score), so padding a
12//! request cannot skip detection.
13//!
14//! The reference also yields to its event loop every couple of
15//! milliseconds; that half has no Rust counterpart because the scan is
16//! synchronous on the calling task.
17//!
18//! The thread CPU clock is `clock_gettime(CLOCK_THREAD_CPUTIME_ID)` through
19//! the safe `rustix` wrapper on Linux, Android and macOS (the crate forbids
20//! `unsafe`, so a direct `libc` call is not an option). Elsewhere the clock
21//! falls back to wall time, which can only exhaust the budget earlier,
22//! never later.
23
24use std::cmp::Ordering;
25
26/// The pattern name of the synthetic budget threat (reference
27/// `_SCAN_TIME_BUDGET_EXHAUSTED_PATTERN`).
28pub const SCAN_TIME_BUDGET_EXHAUSTED_PATTERN: &str = "scan_time_budget_exhausted";
29
30/// The reference default `detection_max_scan_seconds`.
31pub const DEFAULT_MAX_SCAN_SECONDS: f64 = 10.0;
32
33/// The trigger text the reference `_build_threat_message` renders for the
34/// budget threat.
35#[must_use]
36pub fn budget_exhausted_message() -> String {
37    format!("Pattern exceeded scan time budget: '{SCAN_TIME_BUDGET_EXHAUSTED_PATTERN}'")
38}
39
40#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
41fn thread_cpu_seconds() -> f64 {
42    let ts = rustix::time::clock_gettime(rustix::time::ClockId::ThreadCPUTime);
43    ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
44}
45
46#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
47fn thread_cpu_seconds() -> f64 {
48    use std::sync::OnceLock;
49    use std::time::Instant;
50    static START: OnceLock<Instant> = OnceLock::new();
51    START.get_or_init(Instant::now).elapsed().as_secs_f64()
52}
53
54/// The CPU seconds a request has left to spend scanning.
55#[derive(Debug, Clone, Copy)]
56pub struct ScanTimeBudget {
57    cap: Option<f64>,
58    spent: f64,
59    clock: fn() -> f64,
60}
61
62impl ScanTimeBudget {
63    /// A budget of `cap_seconds` of thread CPU time.
64    #[must_use]
65    pub fn new(cap_seconds: f64) -> Self {
66        Self::with_clock(Some(cap_seconds), thread_cpu_seconds)
67    }
68
69    /// No cap (the reference's `None` outside a request).
70    #[must_use]
71    pub fn unbounded() -> Self {
72        Self::with_clock(None, thread_cpu_seconds)
73    }
74
75    /// A budget over an injected monotonic seconds clock (tests).
76    #[must_use]
77    pub const fn with_clock(cap: Option<f64>, clock: fn() -> f64) -> Self {
78        Self {
79            cap,
80            spent: 0.0,
81            clock,
82        }
83    }
84
85    /// `true` once the summed scan time has reached the cap.
86    #[must_use]
87    pub fn exhausted(&self) -> bool {
88        // Anything but "spent is below the cap" counts as exhausted, so a
89        // NaN cap fails closed.
90        self.cap
91            .is_some_and(|cap| self.spent.partial_cmp(&cap) != Some(Ordering::Less))
92    }
93
94    /// Run one scan and charge its CPU time to the budget.
95    pub fn measure<T>(&mut self, scan: impl FnOnce() -> T) -> T {
96        let start = (self.clock)();
97        let result = scan();
98        self.spent += ((self.clock)() - start).max(0.0);
99        result
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::cell::Cell;
107
108    thread_local! {
109        static NOW: Cell<f64> = const { Cell::new(0.0) };
110    }
111
112    fn stepping_clock() -> f64 {
113        NOW.with(|now| {
114            now.set(now.get() + 1.0);
115            now.get()
116        })
117    }
118
119    #[test]
120    fn unbounded_budget_is_never_exhausted() {
121        let mut budget = ScanTimeBudget::unbounded();
122        budget.measure(|| ());
123        assert!(!budget.exhausted());
124    }
125
126    #[test]
127    fn measured_time_exhausts_the_cap() {
128        let mut budget = ScanTimeBudget::with_clock(Some(2.5), stepping_clock);
129        assert!(!budget.exhausted());
130        // Each measure reads the clock twice: one fake second per scan.
131        assert_eq!(budget.measure(|| 7), 7);
132        assert!(!budget.exhausted());
133        budget.measure(|| ());
134        assert!(!budget.exhausted());
135        budget.measure(|| ());
136        assert!(budget.exhausted());
137    }
138
139    #[test]
140    fn a_backwards_clock_never_refunds_time() {
141        fn backwards() -> f64 {
142            NOW.with(|now| {
143                now.set(now.get() - 1.0);
144                now.get()
145            })
146        }
147        let mut budget = ScanTimeBudget::with_clock(Some(0.1), backwards);
148        budget.measure(|| ());
149        assert!(!budget.exhausted());
150    }
151
152    #[test]
153    fn the_real_thread_clock_advances_with_work() {
154        let first = thread_cpu_seconds();
155        let mut acc = 0_u64;
156        for i in 0..5_000_000_u64 {
157            acc = acc.wrapping_add(std::hint::black_box(i));
158        }
159        std::hint::black_box(acc);
160        assert!(thread_cpu_seconds() > first);
161    }
162
163    #[test]
164    fn a_nan_cap_fails_closed() {
165        assert!(ScanTimeBudget::new(f64::NAN).exhausted());
166    }
167
168    #[test]
169    fn the_message_names_the_budget() {
170        assert_eq!(
171            budget_exhausted_message(),
172            "Pattern exceeded scan time budget: 'scan_time_budget_exhausted'"
173        );
174    }
175}