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}