use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::net::IpAddr;
use std::time::{Duration, Instant};
use crate::correlation::{CampaignUpdate, CorrelationReason, CorrelationType, FingerprintIndex};
use super::{Detector, DetectorResult};
#[derive(Debug, Clone)]
pub struct RotationConfig {
pub min_fingerprints: usize,
pub window: Duration,
pub track_combined: bool,
pub cleanup_interval_secs: u64,
pub grouping_window: Duration,
pub min_group_size: usize,
pub confidence_divisor: f64,
pub confidence_base: f64,
pub confidence_min: f64,
pub confidence_max: f64,
pub scan_interval_ms: u64,
}
impl Default for RotationConfig {
fn default() -> Self {
Self {
min_fingerprints: 3,
window: Duration::from_secs(60),
track_combined: true,
cleanup_interval_secs: 30,
grouping_window: Duration::from_secs(10),
min_group_size: 2,
confidence_divisor: 5.0,
confidence_base: 0.7,
confidence_min: 0.5,
confidence_max: 0.95,
scan_interval_ms: 10000,
}
}
}
impl RotationConfig {
pub fn with_threshold(min_fingerprints: usize) -> Self {
Self {
min_fingerprints,
..Default::default()
}
}
pub fn with_window(window: Duration) -> Self {
Self {
window,
..Default::default()
}
}
pub fn validate(&self) -> Result<(), String> {
if self.min_fingerprints < 2 {
return Err("min_fingerprints must be at least 2".to_string());
}
if self.window.is_zero() {
return Err("window duration must be positive".to_string());
}
Ok(())
}
}
#[derive(Debug)]
struct FingerprintHistory {
observations: Vec<(Instant, String)>,
last_cleanup: Instant,
}
impl FingerprintHistory {
fn new() -> Self {
Self {
observations: Vec::new(),
last_cleanup: Instant::now(),
}
}
fn add(&mut self, fingerprint: String) {
self.observations.push((Instant::now(), fingerprint));
}
fn cleanup(&mut self, window: Duration) {
let cutoff = Instant::now() - window;
self.observations.retain(|(ts, _)| *ts > cutoff);
self.last_cleanup = Instant::now();
}
fn unique_count_in_window(&self, window: Duration) -> usize {
let cutoff = Instant::now() - window;
let unique: HashSet<_> = self
.observations
.iter()
.filter(|(ts, _)| *ts > cutoff)
.map(|(_, fp)| fp.as_str())
.collect();
unique.len()
}
fn unique_fingerprints_in_window(&self, window: Duration) -> Vec<String> {
let cutoff = Instant::now() - window;
let unique: HashSet<_> = self
.observations
.iter()
.filter(|(ts, _)| *ts > cutoff)
.map(|(_, fp)| fp.clone())
.collect();
unique.into_iter().collect()
}
fn needs_cleanup(&self, interval_secs: u64) -> bool {
self.last_cleanup.elapsed().as_secs() >= interval_secs
}
}
pub struct Ja4RotationDetector {
config: RotationConfig,
history: RwLock<HashMap<IpAddr, FingerprintHistory>>,
flagged: RwLock<HashSet<IpAddr>>,
}
impl Ja4RotationDetector {
pub fn new(config: RotationConfig) -> Self {
Self {
config,
history: RwLock::new(HashMap::new()),
flagged: RwLock::new(HashSet::new()),
}
}
pub fn with_defaults() -> Self {
Self::new(RotationConfig::default())
}
pub fn record_fingerprint(&self, ip: IpAddr, fingerprint: String) {
if fingerprint.is_empty() {
return;
}
let mut history = self.history.write();
let entry = history.entry(ip).or_insert_with(FingerprintHistory::new);
entry.add(fingerprint);
if entry.needs_cleanup(self.config.cleanup_interval_secs) {
entry.cleanup(self.config.window);
}
let unique_count = entry.unique_count_in_window(self.config.window);
if unique_count >= self.config.min_fingerprints {
drop(history); self.flagged.write().insert(ip);
}
}
pub fn is_rotating(&self, ip: &IpAddr) -> bool {
if self.flagged.read().contains(ip) {
return true;
}
self.unique_count_in_window(ip) >= self.config.min_fingerprints
}
pub fn unique_count_in_window(&self, ip: &IpAddr) -> usize {
self.history
.read()
.get(ip)
.map(|h| h.unique_count_in_window(self.config.window))
.unwrap_or(0)
}
pub fn unique_fingerprints(&self, ip: &IpAddr) -> Vec<String> {
self.history
.read()
.get(ip)
.map(|h| h.unique_fingerprints_in_window(self.config.window))
.unwrap_or_default()
}
pub fn get_rotating_ips(&self) -> Vec<IpAddr> {
self.flagged.read().iter().copied().collect()
}
pub fn tracked_ip_count(&self) -> usize {
self.history.read().len()
}
pub fn flagged_ip_count(&self) -> usize {
self.flagged.read().len()
}
pub fn cleanup_old_observations(&self) {
let mut history = self.history.write();
for (_, ip_history) in history.iter_mut() {
ip_history.cleanup(self.config.window);
}
history.retain(|_, h| !h.observations.is_empty());
let window = self.config.window;
let min_fps = self.config.min_fingerprints;
self.flagged.write().retain(|ip| {
history
.get(ip)
.map(|h| h.unique_count_in_window(window) >= min_fps)
.unwrap_or(false)
});
}
pub fn stats(&self) -> Ja4RotationStats {
let history = self.history.read();
let tracked = history.len();
let total_observations: usize = history.values().map(|v| v.observations.len()).sum();
let flagged = self.flagged.read().len();
Ja4RotationStats {
tracked_ips: tracked,
flagged_ips: flagged,
total_observations,
window_seconds: self.config.window.as_secs(),
min_fingerprints: self.config.min_fingerprints,
}
}
fn group_by_rotation_timing(&self) -> Vec<Vec<IpAddr>> {
let history = self.history.read();
let flagged = self.flagged.read();
let mut ip_first_seen: Vec<(IpAddr, Instant)> = flagged
.iter()
.filter_map(|ip| {
history
.get(ip)
.and_then(|h| h.observations.first().map(|(ts, _)| (*ip, *ts)))
})
.collect();
ip_first_seen.sort_by_key(|(_, ts)| *ts);
let mut groups: Vec<Vec<IpAddr>> = Vec::new();
let mut current_group: Vec<IpAddr> = Vec::new();
let mut group_start: Option<Instant> = None;
for (ip, first_seen) in ip_first_seen {
match group_start {
None => {
group_start = Some(first_seen);
current_group.push(ip);
}
Some(start) => {
if first_seen.duration_since(start) <= self.config.grouping_window {
current_group.push(ip);
} else {
if current_group.len() >= self.config.min_group_size {
groups.push(std::mem::take(&mut current_group));
} else {
current_group.clear();
}
group_start = Some(first_seen);
current_group.push(ip);
}
}
}
}
if current_group.len() >= self.config.min_group_size {
groups.push(current_group);
}
groups
}
}
impl Default for Ja4RotationDetector {
fn default() -> Self {
Self::with_defaults()
}
}
impl Detector for Ja4RotationDetector {
fn name(&self) -> &'static str {
"ja4_rotation"
}
fn analyze(&self, _index: &FingerprintIndex) -> DetectorResult<Vec<CampaignUpdate>> {
self.cleanup_old_observations();
let groups = self.group_by_rotation_timing();
let updates: Vec<CampaignUpdate> = groups
.into_iter()
.map(|ips| {
let evidence: Vec<String> = ips
.iter()
.flat_map(|ip| {
let fps = self.unique_fingerprints(ip);
let ip_str = ip.to_string();
fps.into_iter()
.take(3) .map(move |fp| format!("{}:{}", ip_str, fp))
})
.take(10) .collect();
let ip_strings: Vec<String> = ips.iter().map(|ip| ip.to_string()).collect();
let ip_count = ip_strings.len();
let avg_unique: f64 = ips
.iter()
.map(|ip| self.unique_count_in_window(ip) as f64)
.sum::<f64>()
/ ip_count as f64;
let confidence = ((avg_unique - self.config.min_fingerprints as f64)
/ self.config.confidence_divisor
+ self.config.confidence_base)
.clamp(self.config.confidence_min, self.config.confidence_max);
CampaignUpdate {
campaign_id: None,
status: None,
confidence: Some(confidence),
attack_types: Some(vec!["fingerprint_rotation".to_string()]),
add_member_ips: Some(ip_strings.clone()),
add_correlation_reason: Some(CorrelationReason::new(
CorrelationType::TlsFingerprint,
confidence,
format!(
"{} IPs rotating JA4 fingerprints within {}s window",
ip_count,
self.config.window.as_secs()
),
evidence,
)),
increment_requests: None,
increment_blocked: None,
increment_rules: None,
risk_score: Some(((confidence * 100.0) as u32).min(100)),
}
})
.collect();
Ok(updates)
}
fn should_trigger(&self, ip: &IpAddr, _index: &FingerprintIndex) -> bool {
self.unique_count_in_window(ip) >= self.config.min_fingerprints.saturating_sub(1)
}
fn scan_interval_ms(&self) -> u64 {
self.config.scan_interval_ms
}
}
#[derive(Debug, Clone)]
pub struct Ja4RotationStats {
pub tracked_ips: usize,
pub flagged_ips: usize,
pub total_observations: usize,
pub window_seconds: u64,
pub min_fingerprints: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_config_default() {
let config = RotationConfig::default();
assert_eq!(config.min_fingerprints, 3);
assert_eq!(config.window, Duration::from_secs(60));
assert!(config.track_combined);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_threshold() {
let config = RotationConfig::with_threshold(5);
assert_eq!(config.min_fingerprints, 5);
assert!(config.validate().is_ok());
}
#[test]
fn test_config_with_window() {
let config = RotationConfig::with_window(Duration::from_secs(120));
assert_eq!(config.window, Duration::from_secs(120));
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validation_min_fingerprints() {
let config = RotationConfig {
min_fingerprints: 1,
..Default::default()
};
assert!(config.validate().is_err());
assert!(config.validate().unwrap_err().contains("min_fingerprints"));
}
#[test]
fn test_config_validation_zero_window() {
let config = RotationConfig {
window: Duration::ZERO,
..Default::default()
};
assert!(config.validate().is_err());
assert!(config.validate().unwrap_err().contains("window"));
}
#[test]
fn test_detector_new() {
let detector = Ja4RotationDetector::with_defaults();
assert_eq!(detector.name(), "ja4_rotation");
assert_eq!(detector.tracked_ip_count(), 0);
assert_eq!(detector.flagged_ip_count(), 0);
}
#[test]
fn test_record_fingerprint_single() {
let detector = Ja4RotationDetector::with_defaults();
let ip: IpAddr = "192.168.1.1".parse().unwrap();
detector.record_fingerprint(ip, "fp1".to_string());
assert_eq!(detector.tracked_ip_count(), 1);
assert_eq!(detector.unique_count_in_window(&ip), 1);
assert!(!detector.is_rotating(&ip));
}
#[test]
fn test_record_fingerprint_empty_skipped() {
let detector = Ja4RotationDetector::with_defaults();
let ip: IpAddr = "192.168.1.1".parse().unwrap();
detector.record_fingerprint(ip, "".to_string());
assert_eq!(detector.tracked_ip_count(), 0);
}
#[test]
fn test_rotation_detection_with_multiple_fingerprints() {
let config = RotationConfig {
min_fingerprints: 3,
window: Duration::from_secs(60),
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
detector.record_fingerprint(ip, "fp_alpha".to_string());
detector.record_fingerprint(ip, "fp_beta".to_string());
detector.record_fingerprint(ip, "fp_gamma".to_string());
assert!(detector.is_rotating(&ip));
assert_eq!(detector.flagged_ip_count(), 1);
assert_eq!(detector.unique_count_in_window(&ip), 3);
let fps = detector.unique_fingerprints(&ip);
assert_eq!(fps.len(), 3);
assert!(fps.contains(&"fp_alpha".to_string()));
assert!(fps.contains(&"fp_beta".to_string()));
assert!(fps.contains(&"fp_gamma".to_string()));
}
#[test]
fn test_rotation_not_triggered_below_threshold() {
let config = RotationConfig {
min_fingerprints: 4,
window: Duration::from_secs(60),
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
detector.record_fingerprint(ip, "fp1".to_string());
detector.record_fingerprint(ip, "fp2".to_string());
detector.record_fingerprint(ip, "fp3".to_string());
assert!(!detector.is_rotating(&ip));
assert_eq!(detector.flagged_ip_count(), 0);
assert_eq!(detector.unique_count_in_window(&ip), 3);
}
#[test]
fn test_duplicate_fingerprints_counted_once() {
let detector = Ja4RotationDetector::with_defaults();
let ip: IpAddr = "192.168.1.1".parse().unwrap();
for _ in 0..10 {
detector.record_fingerprint(ip, "same_fp".to_string());
}
assert_eq!(detector.unique_count_in_window(&ip), 1);
assert!(!detector.is_rotating(&ip));
}
#[test]
fn test_window_expiration() {
let config = RotationConfig {
min_fingerprints: 2,
window: Duration::from_millis(50), ..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip: IpAddr = "192.168.1.1".parse().unwrap();
detector.record_fingerprint(ip, "fp1".to_string());
detector.record_fingerprint(ip, "fp2".to_string());
assert!(detector.is_rotating(&ip));
thread::sleep(Duration::from_millis(100));
detector.cleanup_old_observations();
assert!(!detector.is_rotating(&ip));
assert_eq!(detector.flagged_ip_count(), 0);
}
#[test]
fn test_cleanup_removes_empty_histories() {
let config = RotationConfig {
min_fingerprints: 2,
window: Duration::from_millis(10),
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
for i in 0..5 {
let ip: IpAddr = format!("10.0.0.{}", i).parse().unwrap();
detector.record_fingerprint(ip, format!("fp{}", i));
}
assert_eq!(detector.tracked_ip_count(), 5);
thread::sleep(Duration::from_millis(50));
detector.cleanup_old_observations();
assert_eq!(detector.tracked_ip_count(), 0);
}
#[test]
fn test_is_rotating_accuracy() {
let config = RotationConfig {
min_fingerprints: 3,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let rotating_ip: IpAddr = "10.0.0.1".parse().unwrap();
let stable_ip: IpAddr = "10.0.0.2".parse().unwrap();
detector.record_fingerprint(rotating_ip, "fp1".to_string());
detector.record_fingerprint(rotating_ip, "fp2".to_string());
detector.record_fingerprint(rotating_ip, "fp3".to_string());
detector.record_fingerprint(rotating_ip, "fp4".to_string());
for _ in 0..10 {
detector.record_fingerprint(stable_ip, "stable_fp".to_string());
}
assert!(detector.is_rotating(&rotating_ip));
assert!(!detector.is_rotating(&stable_ip));
}
#[test]
fn test_should_trigger() {
let config = RotationConfig {
min_fingerprints: 3,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let index = FingerprintIndex::new();
let ip: IpAddr = "10.0.0.1".parse().unwrap();
assert!(!detector.should_trigger(&ip, &index));
detector.record_fingerprint(ip, "fp1".to_string());
assert!(!detector.should_trigger(&ip, &index));
detector.record_fingerprint(ip, "fp2".to_string());
assert!(detector.should_trigger(&ip, &index));
}
#[test]
fn test_analyze_creates_campaign_updates() {
let config = RotationConfig {
min_fingerprints: 2,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let index = FingerprintIndex::new();
let ip1: IpAddr = "10.0.0.1".parse().unwrap();
let ip2: IpAddr = "10.0.0.2".parse().unwrap();
detector.record_fingerprint(ip1, "fp1".to_string());
detector.record_fingerprint(ip1, "fp2".to_string());
detector.record_fingerprint(ip2, "fp1".to_string());
detector.record_fingerprint(ip2, "fp2".to_string());
assert!(detector.is_rotating(&ip1));
assert!(detector.is_rotating(&ip2));
let updates = detector.analyze(&index).unwrap();
assert!(!updates.is_empty() || detector.flagged_ip_count() == 2);
}
#[test]
fn test_scan_interval() {
let detector = Ja4RotationDetector::with_defaults();
assert_eq!(detector.scan_interval_ms(), 10000);
}
#[test]
fn test_stats() {
let config = RotationConfig {
min_fingerprints: 3,
window: Duration::from_secs(60),
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip1: IpAddr = "10.0.0.1".parse().unwrap();
let ip2: IpAddr = "10.0.0.2".parse().unwrap();
detector.record_fingerprint(ip1, "fp1".to_string());
detector.record_fingerprint(ip1, "fp2".to_string());
detector.record_fingerprint(ip1, "fp3".to_string());
detector.record_fingerprint(ip2, "fp1".to_string());
let stats = detector.stats();
assert_eq!(stats.tracked_ips, 2);
assert_eq!(stats.flagged_ips, 1);
assert_eq!(stats.total_observations, 4);
assert_eq!(stats.window_seconds, 60);
assert_eq!(stats.min_fingerprints, 3);
}
#[test]
fn test_get_rotating_ips() {
let config = RotationConfig {
min_fingerprints: 2,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip1: IpAddr = "10.0.0.1".parse().unwrap();
let ip2: IpAddr = "10.0.0.2".parse().unwrap();
let ip3: IpAddr = "10.0.0.3".parse().unwrap();
detector.record_fingerprint(ip1, "fp1".to_string());
detector.record_fingerprint(ip1, "fp2".to_string());
detector.record_fingerprint(ip2, "fp3".to_string());
detector.record_fingerprint(ip2, "fp4".to_string());
detector.record_fingerprint(ip3, "fp5".to_string());
let rotating = detector.get_rotating_ips();
assert_eq!(rotating.len(), 2);
assert!(rotating.contains(&ip1));
assert!(rotating.contains(&ip2));
assert!(!rotating.contains(&ip3));
}
#[test]
fn test_concurrent_fingerprint_recording() {
use std::sync::Arc;
let detector = Arc::new(Ja4RotationDetector::with_defaults());
let mut handles = vec![];
for thread_id in 0..10 {
let detector = Arc::clone(&detector);
handles.push(thread::spawn(move || {
for fp_id in 0..100 {
let ip: IpAddr = format!("10.{}.0.1", thread_id).parse().unwrap();
detector.record_fingerprint(ip, format!("fp_{}", fp_id % 5));
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert!(detector.tracked_ip_count() > 0);
}
#[test]
fn test_concurrent_read_write() {
use std::sync::Arc;
let detector = Arc::new(Ja4RotationDetector::with_defaults());
for i in 0..10 {
let ip: IpAddr = format!("10.0.0.{}", i).parse().unwrap();
detector.record_fingerprint(ip, "fp1".to_string());
detector.record_fingerprint(ip, "fp2".to_string());
detector.record_fingerprint(ip, "fp3".to_string());
}
let mut handles = vec![];
for thread_id in 0..5 {
let detector = Arc::clone(&detector);
handles.push(thread::spawn(move || {
for i in 0..50 {
let ip: IpAddr = format!("10.{}.0.{}", thread_id, i % 10).parse().unwrap();
detector.record_fingerprint(ip, format!("new_fp_{}", i));
}
}));
}
for _ in 0..5 {
let detector = Arc::clone(&detector);
handles.push(thread::spawn(move || {
for i in 0..100 {
let ip: IpAddr = format!("10.0.0.{}", i % 10).parse().unwrap();
let _ = detector.is_rotating(&ip);
let _ = detector.unique_count_in_window(&ip);
let _ = detector.stats();
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert!(detector.tracked_ip_count() > 0);
}
#[test]
fn test_ipv6_addresses() {
let detector = Ja4RotationDetector::with_defaults();
let ipv6: IpAddr = "2001:db8::1".parse().unwrap();
detector.record_fingerprint(ipv6, "fp1".to_string());
detector.record_fingerprint(ipv6, "fp2".to_string());
detector.record_fingerprint(ipv6, "fp3".to_string());
assert!(detector.is_rotating(&ipv6));
}
#[test]
fn test_many_unique_fingerprints() {
let config = RotationConfig {
min_fingerprints: 3,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
let ip: IpAddr = "10.0.0.1".parse().unwrap();
for i in 0..100 {
detector.record_fingerprint(ip, format!("fp_{}", i));
}
assert!(detector.is_rotating(&ip));
assert_eq!(detector.unique_count_in_window(&ip), 100);
}
#[test]
fn test_analyze_with_empty_history() {
let detector = Ja4RotationDetector::with_defaults();
let index = FingerprintIndex::new();
let updates = detector.analyze(&index).unwrap();
assert!(updates.is_empty());
}
#[test]
fn test_fingerprint_history_unique_count() {
let mut history = FingerprintHistory::new();
let window = Duration::from_secs(60);
history.add("fp1".to_string());
history.add("fp1".to_string()); history.add("fp2".to_string());
history.add("fp3".to_string());
history.add("fp2".to_string());
assert_eq!(history.unique_count_in_window(window), 3);
}
#[test]
fn test_fingerprint_history_cleanup() {
let mut history = FingerprintHistory::new();
history.add("fp1".to_string());
history.add("fp2".to_string());
thread::sleep(Duration::from_millis(10));
history.cleanup(Duration::from_millis(1));
assert!(history.observations.is_empty());
}
#[test]
fn test_grouping_by_rotation_timing() {
let config = RotationConfig {
min_fingerprints: 2,
..Default::default()
};
let detector = Ja4RotationDetector::new(config);
for i in 0..5 {
let ip: IpAddr = format!("10.0.0.{}", i).parse().unwrap();
detector.record_fingerprint(ip, "fp1".to_string());
detector.record_fingerprint(ip, "fp2".to_string());
}
let groups = detector.group_by_rotation_timing();
if !groups.is_empty() {
assert!(groups[0].len() >= 2);
}
}
}