Skip to main content

synapse_pingora/profiler/
signals.rs

1//! Anomaly signal types and results.
2//!
3//! Provides types for anomaly detection in request profiling:
4//! - Signal types for different anomaly categories
5//! - Signal containers with severity and details
6//! - Aggregated results for request analysis
7//!
8//! ## Performance
9//! - Signal creation: <100ns
10//! - Result aggregation: O(n) where n = number of signals
11
12use serde::{Deserialize, Serialize};
13
14// ============================================================================
15// AnomalySignalType - Types of anomalies detected
16// ============================================================================
17
18/// Type of anomaly detected in a request.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AnomalySignalType {
22    /// Payload size significantly above baseline (z-score > 3)
23    PayloadSizeHigh,
24    /// Payload size suspiciously small for endpoint
25    PayloadSizeLow,
26    /// Query parameter not seen in baseline
27    UnexpectedParam,
28    /// Usually-present parameter missing
29    MissingExpectedParam,
30    /// Parameter value outside learned range
31    ParamValueAnomaly,
32    /// Content-Type doesn't match baseline
33    ContentTypeMismatch,
34    /// Request rate burst from this entity
35    RateBurst,
36    /// Too many parameters
37    ParamCountAnomaly,
38
39    // ========================================================================
40    // Header Anomaly Signals (W4.1 HeaderProfiler)
41    // ========================================================================
42    /// Required header (seen in >95% of baseline) is missing
43    HeaderMissingRequired,
44    /// Unexpected header not seen in baseline
45    HeaderUnexpected,
46    /// Header value is anomalous (unusual pattern, format)
47    HeaderValueAnomaly,
48    /// Header value entropy is anomalous (z-score > 3 sigma)
49    HeaderEntropyAnomaly,
50    /// Header value length is outside expected range
51    HeaderLengthAnomaly,
52
53    // ========================================================================
54    // Error Rate Anomaly Signals
55    // ========================================================================
56    /// Abnormal error rate detected (5xx errors on usually-stable endpoint)
57    AbnormalErrorRate,
58}
59
60impl AnomalySignalType {
61    /// Get the signal type as a string for logging/metrics.
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            Self::PayloadSizeHigh => "payload_size_high",
65            Self::PayloadSizeLow => "payload_size_low",
66            Self::UnexpectedParam => "unexpected_param",
67            Self::MissingExpectedParam => "missing_expected_param",
68            Self::ParamValueAnomaly => "param_value_anomaly",
69            Self::ContentTypeMismatch => "content_type_mismatch",
70            Self::RateBurst => "rate_burst",
71            Self::ParamCountAnomaly => "param_count_anomaly",
72            // Header anomaly signals
73            Self::HeaderMissingRequired => "header_missing_required",
74            Self::HeaderUnexpected => "header_unexpected",
75            Self::HeaderValueAnomaly => "header_value_anomaly",
76            Self::HeaderEntropyAnomaly => "header_entropy_anomaly",
77            Self::HeaderLengthAnomaly => "header_length_anomaly",
78            // Error rate anomaly signals
79            Self::AbnormalErrorRate => "abnormal_error_rate",
80        }
81    }
82
83    /// Get the default severity for this signal type.
84    ///
85    /// Severity scale (1-10):
86    /// - 1-3: Low (informational, minor deviations)
87    /// - 4-6: Medium (notable anomalies, worth investigation)
88    /// - 7-10: High (likely malicious, strong indicators)
89    pub fn default_severity(&self) -> u8 {
90        match self {
91            Self::PayloadSizeHigh => 5,
92            Self::PayloadSizeLow => 2,
93            Self::UnexpectedParam => 3,
94            Self::MissingExpectedParam => 2,
95            Self::ParamValueAnomaly => 4,
96            Self::ContentTypeMismatch => 5,
97            Self::RateBurst => 6,
98            Self::ParamCountAnomaly => 3,
99            // Header anomaly severities (matching spec: missing=10, unexpected=5, value=15, entropy=20)
100            // Mapped to 1-10 scale: missing=4, unexpected=2, value=5, entropy=6, length=4
101            Self::HeaderMissingRequired => 4, // Was: risk 10 -> severity 4
102            Self::HeaderUnexpected => 2,      // Was: risk 5 -> severity 2
103            Self::HeaderValueAnomaly => 5,    // Was: risk 15 -> severity 5
104            Self::HeaderEntropyAnomaly => 6,  // Was: risk 20 -> severity 6
105            Self::HeaderLengthAnomaly => 4,   // Was: risk 10 -> severity 4
106            // Error rate anomaly severity
107            Self::AbnormalErrorRate => 5, // Medium severity for error rate anomaly
108        }
109    }
110
111    /// Get the default risk contribution for this signal type.
112    ///
113    /// Risk contribution scale (0-50):
114    /// - 0-10: Low risk addition
115    /// - 11-25: Medium risk addition
116    /// - 26-50: High risk addition
117    pub fn default_risk(&self) -> u16 {
118        match self {
119            Self::PayloadSizeHigh => 15,
120            Self::PayloadSizeLow => 5,
121            Self::UnexpectedParam => 8,
122            Self::MissingExpectedParam => 5,
123            Self::ParamValueAnomaly => 12,
124            Self::ContentTypeMismatch => 15,
125            Self::RateBurst => 20,
126            Self::ParamCountAnomaly => 8,
127            // Header anomaly risk contributions (matching spec)
128            Self::HeaderMissingRequired => 10,
129            Self::HeaderUnexpected => 5,
130            Self::HeaderValueAnomaly => 15,
131            Self::HeaderEntropyAnomaly => 20,
132            Self::HeaderLengthAnomaly => 10,
133            // Error rate anomaly risk
134            Self::AbnormalErrorRate => 15, // Medium-high risk for error rate anomaly
135        }
136    }
137}
138
139// ============================================================================
140// AnomalySignal - Individual anomaly signal
141// ============================================================================
142
143/// Individual anomaly signal with severity and detail.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct AnomalySignal {
146    /// Type of anomaly
147    pub signal_type: AnomalySignalType,
148    /// Severity (1-10)
149    pub severity: u8,
150    /// Human-readable detail
151    pub detail: String,
152}
153
154impl AnomalySignal {
155    /// Create a new anomaly signal.
156    #[inline]
157    pub fn new(signal_type: AnomalySignalType, severity: u8, detail: String) -> Self {
158        Self {
159            signal_type,
160            severity: severity.min(10),
161            detail,
162        }
163    }
164
165    /// Create a signal with default severity for the type.
166    #[inline]
167    pub fn with_default_severity(signal_type: AnomalySignalType, detail: String) -> Self {
168        Self::new(signal_type, signal_type.default_severity(), detail)
169    }
170}
171
172// ============================================================================
173// AnomalyResult - Detection result for a single request
174// ============================================================================
175
176/// Result of anomaly detection for a single request.
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct AnomalyResult {
179    /// Total anomaly score (-10 to +10, positive = more suspicious)
180    pub total_score: f64,
181    /// Detected anomaly signals
182    pub signals: Vec<AnomalySignal>,
183}
184
185impl AnomalyResult {
186    /// Create empty result (no anomalies).
187    #[inline]
188    pub fn none() -> Self {
189        Self {
190            total_score: 0.0,
191            signals: Vec::new(),
192        }
193    }
194
195    /// Create new result with pre-allocated capacity.
196    #[inline]
197    pub fn new() -> Self {
198        Self {
199            total_score: 0.0,
200            signals: Vec::with_capacity(4), // Typical max signals
201        }
202    }
203
204    /// Add an anomaly signal.
205    #[inline]
206    pub fn add(&mut self, signal_type: AnomalySignalType, severity: u8, detail: String) {
207        self.total_score += severity as f64;
208        self.signals
209            .push(AnomalySignal::new(signal_type, severity, detail));
210    }
211
212    /// Add a signal with computed severity.
213    #[inline]
214    pub fn add_signal(&mut self, signal: AnomalySignal) {
215        self.total_score += signal.severity as f64;
216        self.signals.push(signal);
217    }
218
219    /// Check if any anomalies were detected.
220    #[inline]
221    pub fn has_anomalies(&self) -> bool {
222        !self.signals.is_empty()
223    }
224
225    /// Get the number of signals.
226    #[inline]
227    pub fn signal_count(&self) -> usize {
228        self.signals.len()
229    }
230
231    /// Clamp and normalize the total score to -10..+10 range.
232    pub fn normalize(&mut self) {
233        self.total_score = self.total_score.clamp(-10.0, 10.0);
234    }
235
236    /// Get the maximum severity among all signals.
237    pub fn max_severity(&self) -> u8 {
238        self.signals.iter().map(|s| s.severity).max().unwrap_or(0)
239    }
240
241    /// Get all signal types present.
242    pub fn signal_types(&self) -> Vec<AnomalySignalType> {
243        self.signals.iter().map(|s| s.signal_type).collect()
244    }
245
246    /// Merge another result into this one.
247    pub fn merge(&mut self, other: AnomalyResult) {
248        self.total_score += other.total_score;
249        self.signals.extend(other.signals);
250    }
251}
252
253// ============================================================================
254// Tests
255// ============================================================================
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_anomaly_signal_type_as_str() {
263        assert_eq!(
264            AnomalySignalType::PayloadSizeHigh.as_str(),
265            "payload_size_high"
266        );
267        assert_eq!(AnomalySignalType::RateBurst.as_str(), "rate_burst");
268    }
269
270    #[test]
271    fn test_anomaly_signal_creation() {
272        let signal = AnomalySignal::new(
273            AnomalySignalType::PayloadSizeHigh,
274            7,
275            "Large payload detected".to_string(),
276        );
277        assert_eq!(signal.signal_type, AnomalySignalType::PayloadSizeHigh);
278        assert_eq!(signal.severity, 7);
279    }
280
281    #[test]
282    fn test_anomaly_signal_severity_clamped() {
283        let signal = AnomalySignal::new(
284            AnomalySignalType::RateBurst,
285            15, // Over max
286            "Test".to_string(),
287        );
288        assert_eq!(signal.severity, 10); // Clamped to 10
289    }
290
291    #[test]
292    fn test_anomaly_result_empty() {
293        let result = AnomalyResult::none();
294        assert!(!result.has_anomalies());
295        assert_eq!(result.total_score, 0.0);
296        assert_eq!(result.signal_count(), 0);
297    }
298
299    #[test]
300    fn test_anomaly_result_add() {
301        let mut result = AnomalyResult::new();
302        result.add(
303            AnomalySignalType::UnexpectedParam,
304            3,
305            "New param".to_string(),
306        );
307        result.add(AnomalySignalType::RateBurst, 6, "High rate".to_string());
308
309        assert!(result.has_anomalies());
310        assert_eq!(result.signal_count(), 2);
311        assert_eq!(result.total_score, 9.0);
312    }
313
314    #[test]
315    fn test_anomaly_result_normalize() {
316        let mut result = AnomalyResult::new();
317        for _ in 0..5 {
318            result.add(AnomalySignalType::RateBurst, 6, "Test".to_string());
319        }
320        assert_eq!(result.total_score, 30.0);
321
322        result.normalize();
323        assert_eq!(result.total_score, 10.0);
324    }
325
326    #[test]
327    fn test_anomaly_result_max_severity() {
328        let mut result = AnomalyResult::new();
329        result.add(AnomalySignalType::PayloadSizeLow, 2, "Small".to_string());
330        result.add(AnomalySignalType::RateBurst, 8, "Burst".to_string());
331        result.add(AnomalySignalType::UnexpectedParam, 3, "Param".to_string());
332
333        assert_eq!(result.max_severity(), 8);
334    }
335
336    #[test]
337    fn test_anomaly_result_signal_types() {
338        let mut result = AnomalyResult::new();
339        result.add(AnomalySignalType::PayloadSizeHigh, 5, "Test".to_string());
340        result.add(AnomalySignalType::RateBurst, 6, "Test".to_string());
341
342        let types = result.signal_types();
343        assert!(types.contains(&AnomalySignalType::PayloadSizeHigh));
344        assert!(types.contains(&AnomalySignalType::RateBurst));
345    }
346
347    #[test]
348    fn test_anomaly_result_merge() {
349        let mut result1 = AnomalyResult::new();
350        result1.add(AnomalySignalType::PayloadSizeHigh, 5, "Test1".to_string());
351
352        let mut result2 = AnomalyResult::new();
353        result2.add(AnomalySignalType::RateBurst, 6, "Test2".to_string());
354
355        result1.merge(result2);
356
357        assert_eq!(result1.signal_count(), 2);
358        assert_eq!(result1.total_score, 11.0);
359    }
360
361    #[test]
362    fn test_default_severity() {
363        assert_eq!(AnomalySignalType::PayloadSizeHigh.default_severity(), 5);
364        assert_eq!(AnomalySignalType::RateBurst.default_severity(), 6);
365        assert_eq!(AnomalySignalType::PayloadSizeLow.default_severity(), 2);
366    }
367
368    #[test]
369    fn test_signal_with_default_severity() {
370        let signal = AnomalySignal::with_default_severity(
371            AnomalySignalType::RateBurst,
372            "Test burst".to_string(),
373        );
374        assert_eq!(signal.severity, 6);
375    }
376
377    // ========================================================================
378    // Header anomaly signal tests
379    // ========================================================================
380
381    #[test]
382    fn test_header_anomaly_signal_types_as_str() {
383        assert_eq!(
384            AnomalySignalType::HeaderMissingRequired.as_str(),
385            "header_missing_required"
386        );
387        assert_eq!(
388            AnomalySignalType::HeaderUnexpected.as_str(),
389            "header_unexpected"
390        );
391        assert_eq!(
392            AnomalySignalType::HeaderValueAnomaly.as_str(),
393            "header_value_anomaly"
394        );
395        assert_eq!(
396            AnomalySignalType::HeaderEntropyAnomaly.as_str(),
397            "header_entropy_anomaly"
398        );
399        assert_eq!(
400            AnomalySignalType::HeaderLengthAnomaly.as_str(),
401            "header_length_anomaly"
402        );
403    }
404
405    #[test]
406    fn test_header_anomaly_default_severities() {
407        assert_eq!(
408            AnomalySignalType::HeaderMissingRequired.default_severity(),
409            4
410        );
411        assert_eq!(AnomalySignalType::HeaderUnexpected.default_severity(), 2);
412        assert_eq!(AnomalySignalType::HeaderValueAnomaly.default_severity(), 5);
413        assert_eq!(
414            AnomalySignalType::HeaderEntropyAnomaly.default_severity(),
415            6
416        );
417        assert_eq!(AnomalySignalType::HeaderLengthAnomaly.default_severity(), 4);
418    }
419
420    #[test]
421    fn test_header_anomaly_default_risks() {
422        assert_eq!(AnomalySignalType::HeaderMissingRequired.default_risk(), 10);
423        assert_eq!(AnomalySignalType::HeaderUnexpected.default_risk(), 5);
424        assert_eq!(AnomalySignalType::HeaderValueAnomaly.default_risk(), 15);
425        assert_eq!(AnomalySignalType::HeaderEntropyAnomaly.default_risk(), 20);
426        assert_eq!(AnomalySignalType::HeaderLengthAnomaly.default_risk(), 10);
427    }
428
429    #[test]
430    fn test_header_anomaly_in_result() {
431        let mut result = AnomalyResult::new();
432        result.add(
433            AnomalySignalType::HeaderMissingRequired,
434            4,
435            "Missing Authorization header".to_string(),
436        );
437        result.add(
438            AnomalySignalType::HeaderEntropyAnomaly,
439            6,
440            "High entropy in X-Token".to_string(),
441        );
442
443        assert!(result.has_anomalies());
444        assert_eq!(result.signal_count(), 2);
445        assert_eq!(result.total_score, 10.0); // 4 + 6
446
447        let types = result.signal_types();
448        assert!(types.contains(&AnomalySignalType::HeaderMissingRequired));
449        assert!(types.contains(&AnomalySignalType::HeaderEntropyAnomaly));
450    }
451}