dwara_core/dataplane/
anomaly.rs

1//! Lightweight statistical anomaly scoring (DW-090).
2//!
3//! A first-line traffic filter that scores requests against configurable
4//! request-shape signals (header entropy, header count/bytes, path
5//! length/depth, query count, body size, unusual method). Each signal
6//! produces a normalized [0, 1] sub-score; the overall score is the
7//! average of the configured signals' scores. A score at or above the
8//! policy's `threshold` is blocked (403 `anomaly_blocked`) unless
9//! `dry_run` is set (scored and logged, request proceeds).
10//!
11//! ## Request-path position
12//!
13//! The anomaly check runs AFTER the WAF-lite filter and BEFORE the route
14//! limits (DW-027): it is an inspection phase like the WAF, rejecting
15//! abusive request shapes before any resource is spent on auth or rate
16//! limiting. It inspects the ORIGINAL request (before path rewrite /
17//! transforms).
18//!
19//! ## Design constraints
20//!
21//! - No ML, no heavy computation on the hot path: every signal is O(1)
22//!   or O(n) in the header count/size (already bounded by the parser
23//!   hardening knobs).
24//! - No new dependencies: the Shannon entropy and normalization math is
25//!   in-house (a byte-frequency histogram over a stack array).
26//! - The scorer is compiled once per config generation (like the WAF and
27//!   rate-limit engines) and reused across requests.
28
29use hyper::HeaderMap;
30
31use crate::config::{AnomalyPolicy, AnomalySignal};
32
33/// A compiled anomaly-scoring generation: the configured signals, the
34/// block threshold, the body inspection cap, and the dry-run flag. Built
35/// once per config generation from [`AnomalyPolicy`] and reused across
36/// requests.
37pub struct AnomalyScorer {
38    signals: Vec<AnomalySignal>,
39    threshold: f64,
40    max_body_inspect_bytes: u64,
41    dry_run: bool,
42}
43
44/// The result of scoring one request: the overall score, whether it
45/// triggered (>= threshold), and the per-signal sub-scores for
46/// observability/logging.
47pub struct AnomalyResult {
48    /// The overall score in [0, 1] (the average of the configured
49    /// signals' sub-scores).
50    pub score: f64,
51    /// Whether the score is at or above the policy's threshold.
52    pub triggered: bool,
53    /// One (signal_name, sub_score) pair per configured signal, in
54    /// configuration order. The signal names are the snake_case enum
55    /// variants.
56    pub signals: Vec<(String, f64)>,
57}
58
59impl AnomalyScorer {
60    /// Compile a scorer from the policy config. Returns `None` when the
61    /// policy is disabled (`enabled: false`) — the caller skips scoring
62    /// entirely in that case.
63    pub fn compile(policy: &AnomalyPolicy) -> Option<Self> {
64        if !policy.enabled {
65            return None;
66        }
67        Some(AnomalyScorer {
68            signals: policy.signals.clone(),
69            threshold: policy.threshold,
70            max_body_inspect_bytes: policy.max_body_inspect_bytes,
71            dry_run: policy.dry_run,
72        })
73    }
74
75    /// Whether this scorer is in dry-run (audit-log-only) mode.
76    pub fn dry_run(&self) -> bool {
77        self.dry_run
78    }
79
80    /// Score a request against the configured signals. `body_prefix` is
81    /// an optional buffered body slice (up to `max_body_inspect_bytes`);
82    /// `None` means the body was not inspected (the `body_size` signal
83    /// scores 0.0 in that case).
84    pub fn score(
85        &self,
86        method: &str,
87        path: &str,
88        query: Option<&str>,
89        headers: &HeaderMap,
90        body_prefix: Option<&[u8]>,
91    ) -> AnomalyResult {
92        let mut subs: Vec<(String, f64)> = Vec::with_capacity(self.signals.len());
93        for &signal in &self.signals {
94            let (name, val) = match signal {
95                AnomalySignal::HeaderEntropy => ("header_entropy", header_entropy(headers)),
96                AnomalySignal::HeaderCount => ("header_count", header_count(headers)),
97                AnomalySignal::HeaderBytes => ("header_bytes", header_bytes(headers)),
98                AnomalySignal::PathLength => ("path_length", path_length(path)),
99                AnomalySignal::PathDepth => ("path_depth", path_depth(path)),
100                AnomalySignal::QueryCount => ("query_count", query_count(query.unwrap_or(""))),
101                AnomalySignal::BodySize => (
102                    "body_size",
103                    body_size(body_prefix, self.max_body_inspect_bytes),
104                ),
105                AnomalySignal::MethodUnusual => ("method_unusual", method_unusual(method)),
106            };
107            subs.push((name.to_string(), val));
108        }
109        let score = if subs.is_empty() {
110            0.0
111        } else {
112            subs.iter().map(|(_, v)| *v).sum::<f64>() / subs.len() as f64
113        };
114        let triggered = score >= self.threshold;
115        AnomalyResult {
116            score,
117            triggered,
118            signals: subs,
119        }
120    }
121}
122
123/// Shannon entropy of concatenated header values, normalized by 8 (the
124/// max entropy for byte values). Returns a value in [0, 1] where 1 =
125/// high entropy (potentially suspicious randomized/obfuscated payloads).
126///
127/// The histogram is a stack-allocated [u64; 256] byte-frequency table.
128/// Concatenating all header values and computing the byte distribution
129/// is O(total header bytes) — already bounded by the parser hardening
130/// knobs (`DWARA_HTTP1_MAX_HEADERS`, `DWARA_HTTP1_MAX_BUF_KIB`).
131fn header_entropy(headers: &HeaderMap) -> f64 {
132    // Concatenate all header values into a single byte stream.
133    let mut buf: Vec<u8> = Vec::new();
134    for val in headers.values() {
135        buf.extend_from_slice(val.as_bytes());
136    }
137    if buf.is_empty() {
138        return 0.0;
139    }
140    // Byte-frequency histogram.
141    let mut counts = [0u64; 256];
142    for &b in &buf {
143        counts[b as usize] += 1;
144    }
145    let total = buf.len() as f64;
146    let mut entropy = 0.0;
147    for &c in &counts {
148        if c == 0 {
149            continue;
150        }
151        let p = c as f64 / total;
152        entropy -= p * p.log2();
153    }
154    // Normalize by 8 (max entropy for 256 equally-likely byte values).
155    (entropy / 8.0).clamp(0.0, 1.0)
156}
157
158/// Header count outlier: `count / 50`, capped at 1.0. A request with 50
159/// or more header fields scores 1.0.
160fn header_count(headers: &HeaderMap) -> f64 {
161    let count = headers.len() as f64;
162    (count / 50.0).clamp(0.0, 1.0)
163}
164
165/// Total header bytes outlier: `bytes / 8192`, capped at 1.0. The byte
166/// count is the sum of every header name + value + separator overhead
167/// (name.len() + value.len() + 4 for ": " and "\r\n").
168fn header_bytes(headers: &HeaderMap) -> f64 {
169    let mut bytes: u64 = 0;
170    for (name, val) in headers.iter() {
171        bytes += name.as_str().len() as u64;
172        bytes += val.as_bytes().len() as u64;
173        bytes += 4; // ": " + "\r\n"
174    }
175    (bytes as f64 / 8192.0).clamp(0.0, 1.0)
176}
177
178/// Path length outlier: `len / 1024`, capped at 1.0.
179fn path_length(path: &str) -> f64 {
180    (path.len() as f64 / 1024.0).clamp(0.0, 1.0)
181}
182
183/// Path depth outlier: the number of non-empty path segments / 20,
184/// capped at 1.0. A path like `/a/b/c/d` has depth 4.
185fn path_depth(path: &str) -> f64 {
186    let depth = path.split('/').filter(|s| !s.is_empty()).count() as f64;
187    (depth / 20.0).clamp(0.0, 1.0)
188}
189
190/// Query parameter count outlier: the number of `key=value` pairs /
191/// 50, capped at 1.0. Splits on `&` and counts non-empty segments.
192fn query_count(query: &str) -> f64 {
193    if query.is_empty() {
194        return 0.0;
195    }
196    let count = query.split('&').filter(|s| !s.is_empty()).count() as f64;
197    (count / 50.0).clamp(0.0, 1.0)
198}
199
200/// Body size outlier: `min(body_len, cap) / cap`, capped at 1.0. When
201/// no body prefix was inspected, scores 0.0.
202fn body_size(body_prefix: Option<&[u8]>, cap: u64) -> f64 {
203    let cap = cap.max(1) as f64;
204    let len = body_prefix.map(|b| b.len() as f64).unwrap_or(0.0);
205    (len / cap).clamp(0.0, 1.0)
206}
207
208/// Unusual HTTP method: 1.0 if the method is not one of GET, POST, PUT,
209/// DELETE, PATCH, HEAD, OPTIONS; 0.0 otherwise. Case-sensitive (HTTP
210/// methods are uppercase by convention and hyper normalizes them).
211fn method_unusual(method: &str) -> f64 {
212    match method {
213        "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" => 0.0,
214        _ => 1.0,
215    }
216}