use parking_lot::RwLock;
use std::collections::HashSet;
use std::net::IpAddr;
use crate::correlation::{
Campaign, CampaignUpdate, CorrelationReason, CorrelationType, FingerprintGroup,
FingerprintIndex, FingerprintType,
};
use super::{Detector, DetectorResult};
#[derive(Debug, Clone)]
pub struct SharedFingerprintConfig {
pub threshold: usize,
pub base_confidence: f64,
pub combined_type_bonus: f64,
pub size_bonus_per_ip: f64,
pub max_size_bonus: f64,
pub scan_interval_ms: u64,
}
impl Default for SharedFingerprintConfig {
fn default() -> Self {
Self {
threshold: 3,
base_confidence: 0.85,
combined_type_bonus: 0.1,
size_bonus_per_ip: 0.02,
max_size_bonus: 0.05,
scan_interval_ms: 5000,
}
}
}
pub struct SharedFingerprintDetector {
config: SharedFingerprintConfig,
processed_fingerprints: RwLock<HashSet<String>>,
}
impl SharedFingerprintDetector {
pub fn new(threshold: usize) -> Self {
assert!(
threshold >= 2,
"Threshold must be at least 2 for correlation"
);
Self {
config: SharedFingerprintConfig {
threshold,
..Default::default()
},
processed_fingerprints: RwLock::new(HashSet::new()),
}
}
pub fn with_config(threshold: usize, base_confidence: f64, scan_interval_ms: u64) -> Self {
assert!(
threshold >= 2,
"Threshold must be at least 2 for correlation"
);
Self {
config: SharedFingerprintConfig {
threshold,
base_confidence: base_confidence.clamp(0.0, 1.0),
scan_interval_ms,
..Default::default()
},
processed_fingerprints: RwLock::new(HashSet::new()),
}
}
pub fn from_config(config: SharedFingerprintConfig) -> Self {
assert!(
config.threshold >= 2,
"Threshold must be at least 2 for correlation"
);
Self {
config,
processed_fingerprints: RwLock::new(HashSet::new()),
}
}
fn is_processed(&self, fingerprint: &str) -> bool {
self.processed_fingerprints.read().contains(fingerprint)
}
fn mark_processed(&self, fingerprint: &str) {
self.processed_fingerprints
.write()
.insert(fingerprint.to_string());
}
pub fn clear_processed(&self) {
self.processed_fingerprints.write().clear();
}
pub fn processed_count(&self) -> usize {
self.processed_fingerprints.read().len()
}
fn calculate_confidence(&self, fp_type: FingerprintType, group_size: usize) -> f64 {
let type_bonus = match fp_type {
FingerprintType::Ja4 => 0.0,
FingerprintType::Combined => self.config.combined_type_bonus,
};
let size_bonus = ((group_size.saturating_sub(self.config.threshold)) as f64
* self.config.size_bonus_per_ip)
.min(self.config.max_size_bonus);
(self.config.base_confidence + type_bonus + size_bonus).min(1.0)
}
fn create_campaign_update(&self, group: &FingerprintGroup) -> CampaignUpdate {
let confidence = self.calculate_confidence(group.fingerprint_type, group.size);
let description = format!(
"{} fingerprint '{}' shared by {} IPs",
group.fingerprint_type, group.fingerprint, group.size
);
let reason = CorrelationReason::new(
CorrelationType::HttpFingerprint,
confidence,
description,
group.ips.clone(),
);
let campaign = Campaign::new(Campaign::generate_id(), group.ips.clone(), confidence);
CampaignUpdate {
campaign_id: Some(campaign.id.clone()),
status: Some(campaign.status),
confidence: Some(confidence),
attack_types: None,
add_member_ips: Some(group.ips.clone()),
add_correlation_reason: Some(reason),
increment_requests: None,
increment_blocked: None,
increment_rules: None,
risk_score: None,
}
}
fn process_group(&self, group: &FingerprintGroup) -> Option<CampaignUpdate> {
if self.is_processed(&group.fingerprint) {
return None;
}
if group.size < self.config.threshold {
return None;
}
self.mark_processed(&group.fingerprint);
Some(self.create_campaign_update(group))
}
}
impl Detector for SharedFingerprintDetector {
fn name(&self) -> &'static str {
"shared_fingerprint"
}
fn analyze(&self, index: &FingerprintIndex) -> DetectorResult<Vec<CampaignUpdate>> {
let groups = index.get_groups_above_threshold(self.config.threshold);
if groups.is_empty() {
return Ok(Vec::new());
}
let updates: Vec<CampaignUpdate> = groups
.iter()
.filter_map(|group| self.process_group(group))
.collect();
Ok(updates)
}
fn should_trigger(&self, ip: &IpAddr, index: &FingerprintIndex) -> bool {
let ip_str = ip.to_string();
let fingerprints = match index.get_ip_fingerprints(&ip_str) {
Some(fps) => fps,
None => return false,
};
if let Some(ref ja4) = fingerprints.0 {
if !self.is_processed(ja4) {
let count = index.count_ips_by_ja4(ja4);
if count >= self.config.threshold {
return true;
}
}
}
if let Some(ref combined) = fingerprints.1 {
if !self.is_processed(combined) {
let count = index.count_ips_by_combined(combined);
if count >= self.config.threshold {
return true;
}
}
}
false
}
fn scan_interval_ms(&self) -> u64 {
self.config.scan_interval_ms
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_index_ja4(fingerprint: &str, ip_count: usize) -> FingerprintIndex {
let index = FingerprintIndex::new();
for i in 0..ip_count {
let ip = format!("192.168.1.{}", i + 1);
index.update_entity(&ip, Some(fingerprint), None);
}
index
}
fn create_test_index_combined(fingerprint: &str, ip_count: usize) -> FingerprintIndex {
let index = FingerprintIndex::new();
for i in 0..ip_count {
let ip = format!("10.0.0.{}", i + 1);
index.update_entity(&ip, None, Some(fingerprint));
}
index
}
fn create_mixed_test_index() -> FingerprintIndex {
let index = FingerprintIndex::new();
for i in 0..4 {
index.update_entity(&format!("192.168.1.{}", i + 1), Some("ja4_shared"), None);
}
for i in 0..3 {
index.update_entity(&format!("10.0.0.{}", i + 1), None, Some("combined_shared"));
}
index.update_entity("172.16.0.1", Some("ja4_unique"), Some("combined_unique"));
index
}
#[test]
fn test_new_detector() {
let detector = SharedFingerprintDetector::new(3);
assert_eq!(detector.config.threshold, 3);
assert!((detector.config.base_confidence - 0.85).abs() < 0.001);
assert_eq!(detector.config.scan_interval_ms, 5000);
assert_eq!(detector.processed_count(), 0);
}
#[test]
fn test_detector_with_config() {
let detector = SharedFingerprintDetector::with_config(5, 0.9, 10000);
assert_eq!(detector.config.threshold, 5);
assert!((detector.config.base_confidence - 0.9).abs() < 0.001);
assert_eq!(detector.config.scan_interval_ms, 10000);
}
#[test]
fn test_confidence_clamping() {
let detector = SharedFingerprintDetector::with_config(2, 1.5, 1000);
assert!((detector.config.base_confidence - 1.0).abs() < 0.001);
let detector = SharedFingerprintDetector::with_config(2, -0.5, 1000);
assert!(detector.config.base_confidence >= 0.0);
}
#[test]
#[should_panic(expected = "Threshold must be at least 2")]
fn test_threshold_too_low() {
SharedFingerprintDetector::new(1);
}
#[test]
fn test_detect_ja4_group_at_threshold() {
let index = create_test_index_ja4("shared_ja4", 3);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 1);
assert!(updates[0].add_correlation_reason.is_some());
let reason = updates[0].add_correlation_reason.as_ref().unwrap();
assert_eq!(reason.correlation_type, CorrelationType::HttpFingerprint);
assert_eq!(reason.evidence.len(), 3);
}
#[test]
fn test_detect_ja4_group_above_threshold() {
let index = create_test_index_ja4("large_group", 10);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 1);
let reason = updates[0].add_correlation_reason.as_ref().unwrap();
assert_eq!(reason.evidence.len(), 10);
assert!(reason.confidence > 0.85);
}
#[test]
fn test_no_detection_below_threshold() {
let index = create_test_index_ja4("small_group", 2);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert!(updates.is_empty());
}
#[test]
fn test_detect_combined_group() {
let index = create_test_index_combined("combined_fp", 4);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 1);
let reason = updates[0].add_correlation_reason.as_ref().unwrap();
assert!(reason.confidence > 0.9);
}
#[test]
fn test_detect_mixed_groups() {
let index = create_mixed_test_index();
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 2);
}
#[test]
fn test_no_duplicate_campaigns() {
let index = create_test_index_ja4("repeated_fp", 5);
let detector = SharedFingerprintDetector::new(3);
let updates1 = detector.analyze(&index).unwrap();
assert_eq!(updates1.len(), 1);
let updates2 = detector.analyze(&index).unwrap();
assert!(updates2.is_empty());
assert_eq!(detector.processed_count(), 1);
}
#[test]
fn test_clear_processed() {
let index = create_test_index_ja4("clearable_fp", 3);
let detector = SharedFingerprintDetector::new(3);
let updates1 = detector.analyze(&index).unwrap();
assert_eq!(updates1.len(), 1);
detector.clear_processed();
assert_eq!(detector.processed_count(), 0);
let updates2 = detector.analyze(&index).unwrap();
assert_eq!(updates2.len(), 1);
}
#[test]
fn test_should_trigger_at_threshold() {
let index = create_test_index_ja4("trigger_fp", 3);
let detector = SharedFingerprintDetector::new(3);
let ip: IpAddr = "192.168.1.1".parse().unwrap();
assert!(detector.should_trigger(&ip, &index));
}
#[test]
fn test_should_not_trigger_below_threshold() {
let index = create_test_index_ja4("small_fp", 2);
let detector = SharedFingerprintDetector::new(3);
let ip: IpAddr = "192.168.1.1".parse().unwrap();
assert!(!detector.should_trigger(&ip, &index));
}
#[test]
fn test_should_not_trigger_already_processed() {
let index = create_test_index_ja4("processed_fp", 5);
let detector = SharedFingerprintDetector::new(3);
detector.analyze(&index).unwrap();
let ip: IpAddr = "192.168.1.1".parse().unwrap();
assert!(!detector.should_trigger(&ip, &index));
}
#[test]
fn test_should_not_trigger_unknown_ip() {
let index = create_test_index_ja4("known_fp", 3);
let detector = SharedFingerprintDetector::new(3);
let ip: IpAddr = "10.10.10.10".parse().unwrap();
assert!(!detector.should_trigger(&ip, &index));
}
#[test]
fn test_confidence_ja4_only() {
let detector = SharedFingerprintDetector::new(3);
let confidence = detector.calculate_confidence(FingerprintType::Ja4, 3);
assert!((confidence - 0.85).abs() < 0.001);
}
#[test]
fn test_confidence_combined_higher() {
let detector = SharedFingerprintDetector::new(3);
let ja4_conf = detector.calculate_confidence(FingerprintType::Ja4, 3);
let combined_conf = detector.calculate_confidence(FingerprintType::Combined, 3);
assert!(combined_conf > ja4_conf);
assert!((combined_conf - 0.95).abs() < 0.001);
}
#[test]
fn test_confidence_increases_with_size() {
let detector = SharedFingerprintDetector::new(3);
let conf_3 = detector.calculate_confidence(FingerprintType::Ja4, 3);
let conf_5 = detector.calculate_confidence(FingerprintType::Ja4, 5);
let conf_10 = detector.calculate_confidence(FingerprintType::Ja4, 10);
assert!(conf_5 > conf_3);
assert!(conf_10 > conf_5);
}
#[test]
fn test_confidence_capped_at_one() {
let detector = SharedFingerprintDetector::with_config(3, 0.99, 1000);
let confidence = detector.calculate_confidence(FingerprintType::Combined, 100);
assert!((confidence - 1.0).abs() < 0.001);
}
#[test]
fn test_detector_name() {
let detector = SharedFingerprintDetector::new(3);
assert_eq!(detector.name(), "shared_fingerprint");
}
#[test]
fn test_detector_scan_interval() {
let detector = SharedFingerprintDetector::with_config(3, 0.9, 7500);
assert_eq!(detector.scan_interval_ms(), 7500);
}
#[test]
fn test_empty_index() {
let index = FingerprintIndex::new();
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert!(updates.is_empty());
}
#[test]
fn test_all_unique_fingerprints() {
let index = FingerprintIndex::new();
for i in 0..10 {
index.update_entity(
&format!("10.0.0.{}", i),
Some(&format!("unique_fp_{}", i)),
None,
);
}
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert!(updates.is_empty());
}
#[test]
fn test_multiple_groups_different_sizes() {
let index = FingerprintIndex::new();
for i in 0..5 {
index.update_entity(&format!("192.168.1.{}", i), Some("large_group"), None);
}
for i in 0..3 {
index.update_entity(&format!("10.0.0.{}", i), Some("medium_group"), None);
}
index.update_entity("172.16.0.1", Some("small_group"), None);
index.update_entity("172.16.0.2", Some("small_group"), None);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 2);
}
#[test]
fn test_ipv6_addresses() {
let index = FingerprintIndex::new();
index.update_entity("2001:db8::1", Some("ipv6_shared"), None);
index.update_entity("2001:db8::2", Some("ipv6_shared"), None);
index.update_entity("2001:db8::3", Some("ipv6_shared"), None);
let detector = SharedFingerprintDetector::new(3);
let updates = detector.analyze(&index).unwrap();
assert_eq!(updates.len(), 1);
let reason = updates[0].add_correlation_reason.as_ref().unwrap();
assert!(reason.evidence.contains(&"2001:db8::1".to_string()));
}
#[test]
fn test_concurrent_analysis() {
use std::sync::Arc;
use std::thread;
let index = Arc::new(create_test_index_ja4("concurrent_fp", 5));
let detector = Arc::new(SharedFingerprintDetector::new(3));
let mut handles = vec![];
for _ in 0..5 {
let index = Arc::clone(&index);
let detector = Arc::clone(&detector);
handles.push(thread::spawn(move || detector.analyze(&index).unwrap()));
}
let mut total_updates = 0;
for handle in handles {
let updates = handle.join().unwrap();
total_updates += updates.len();
}
assert_eq!(total_updates, 1);
assert_eq!(detector.processed_count(), 1);
}
#[test]
fn test_concurrent_trigger_checks() {
use std::sync::Arc;
use std::thread;
let index = Arc::new(create_test_index_ja4("trigger_concurrent", 5));
let detector = Arc::new(SharedFingerprintDetector::new(3));
let mut handles = vec![];
for i in 0..5 {
let index = Arc::clone(&index);
let detector = Arc::clone(&detector);
let ip: IpAddr = format!("192.168.1.{}", i + 1).parse().unwrap();
handles.push(thread::spawn(move || detector.should_trigger(&ip, &index)));
}
let mut triggered_count = 0;
for handle in handles {
if handle.join().unwrap() {
triggered_count += 1;
}
}
assert!(triggered_count > 0);
}
}