Skip to main content

synapse_pingora/shadow/
protocol.rs

1//! Shadow mirroring protocol and payload definitions.
2//!
3//! Defines the JSON payload format sent to honeypot endpoints.
4//!
5//! # Security
6//!
7//! Headers are sanitized before being sent to honeypots to prevent credential leakage.
8//! Sensitive headers (Authorization, Cookie, etc.) are stripped automatically.
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Headers that contain sensitive credentials and must be stripped before mirroring.
14/// These headers could expose user credentials if forwarded to honeypot systems.
15const SENSITIVE_HEADERS: &[&str] = &[
16    "authorization",
17    "cookie",
18    "set-cookie",
19    "x-api-key",
20    "x-auth-token",
21    "proxy-authorization",
22    "www-authenticate",
23    "proxy-authenticate",
24    "x-csrf-token",
25    "x-xsrf-token",
26];
27
28/// Sanitizes headers by removing sensitive credential headers.
29///
30/// This prevents credential leakage when forwarding requests to honeypot systems.
31/// Headers are matched case-insensitively.
32pub fn sanitize_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
33    headers
34        .iter()
35        .filter(|(key, _)| {
36            let lower_key = key.to_lowercase();
37            !SENSITIVE_HEADERS.contains(&lower_key.as_str())
38        })
39        .map(|(k, v)| (k.clone(), v.clone()))
40        .collect()
41}
42
43/// Checks if a header name is considered sensitive.
44pub fn is_sensitive_header(name: &str) -> bool {
45    SENSITIVE_HEADERS.contains(&name.to_lowercase().as_str())
46}
47
48/// JSON payload sent to honeypot endpoints.
49///
50/// Contains all relevant request context for threat analysis:
51/// - Client identification (IP, fingerprints)
52/// - Risk assessment (score, matched rules, campaign correlation)
53/// - Full request details (method, URI, headers, body)
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct MirrorPayload {
56    /// Unique request identifier (UUID v4)
57    pub request_id: String,
58
59    /// Timestamp of original request (RFC 3339)
60    pub timestamp: String,
61
62    /// Source IP address of the client
63    pub source_ip: String,
64
65    /// JA4 TLS fingerprint (if available)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub ja4_fingerprint: Option<String>,
68
69    /// JA4H HTTP fingerprint (if available)
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub ja4h_fingerprint: Option<String>,
72
73    /// Risk score that triggered mirroring (0-100)
74    pub risk_score: f32,
75
76    /// IDs of rules that matched this request
77    pub matched_rules: Vec<String>,
78
79    /// Campaign ID if correlated to a known threat campaign
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub campaign_id: Option<String>,
82
83    /// HTTP method (GET, POST, etc.)
84    pub method: String,
85
86    /// Request URI (path + query string)
87    pub uri: String,
88
89    /// Request headers (filtered based on configuration)
90    pub headers: HashMap<String, String>,
91
92    /// Request body (if include_body enabled and within max size)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub body: Option<String>,
95
96    /// WAF site/vhost name that processed the request
97    pub site_name: String,
98
99    /// Synapse sensor ID for multi-sensor deployments
100    pub sensor_id: String,
101
102    /// Version of the mirror payload protocol
103    #[serde(default = "default_protocol_version")]
104    pub protocol_version: String,
105}
106
107fn default_protocol_version() -> String {
108    "1.0".to_string()
109}
110
111impl MirrorPayload {
112    /// Creates a new MirrorPayload with required fields.
113    pub fn new(
114        request_id: String,
115        source_ip: String,
116        risk_score: f32,
117        method: String,
118        uri: String,
119        site_name: String,
120        sensor_id: String,
121    ) -> Self {
122        Self {
123            request_id,
124            timestamp: chrono::Utc::now().to_rfc3339(),
125            source_ip,
126            ja4_fingerprint: None,
127            ja4h_fingerprint: None,
128            risk_score,
129            matched_rules: Vec::new(),
130            campaign_id: None,
131            method,
132            uri,
133            headers: HashMap::new(),
134            body: None,
135            site_name,
136            sensor_id,
137            protocol_version: default_protocol_version(),
138        }
139    }
140
141    /// Sets the JA4 TLS fingerprint.
142    pub fn with_ja4(mut self, fingerprint: Option<String>) -> Self {
143        self.ja4_fingerprint = fingerprint;
144        self
145    }
146
147    /// Sets the JA4H HTTP fingerprint.
148    pub fn with_ja4h(mut self, fingerprint: Option<String>) -> Self {
149        self.ja4h_fingerprint = fingerprint;
150        self
151    }
152
153    /// Sets the matched rules.
154    pub fn with_rules(mut self, rules: Vec<String>) -> Self {
155        self.matched_rules = rules;
156        self
157    }
158
159    /// Sets the campaign ID.
160    pub fn with_campaign(mut self, campaign_id: Option<String>) -> Self {
161        self.campaign_id = campaign_id;
162        self
163    }
164
165    /// Sets the request headers after sanitizing sensitive credentials.
166    ///
167    /// Automatically strips Authorization, Cookie, and other credential headers
168    /// to prevent leaking user credentials to honeypot systems.
169    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
170        self.headers = sanitize_headers(&headers);
171        self
172    }
173
174    /// Sets the request headers without sanitization.
175    ///
176    /// # Safety
177    /// This method bypasses header sanitization. Only use this when headers
178    /// have already been sanitized or when intentionally including all headers
179    /// (e.g., for internal testing honeypots).
180    pub fn with_headers_unsanitized(mut self, headers: HashMap<String, String>) -> Self {
181        self.headers = headers;
182        self
183    }
184
185    /// Sets the request body.
186    pub fn with_body(mut self, body: Option<String>) -> Self {
187        self.body = body;
188        self
189    }
190
191    /// Serializes the payload to JSON bytes.
192    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
193        serde_json::to_vec(self)
194    }
195
196    /// Serializes the payload to a JSON string.
197    pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
198        serde_json::to_string(self)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_sanitize_headers() {
208        let mut headers = HashMap::new();
209        headers.insert("Content-Type".to_string(), "application/json".to_string());
210        headers.insert(
211            "Authorization".to_string(),
212            "Bearer secret-token".to_string(),
213        );
214        headers.insert("Cookie".to_string(), "session=abc123".to_string());
215        headers.insert("X-Api-Key".to_string(), "api-key-value".to_string());
216        headers.insert("User-Agent".to_string(), "test-agent".to_string());
217        headers.insert("X-Request-ID".to_string(), "req-123".to_string());
218
219        let sanitized = sanitize_headers(&headers);
220
221        // Safe headers should be preserved
222        assert!(sanitized.contains_key("Content-Type"));
223        assert!(sanitized.contains_key("User-Agent"));
224        assert!(sanitized.contains_key("X-Request-ID"));
225
226        // Sensitive headers should be removed
227        assert!(!sanitized.contains_key("Authorization"));
228        assert!(!sanitized.contains_key("Cookie"));
229        assert!(!sanitized.contains_key("X-Api-Key"));
230
231        assert_eq!(sanitized.len(), 3);
232    }
233
234    #[test]
235    fn test_sanitize_headers_case_insensitive() {
236        let mut headers = HashMap::new();
237        headers.insert("AUTHORIZATION".to_string(), "Bearer token".to_string());
238        headers.insert("cookie".to_string(), "session=xyz".to_string());
239        headers.insert("X-API-KEY".to_string(), "key".to_string());
240
241        let sanitized = sanitize_headers(&headers);
242        assert!(sanitized.is_empty());
243    }
244
245    #[test]
246    fn test_is_sensitive_header() {
247        assert!(is_sensitive_header("authorization"));
248        assert!(is_sensitive_header("Authorization"));
249        assert!(is_sensitive_header("COOKIE"));
250        assert!(is_sensitive_header("x-api-key"));
251        assert!(is_sensitive_header("X-CSRF-Token"));
252
253        assert!(!is_sensitive_header("Content-Type"));
254        assert!(!is_sensitive_header("User-Agent"));
255        assert!(!is_sensitive_header("X-Request-ID"));
256    }
257
258    #[test]
259    fn test_with_headers_sanitizes() {
260        let mut headers = HashMap::new();
261        headers.insert("Content-Type".to_string(), "application/json".to_string());
262        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
263
264        let payload = MirrorPayload::new(
265            "test".to_string(),
266            "10.0.0.1".to_string(),
267            50.0,
268            "POST".to_string(),
269            "/api".to_string(),
270            "site".to_string(),
271            "sensor".to_string(),
272        )
273        .with_headers(headers);
274
275        assert!(payload.headers.contains_key("Content-Type"));
276        assert!(!payload.headers.contains_key("Authorization"));
277    }
278
279    #[test]
280    fn test_new_payload() {
281        let payload = MirrorPayload::new(
282            "test-uuid".to_string(),
283            "192.168.1.100".to_string(),
284            55.0,
285            "POST".to_string(),
286            "/api/login".to_string(),
287            "example.com".to_string(),
288            "sensor-01".to_string(),
289        );
290
291        assert_eq!(payload.request_id, "test-uuid");
292        assert_eq!(payload.source_ip, "192.168.1.100");
293        assert_eq!(payload.risk_score, 55.0);
294        assert_eq!(payload.method, "POST");
295        assert_eq!(payload.uri, "/api/login");
296        assert_eq!(payload.site_name, "example.com");
297        assert_eq!(payload.sensor_id, "sensor-01");
298        assert_eq!(payload.protocol_version, "1.0");
299        assert!(payload.ja4_fingerprint.is_none());
300        assert!(payload.matched_rules.is_empty());
301    }
302
303    #[test]
304    fn test_builder_pattern() {
305        let payload = MirrorPayload::new(
306            "test-uuid".to_string(),
307            "10.0.0.1".to_string(),
308            60.0,
309            "GET".to_string(),
310            "/admin".to_string(),
311            "admin.example.com".to_string(),
312            "sensor-02".to_string(),
313        )
314        .with_ja4(Some("t13d1516h2_abc123".to_string()))
315        .with_ja4h(Some("ge11cn20enus_xyz789".to_string()))
316        .with_rules(vec!["sqli-001".to_string(), "xss-002".to_string()])
317        .with_campaign(Some("campaign-12345".to_string()));
318
319        assert_eq!(
320            payload.ja4_fingerprint,
321            Some("t13d1516h2_abc123".to_string())
322        );
323        assert_eq!(
324            payload.ja4h_fingerprint,
325            Some("ge11cn20enus_xyz789".to_string())
326        );
327        assert_eq!(payload.matched_rules.len(), 2);
328        assert_eq!(payload.campaign_id, Some("campaign-12345".to_string()));
329    }
330
331    #[test]
332    fn test_json_serialization() {
333        let payload = MirrorPayload::new(
334            "test-uuid".to_string(),
335            "192.168.1.1".to_string(),
336            45.0,
337            "POST".to_string(),
338            "/api/data".to_string(),
339            "api.example.com".to_string(),
340            "sensor-01".to_string(),
341        );
342
343        let json = payload.to_json_string().unwrap();
344        assert!(json.contains("\"request_id\":\"test-uuid\""));
345        assert!(json.contains("\"source_ip\":\"192.168.1.1\""));
346        assert!(json.contains("\"risk_score\":45.0"));
347    }
348
349    #[test]
350    fn test_json_deserialization() {
351        let json = r#"{
352            "request_id": "abc123",
353            "timestamp": "2024-01-15T12:00:00Z",
354            "source_ip": "10.0.0.1",
355            "risk_score": 50.0,
356            "matched_rules": ["rule-1"],
357            "method": "GET",
358            "uri": "/test",
359            "headers": {},
360            "site_name": "test.com",
361            "sensor_id": "sensor-1",
362            "protocol_version": "1.0"
363        }"#;
364
365        let payload: MirrorPayload = serde_json::from_str(json).unwrap();
366        assert_eq!(payload.request_id, "abc123");
367        assert_eq!(payload.source_ip, "10.0.0.1");
368        assert_eq!(payload.risk_score, 50.0);
369    }
370
371    #[test]
372    fn test_optional_fields_skip_serialization() {
373        let payload = MirrorPayload::new(
374            "test".to_string(),
375            "10.0.0.1".to_string(),
376            50.0,
377            "GET".to_string(),
378            "/".to_string(),
379            "site".to_string(),
380            "sensor".to_string(),
381        );
382
383        let json = payload.to_json_string().unwrap();
384        // Optional None fields should not appear in JSON
385        assert!(!json.contains("ja4_fingerprint"));
386        assert!(!json.contains("ja4h_fingerprint"));
387        assert!(!json.contains("campaign_id"));
388        assert!(!json.contains("body"));
389    }
390}