sentinel_modsec/engine/
scoring.rs

1//! Anomaly scoring system.
2
3use crate::variables::MutableCollection;
4
5/// Standard anomaly score variable names (CRS).
6pub const ANOMALY_SCORE: &str = "anomaly_score";
7pub const INBOUND_ANOMALY_SCORE_THRESHOLD: &str = "inbound_anomaly_score_threshold";
8pub const OUTBOUND_ANOMALY_SCORE_THRESHOLD: &str = "outbound_anomaly_score_threshold";
9pub const SQL_INJECTION_SCORE: &str = "sql_injection_score";
10pub const XSS_SCORE: &str = "xss_score";
11pub const RFI_SCORE: &str = "rfi_score";
12pub const LFI_SCORE: &str = "lfi_score";
13pub const RCE_SCORE: &str = "rce_score";
14pub const PHP_INJECTION_SCORE: &str = "php_injection_score";
15pub const SESSION_FIXATION_SCORE: &str = "session_fixation_score";
16
17/// Default CRS thresholds by paranoia level.
18#[derive(Debug, Clone)]
19pub struct ScoringConfig {
20    /// Paranoia level (1-4).
21    pub paranoia_level: u8,
22    /// Inbound anomaly score threshold.
23    pub inbound_threshold: i32,
24    /// Outbound anomaly score threshold.
25    pub outbound_threshold: i32,
26    /// Critical severity score.
27    pub critical_score: i32,
28    /// Error severity score.
29    pub error_score: i32,
30    /// Warning severity score.
31    pub warning_score: i32,
32    /// Notice severity score.
33    pub notice_score: i32,
34}
35
36impl Default for ScoringConfig {
37    fn default() -> Self {
38        Self {
39            paranoia_level: 1,
40            inbound_threshold: 5,
41            outbound_threshold: 4,
42            critical_score: 5,
43            error_score: 4,
44            warning_score: 3,
45            notice_score: 2,
46        }
47    }
48}
49
50impl ScoringConfig {
51    /// Create config for paranoia level.
52    pub fn for_paranoia_level(level: u8) -> Self {
53        match level {
54            1 => Self::default(),
55            2 => Self {
56                paranoia_level: 2,
57                inbound_threshold: 10,
58                outbound_threshold: 8,
59                ..Default::default()
60            },
61            3 => Self {
62                paranoia_level: 3,
63                inbound_threshold: 15,
64                outbound_threshold: 12,
65                ..Default::default()
66            },
67            _ => Self {
68                paranoia_level: 4,
69                inbound_threshold: 20,
70                outbound_threshold: 16,
71                ..Default::default()
72            },
73        }
74    }
75
76    /// Get score for severity level.
77    pub fn score_for_severity(&self, severity: u8) -> i32 {
78        match severity {
79            0 | 1 | 2 => self.critical_score,
80            3 => self.error_score,
81            4 => self.warning_score,
82            5 => self.notice_score,
83            _ => 0,
84        }
85    }
86}
87
88/// Anomaly score tracker.
89#[derive(Debug, Clone, Default)]
90pub struct AnomalyScore {
91    /// Total inbound score.
92    pub inbound: i32,
93    /// Total outbound score.
94    pub outbound: i32,
95    /// SQL injection specific score.
96    pub sqli: i32,
97    /// XSS specific score.
98    pub xss: i32,
99    /// RFI specific score.
100    pub rfi: i32,
101    /// LFI specific score.
102    pub lfi: i32,
103    /// RCE specific score.
104    pub rce: i32,
105    /// PHP injection specific score.
106    pub php: i32,
107    /// Session fixation specific score.
108    pub session_fixation: i32,
109}
110
111impl AnomalyScore {
112    /// Create a new anomaly score tracker.
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    /// Add to inbound score.
118    pub fn add_inbound(&mut self, score: i32) {
119        self.inbound += score;
120    }
121
122    /// Add to outbound score.
123    pub fn add_outbound(&mut self, score: i32) {
124        self.outbound += score;
125    }
126
127    /// Check if inbound threshold exceeded.
128    pub fn inbound_exceeded(&self, threshold: i32) -> bool {
129        self.inbound >= threshold
130    }
131
132    /// Check if outbound threshold exceeded.
133    pub fn outbound_exceeded(&self, threshold: i32) -> bool {
134        self.outbound >= threshold
135    }
136
137    /// Sync scores to TX collection.
138    pub fn sync_to_tx<C: MutableCollection>(&self, tx: &mut C) {
139        tx.set(ANOMALY_SCORE.to_string(), self.inbound.to_string());
140        tx.set(SQL_INJECTION_SCORE.to_string(), self.sqli.to_string());
141        tx.set(XSS_SCORE.to_string(), self.xss.to_string());
142        tx.set(RFI_SCORE.to_string(), self.rfi.to_string());
143        tx.set(LFI_SCORE.to_string(), self.lfi.to_string());
144        tx.set(RCE_SCORE.to_string(), self.rce.to_string());
145        tx.set(PHP_INJECTION_SCORE.to_string(), self.php.to_string());
146        tx.set(SESSION_FIXATION_SCORE.to_string(), self.session_fixation.to_string());
147    }
148
149    /// Load scores from TX collection.
150    pub fn sync_from_tx<C: crate::variables::Collection>(&mut self, tx: &C) {
151        if let Some(values) = tx.get(ANOMALY_SCORE) {
152            if let Some(v) = values.first() {
153                self.inbound = v.parse().unwrap_or(0);
154            }
155        }
156        if let Some(values) = tx.get(SQL_INJECTION_SCORE) {
157            if let Some(v) = values.first() {
158                self.sqli = v.parse().unwrap_or(0);
159            }
160        }
161        if let Some(values) = tx.get(XSS_SCORE) {
162            if let Some(v) = values.first() {
163                self.xss = v.parse().unwrap_or(0);
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::variables::{Collection, HashMapCollection};
173
174    #[test]
175    fn test_scoring_config() {
176        let config = ScoringConfig::default();
177        assert_eq!(config.score_for_severity(2), 5); // Critical
178        assert_eq!(config.score_for_severity(3), 4); // Error
179        assert_eq!(config.score_for_severity(4), 3); // Warning
180    }
181
182    #[test]
183    fn test_anomaly_score() {
184        let mut score = AnomalyScore::new();
185        score.add_inbound(5);
186        score.add_inbound(3);
187        assert_eq!(score.inbound, 8);
188        assert!(score.inbound_exceeded(5));
189        assert!(!score.inbound_exceeded(10));
190    }
191
192    #[test]
193    fn test_sync_to_tx() {
194        let mut score = AnomalyScore::new();
195        score.inbound = 15;
196        score.sqli = 10;
197
198        let mut tx = HashMapCollection::new();
199        score.sync_to_tx(&mut tx);
200
201        let anomaly_val = tx.get(ANOMALY_SCORE).and_then(|v| v.first().map(|s| s.to_string()));
202        let sqli_val = tx.get(SQL_INJECTION_SCORE).and_then(|v| v.first().map(|s| s.to_string()));
203        assert_eq!(anomaly_val, Some("15".to_string()));
204        assert_eq!(sqli_val, Some("10".to_string()));
205    }
206}