Skip to main content

synapse_pingora/trends/
types.rs

1//! Type definitions for the Trends subsystem.
2//!
3//! Tracks time-series signals for auth tokens, device fingerprints,
4//! network signals, and behavioral patterns.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9// ============================================================================
10// Signal Categories & Types
11// ============================================================================
12
13/// High-level signal categories for fingerprinting and tracking.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum SignalCategory {
17    /// JWT, session tokens, API keys
18    AuthToken,
19    /// HTTP fingerprint, client hints
20    Device,
21    /// IP, TLS fingerprint, ASN
22    Network,
23    /// Request timing, navigation patterns
24    Behavioral,
25}
26
27impl std::fmt::Display for SignalCategory {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            SignalCategory::AuthToken => write!(f, "auth_token"),
31            SignalCategory::Device => write!(f, "device"),
32            SignalCategory::Network => write!(f, "network"),
33            SignalCategory::Behavioral => write!(f, "behavioral"),
34        }
35    }
36}
37
38/// Specific signal types within each category.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum SignalType {
42    // Auth token types
43    Jwt,
44    ApiKey,
45    SessionCookie,
46    Bearer,
47    Basic,
48    CustomAuth,
49
50    // Device types
51    HttpFingerprint,
52    HeaderOrder,
53    ClientHints,
54    AcceptPattern,
55
56    // Network types
57    Ip,
58    TlsFingerprint,
59    Asn,
60    Geo,
61    Ja4,
62    Ja4h,
63
64    // Behavioral types
65    Timing,
66    Navigation,
67    RequestPattern,
68    DlpMatch,
69}
70
71impl SignalType {
72    /// Get the category for this signal type.
73    pub fn category(&self) -> SignalCategory {
74        match self {
75            SignalType::Jwt
76            | SignalType::ApiKey
77            | SignalType::SessionCookie
78            | SignalType::Bearer
79            | SignalType::Basic
80            | SignalType::CustomAuth => SignalCategory::AuthToken,
81
82            SignalType::HttpFingerprint
83            | SignalType::HeaderOrder
84            | SignalType::ClientHints
85            | SignalType::AcceptPattern => SignalCategory::Device,
86
87            SignalType::Ip
88            | SignalType::TlsFingerprint
89            | SignalType::Asn
90            | SignalType::Geo
91            | SignalType::Ja4
92            | SignalType::Ja4h => SignalCategory::Network,
93
94            SignalType::Timing
95            | SignalType::Navigation
96            | SignalType::RequestPattern
97            | SignalType::DlpMatch => SignalCategory::Behavioral,
98        }
99    }
100}
101
102// ============================================================================
103// Signal Data Structures
104// ============================================================================
105
106/// Base signal interface - recorded for every relevant request.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Signal {
109    /// UUID
110    pub id: String,
111    /// Unix timestamp in milliseconds
112    pub timestamp: i64,
113    /// Signal category
114    pub category: SignalCategory,
115    /// Signal type
116    pub signal_type: SignalType,
117    /// The actual fingerprint/token hash/signal value
118    pub value: String,
119    /// Entity ID (usually IP address)
120    pub entity_id: String,
121    /// Session ID if available
122    pub session_id: Option<String>,
123    /// Category-specific metadata
124    pub metadata: SignalMetadata,
125}
126
127/// Metadata varies by signal category.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(untagged)]
130pub enum SignalMetadata {
131    AuthToken(AuthTokenMetadata),
132    Device(DeviceMetadata),
133    Network(NetworkMetadata),
134    Behavioral(BehavioralMetadata),
135}
136
137impl Default for SignalMetadata {
138    fn default() -> Self {
139        SignalMetadata::Behavioral(BehavioralMetadata::default())
140    }
141}
142
143/// Metadata for auth token signals.
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
145pub struct AuthTokenMetadata {
146    /// Header name where token was found
147    pub header_name: String,
148    /// Token prefix (Bearer, Basic, etc.)
149    pub token_prefix: Option<String>,
150    /// SHA-256 hash (never store raw tokens)
151    pub token_hash: String,
152    /// JWT claims if applicable
153    pub jwt_claims: Option<JwtClaims>,
154}
155
156/// Parsed JWT claims.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct JwtClaims {
159    pub sub: Option<String>,
160    pub iss: Option<String>,
161    pub exp: Option<i64>,
162    pub iat: Option<i64>,
163    pub aud: Option<String>,
164}
165
166/// Metadata for device signals.
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct DeviceMetadata {
169    pub user_agent: String,
170    pub accept_language: Option<String>,
171    pub header_count: usize,
172    pub client_hints: Option<ClientHints>,
173}
174
175/// Client hints from Sec-CH-* headers.
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct ClientHints {
178    pub brands: Vec<String>,
179    pub mobile: Option<bool>,
180    pub platform: Option<String>,
181    pub platform_version: Option<String>,
182    pub architecture: Option<String>,
183    pub model: Option<String>,
184    pub bitness: Option<String>,
185}
186
187/// Metadata for network signals.
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct NetworkMetadata {
190    pub ip: String,
191    pub tls_version: Option<String>,
192    pub tls_cipher: Option<String>,
193    pub alpn_protocol: Option<String>,
194    pub tls_fingerprint: Option<String>,
195    // JA4+ fingerprinting
196    pub ja4: Option<String>,
197    pub ja4h: Option<String>,
198    pub ja4_combined: Option<String>,
199    pub ja4_tls_version: Option<u8>,
200    pub ja4_http_version: Option<u8>,
201    pub ja4_protocol: Option<String>,
202    pub ja4_bot_match: Option<String>,
203    pub ja4_bot_category: Option<String>,
204    pub ja4_bot_risk: Option<u8>,
205}
206
207/// Metadata for behavioral signals.
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct BehavioralMetadata {
210    /// Time since last request in milliseconds
211    pub time_since_last_request: Option<i64>,
212    /// Requests per minute
213    pub requests_per_minute: Option<f64>,
214    /// Normalized path template
215    pub path_pattern: Option<String>,
216    /// Last N methods
217    pub method_sequence: Vec<String>,
218    /// Referer pattern
219    pub referer_pattern: Option<String>,
220}
221
222// ============================================================================
223// Time-Series Storage
224// ============================================================================
225
226/// A bucket aggregates signals over a time period.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct SignalBucketData {
229    /// Bucket start time (Unix ms)
230    pub timestamp: i64,
231    /// Bucket end time (Unix ms)
232    pub end_timestamp: i64,
233    /// Raw signals (up to max_signals_per_bucket)
234    pub signals: Vec<Signal>,
235    /// Aggregated statistics
236    pub summary: BucketSummary,
237}
238
239/// Summary statistics for a bucket.
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct BucketSummary {
242    pub total_count: usize,
243    pub by_category: HashMap<SignalCategory, CategorySummary>,
244}
245
246/// Summary for a specific category.
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct CategorySummary {
249    pub count: usize,
250    pub unique_values: HashSet<String>,
251    pub unique_entities: HashSet<String>,
252    pub by_type: HashMap<SignalType, usize>,
253}
254
255// ============================================================================
256// Trend Queries & Results
257// ============================================================================
258
259/// Query options for retrieving trends.
260#[derive(Debug, Clone, Default)]
261pub struct TrendQueryOptions {
262    pub category: Option<SignalCategory>,
263    pub signal_type: Option<SignalType>,
264    pub from: Option<i64>,
265    pub to: Option<i64>,
266    pub resolution: Option<TrendResolution>,
267    pub entity_id: Option<String>,
268    pub limit: Option<usize>,
269}
270
271/// Resolution for trend histogram.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum TrendResolution {
274    Minute,
275    Hour,
276    Day,
277}
278
279/// Trend data for a signal type.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct SignalTrend {
282    pub signal_type: SignalType,
283    pub category: SignalCategory,
284    pub count: usize,
285    pub unique_values: usize,
286    pub unique_entities: usize,
287    pub first_seen: i64,
288    pub last_seen: i64,
289    pub histogram: Vec<TrendHistogramBucket>,
290    /// Percentage change from previous period
291    pub change_rate: f64,
292}
293
294/// A bucket in the trend histogram.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct TrendHistogramBucket {
297    pub timestamp: i64,
298    pub count: usize,
299    pub unique_values: usize,
300    pub unique_entities: usize,
301}
302
303/// Overall trends summary.
304#[derive(Debug, Clone, Default, Serialize, Deserialize)]
305pub struct TrendsSummary {
306    pub time_range: TimeRange,
307    pub total_signals: usize,
308    pub by_category: HashMap<SignalCategory, CategoryTrendSummary>,
309    pub top_signal_types: Vec<TopSignalType>,
310    pub anomaly_count: usize,
311}
312
313/// Time range for trends.
314#[derive(Debug, Clone, Default, Serialize, Deserialize)]
315pub struct TimeRange {
316    pub from: i64,
317    pub to: i64,
318}
319
320/// Category trend summary.
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct CategoryTrendSummary {
323    pub count: usize,
324    pub unique_values: usize,
325    pub unique_entities: usize,
326    pub change_rate: f64,
327}
328
329/// Top signal type entry.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct TopSignalType {
332    pub signal_type: SignalType,
333    pub category: SignalCategory,
334    pub count: usize,
335}
336
337// ============================================================================
338// Anomaly Detection
339// ============================================================================
340
341/// Types of anomalies we detect.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
343#[serde(rename_all = "snake_case")]
344pub enum AnomalyType {
345    /// Same session, different fingerprint
346    FingerprintChange,
347    /// Same token across multiple IPs
348    SessionSharing,
349    /// Sudden increase in unique values
350    VelocitySpike,
351    /// Geolocation anomaly
352    ImpossibleTravel,
353    /// Same token, different fingerprints
354    TokenReuse,
355    /// Systematic fingerprint rotation
356    RotationPattern,
357    /// Unusual request timing patterns
358    TimingAnomaly,
359    // JA4 fingerprint anomalies
360    /// Systematic JA4 fingerprint rotation (bot farm)
361    Ja4RotationPattern,
362    /// Same JA4 fingerprint across multiple IPs
363    Ja4IpCluster,
364    /// UA claims browser but JA4 shows bot/script
365    Ja4BrowserSpoofing,
366    /// JA4H fingerprint changed (header manipulation)
367    Ja4hChange,
368    // Payload anomaly types
369    /// Request size > p99 × threshold
370    OversizedRequest,
371    /// Response size > p99 × threshold
372    OversizedResponse,
373    /// Sudden increase in bytes/min
374    BandwidthSpike,
375    /// Large responses, small requests (data theft)
376    ExfiltrationPattern,
377    /// Large requests, small responses (malware upload)
378    UploadPattern,
379}
380
381impl std::fmt::Display for AnomalyType {
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        match self {
384            AnomalyType::FingerprintChange => write!(f, "fingerprint_change"),
385            AnomalyType::SessionSharing => write!(f, "session_sharing"),
386            AnomalyType::VelocitySpike => write!(f, "velocity_spike"),
387            AnomalyType::ImpossibleTravel => write!(f, "impossible_travel"),
388            AnomalyType::TokenReuse => write!(f, "token_reuse"),
389            AnomalyType::RotationPattern => write!(f, "rotation_pattern"),
390            AnomalyType::TimingAnomaly => write!(f, "timing_anomaly"),
391            AnomalyType::Ja4RotationPattern => write!(f, "ja4_rotation_pattern"),
392            AnomalyType::Ja4IpCluster => write!(f, "ja4_ip_cluster"),
393            AnomalyType::Ja4BrowserSpoofing => write!(f, "ja4_browser_spoofing"),
394            AnomalyType::Ja4hChange => write!(f, "ja4h_change"),
395            AnomalyType::OversizedRequest => write!(f, "oversized_request"),
396            AnomalyType::OversizedResponse => write!(f, "oversized_response"),
397            AnomalyType::BandwidthSpike => write!(f, "bandwidth_spike"),
398            AnomalyType::ExfiltrationPattern => write!(f, "exfiltration_pattern"),
399            AnomalyType::UploadPattern => write!(f, "upload_pattern"),
400        }
401    }
402}
403
404/// Anomaly severity levels.
405#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
406#[serde(rename_all = "lowercase")]
407pub enum AnomalySeverity {
408    Low,
409    Medium,
410    High,
411    Critical,
412}
413
414/// A detected anomaly.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct Anomaly {
417    pub id: String,
418    pub detected_at: i64,
419    pub category: SignalCategory,
420    pub anomaly_type: AnomalyType,
421    pub severity: AnomalySeverity,
422    pub description: String,
423    pub signals: Vec<Signal>,
424    pub entities: Vec<String>,
425    pub metadata: AnomalyMetadata,
426    /// Risk score applied to entity (if auto-risk enabled)
427    pub risk_applied: Option<u32>,
428}
429
430/// Anomaly metadata varies by type.
431#[derive(Debug, Clone, Default, Serialize, Deserialize)]
432pub struct AnomalyMetadata {
433    pub previous_value: Option<String>,
434    pub new_value: Option<String>,
435    pub ip_count: Option<usize>,
436    pub change_count: Option<usize>,
437    pub time_delta: Option<i64>,
438    pub threshold: Option<f64>,
439    pub actual: Option<f64>,
440    // Payload profiler context
441    pub template: Option<String>,
442    pub source: Option<String>,
443    // Impossible travel context
444    pub unique_ip_count: Option<usize>,
445    pub ips: Option<Vec<String>>,
446    pub time_delta_ms: Option<i64>,
447    pub time_delta_minutes: Option<f64>,
448    pub token_hash_prefix: Option<String>,
449    pub detection_method: Option<String>,
450}
451
452/// Query options for anomalies.
453#[derive(Debug, Clone, Default)]
454pub struct AnomalyQueryOptions {
455    pub severity: Option<AnomalySeverity>,
456    pub anomaly_type: Option<AnomalyType>,
457    pub category: Option<SignalCategory>,
458    pub from: Option<i64>,
459    pub to: Option<i64>,
460    pub entity_id: Option<String>,
461    pub limit: Option<usize>,
462    pub include_resolved: bool,
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn test_signal_type_category() {
471        assert_eq!(SignalType::Jwt.category(), SignalCategory::AuthToken);
472        assert_eq!(SignalType::Ja4.category(), SignalCategory::Network);
473        assert_eq!(SignalType::Timing.category(), SignalCategory::Behavioral);
474        assert_eq!(SignalType::HeaderOrder.category(), SignalCategory::Device);
475    }
476
477    #[test]
478    fn test_anomaly_type_display() {
479        assert_eq!(
480            AnomalyType::FingerprintChange.to_string(),
481            "fingerprint_change"
482        );
483        assert_eq!(
484            AnomalyType::Ja4RotationPattern.to_string(),
485            "ja4_rotation_pattern"
486        );
487    }
488
489    #[test]
490    fn test_severity_ordering() {
491        assert!(AnomalySeverity::Low < AnomalySeverity::Medium);
492        assert!(AnomalySeverity::Medium < AnomalySeverity::High);
493        assert!(AnomalySeverity::High < AnomalySeverity::Critical);
494    }
495}