Skip to main content

synapse_pingora/waf/
synapse.rs

1//! Synapse facade for the WAF engine.
2//!
3//! Provides a high-level API matching the libsynapse Synapse struct
4//! for seamless migration.
5
6use parking_lot::RwLock;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use super::{Engine, Request, RiskConfig, TraceSink, Verdict, WafError};
10use crate::profiler::{EndpointProfile, ProfileStore, ProfileStoreConfig};
11
12/// Main WAF detection engine facade.
13///
14/// This struct provides the same API as libsynapse::Synapse,
15/// enabling a drop-in replacement.
16///
17/// # Example
18///
19/// ```ignore
20/// use synapse_pingora::waf::{Synapse, Request, Action};
21///
22/// let mut synapse = Synapse::new();
23/// synapse.load_rules(rules_json).unwrap();
24///
25/// let verdict = synapse.analyze(&Request {
26///     method: "GET",
27///     path: "/api/users?id=1' OR '1'='1",
28///     ..Default::default()
29/// });
30///
31/// assert_eq!(verdict.action, Action::Block);
32/// ```
33pub struct Synapse {
34    engine: Engine,
35    /// Risk configuration for anomaly detection thresholds.
36    risk_config: RwLock<RiskConfig>,
37    /// Profile storage for endpoint behavior learning.
38    profile_store: ProfileStore,
39}
40
41impl Default for Synapse {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl Synapse {
48    /// Create a new Synapse instance with no rules loaded.
49    pub fn new() -> Self {
50        Self {
51            engine: Engine::empty(),
52            risk_config: RwLock::new(RiskConfig::default()),
53            profile_store: ProfileStore::new(ProfileStoreConfig::default()),
54        }
55    }
56
57    /// Create a new Synapse instance with custom profile configuration.
58    pub fn with_profile_config(profile_config: ProfileStoreConfig) -> Self {
59        Self {
60            engine: Engine::empty(),
61            risk_config: RwLock::new(RiskConfig::default()),
62            profile_store: ProfileStore::new(profile_config),
63        }
64    }
65
66    /// Load rules from JSON.
67    ///
68    /// Returns the number of rules loaded on success.
69    pub fn load_rules(&mut self, json: &[u8]) -> Result<usize, WafError> {
70        self.engine.load_rules(json)
71    }
72
73    /// Precompute all rule structures including regex compilation.
74    pub fn precompute_rules(
75        &self,
76        json: &[u8],
77    ) -> Result<crate::waf::engine::CompiledRules, WafError> {
78        self.engine.precompute_rules(json)
79    }
80
81    /// Fast swap of rule state using precomputed data.
82    pub fn reload_from_compiled(&mut self, compiled: crate::waf::engine::CompiledRules) {
83        self.engine.reload_from_compiled(compiled);
84    }
85
86    /// Parse rules from JSON bytes without modifying state.
87    pub fn parse_rules(json: &[u8]) -> Result<Vec<crate::waf::WafRule>, WafError> {
88        Engine::parse_rules(json)
89    }
90
91    /// Reload the engine with a new set of pre-parsed rules.
92    pub fn reload_rules(&mut self, rules: Vec<crate::waf::WafRule>) -> Result<(), WafError> {
93        self.engine.reload_rules(rules)
94    }
95
96    /// Analyze a request and return a verdict.
97    pub fn analyze(&self, req: &Request) -> Verdict {
98        self.engine.analyze(req)
99    }
100
101    /// Analyze a request and emit evaluation trace events.
102    pub fn analyze_with_trace(&self, req: &Request, trace: &mut dyn TraceSink) -> Verdict {
103        self.engine.analyze_with_trace(req, trace)
104    }
105
106    /// Analyze a request with a timeout to prevent DoS via complex regexes.
107    ///
108    /// # Arguments
109    /// * `req` - The request to analyze
110    /// * `timeout` - Maximum time allowed for rule evaluation
111    ///
112    /// # Returns
113    /// A `Verdict` with `timed_out=true` if evaluation exceeded the deadline.
114    pub fn analyze_with_timeout(&self, req: &Request, timeout: std::time::Duration) -> Verdict {
115        self.engine.analyze_with_timeout(req, timeout)
116    }
117
118    /// Analyze a request with the default timeout (50ms).
119    ///
120    /// Recommended for production use to prevent DoS attacks.
121    pub fn analyze_safe(&self, req: &Request) -> Verdict {
122        self.engine.analyze_safe(req)
123    }
124
125    /// Record response status code for profiling.
126    ///
127    /// Updates the endpoint profile with the observed status code,
128    /// enabling baseline learning and anomaly detection.
129    pub fn record_response_status(&self, path: &str, status: u16) {
130        let now_ms = Self::now_ms();
131        let mut profile = self.profile_store.get_or_create(path);
132        // Update profile with response status observation
133        // Use 0 for response size since we only have status
134        profile.update_response(0, status, None, now_ms);
135    }
136
137    /// Get all learned profiles.
138    ///
139    /// Returns a snapshot of all endpoint profiles currently in storage.
140    pub fn get_profiles(&self) -> Vec<EndpointProfile> {
141        self.profile_store.get_profiles()
142    }
143
144    /// Load profiles into the engine.
145    ///
146    /// Merges or replaces profiles in storage from a previous snapshot.
147    pub fn load_profiles(&self, profiles: Vec<EndpointProfile>) {
148        for profile in profiles {
149            // Insert each profile into the store by its template path
150            let template = profile.template.clone();
151            let mut entry = self.profile_store.get_or_create(&template);
152            // Merge the loaded profile data into the existing entry
153            *entry = profile;
154        }
155    }
156
157    /// Get the number of loaded rules.
158    pub fn rule_count(&self) -> usize {
159        self.engine.rule_count()
160    }
161
162    /// Get current risk configuration.
163    pub fn risk_config(&self) -> RiskConfig {
164        self.risk_config.read().clone()
165    }
166
167    /// Set risk configuration.
168    ///
169    /// Updates the risk thresholds for anomaly-based blocking.
170    pub fn set_risk_config(&self, config: RiskConfig) {
171        *self.risk_config.write() = config;
172    }
173
174    /// Get the number of stored profiles.
175    pub fn profile_count(&self) -> usize {
176        self.profile_store.len()
177    }
178
179    /// Clear all stored profiles.
180    pub fn clear_profiles(&self) {
181        self.profile_store.clear();
182    }
183
184    /// Get current timestamp in milliseconds.
185    #[inline]
186    fn now_ms() -> u64 {
187        SystemTime::now()
188            .duration_since(UNIX_EPOCH)
189            .map(|d| d.as_millis() as u64)
190            .unwrap_or(0)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_new_synapse() {
200        let synapse = Synapse::new();
201        assert_eq!(synapse.rule_count(), 0);
202    }
203
204    #[test]
205    fn test_load_rules() {
206        let mut synapse = Synapse::new();
207        let rules = r#"[
208            {
209                "id": 1,
210                "description": "SQL injection",
211                "risk": 10.0,
212                "blocking": true,
213                "matches": [
214                    {"type": "uri", "match": {"type": "contains", "match": "' OR '"}}
215                ]
216            }
217        ]"#;
218        let count = synapse.load_rules(rules.as_bytes()).unwrap();
219        assert_eq!(count, 1);
220        assert_eq!(synapse.rule_count(), 1);
221    }
222
223    #[test]
224    fn test_default_synapse() {
225        let synapse = Synapse::default();
226        assert_eq!(synapse.rule_count(), 0);
227    }
228
229    #[test]
230    fn test_risk_config_get_set() {
231        use crate::waf::BlockingMode;
232
233        let synapse = Synapse::new();
234
235        // Default config
236        let config = synapse.risk_config();
237        assert_eq!(config.max_risk, 100.0);
238        assert!(config.enable_repeat_multipliers);
239
240        // Modify config
241        let mut new_config = config.clone();
242        new_config.max_risk = 1000.0;
243        new_config.blocking_mode = BlockingMode::Enforcement;
244        new_config.anomaly_blocking_threshold = 25.0;
245        synapse.set_risk_config(new_config);
246
247        // Verify changes persisted
248        let updated = synapse.risk_config();
249        assert_eq!(updated.max_risk, 1000.0);
250        assert_eq!(updated.anomaly_blocking_threshold, 25.0);
251        assert!(matches!(updated.blocking_mode, BlockingMode::Enforcement));
252    }
253
254    #[test]
255    fn test_record_response_status() {
256        let synapse = Synapse::new();
257
258        // Initially no profiles
259        assert_eq!(synapse.profile_count(), 0);
260
261        // Record some status codes
262        synapse.record_response_status("/api/users", 200);
263        synapse.record_response_status("/api/users", 200);
264        synapse.record_response_status("/api/users", 404);
265
266        // Should have created a profile
267        assert_eq!(synapse.profile_count(), 1);
268
269        // Multiple paths create multiple profiles
270        synapse.record_response_status("/api/orders", 200);
271        assert_eq!(synapse.profile_count(), 2);
272    }
273
274    #[test]
275    fn test_get_and_load_profiles() {
276        let synapse = Synapse::new();
277
278        // Create some profiles
279        synapse.record_response_status("/api/users", 200);
280        synapse.record_response_status("/api/orders", 200);
281        assert_eq!(synapse.profile_count(), 2);
282
283        // Get profiles snapshot
284        let profiles = synapse.get_profiles();
285        assert_eq!(profiles.len(), 2);
286
287        // Clear and verify empty
288        synapse.clear_profiles();
289        assert_eq!(synapse.profile_count(), 0);
290
291        // Load profiles back
292        synapse.load_profiles(profiles);
293        assert_eq!(synapse.profile_count(), 2);
294    }
295
296    #[test]
297    fn test_profile_path_normalization() {
298        let synapse = Synapse::new();
299
300        // Paths with IDs should normalize to templates
301        synapse.record_response_status("/api/users/123", 200);
302        synapse.record_response_status("/api/users/456", 200);
303
304        // Both should map to the same template (with ID normalized)
305        // Note: exact count depends on ProfileStore's segment detection config
306        let profiles = synapse.get_profiles();
307        assert!(!profiles.is_empty());
308    }
309}