Cybersecurity-Projects/PROJECTS/intermediate/ja3-ja4-tls-fingerprinting/crates/tlsfp-intel/tests/detect.rs

368 lines
11 KiB
Rust

// ©AngelaMos | 2026
// detect.rs
//! End to end tests for the detection engine.
//!
//! Each test drives a short sequence of hand built fingerprint events through a
//! store and checks that the right rule fires, and just as importantly that the
//! quiet cases stay quiet. The events are synthetic on purpose: the engine
//! consumes events, not packets, so building them directly is the honest seam
//! to test the rules at.
use tlsfp_core::fingerprint::{Ja3, Ja4Family};
use tlsfp_core::{FingerprintEvent, StreamEvent};
use tlsfp_intel::{AlertSeverity, DetectConfig, IntelStore, Rule};
const CURL_JA4: &str = "t13d1234h1_aaaaaaaaaaaa_bbbbbbbbbbbb";
const MALWARE_JA4: &str = "t10d070600_c50f5591e341_1a3805c3aa63";
const CHROME_JA4: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1";
const BROWSER_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
const CURL_UA: &str = "curl/8.4.0";
const UNIX_JA4T: &str = "29200_2-4-8-1-3_1424_7";
const WINDOWS_JA4T: &str = "64240_2-1-3-1-1-4_1460_8";
const FEED: &str = r#"[
{"application":"Chrome","os":"Windows","ja4_fingerprint":"t13d1516h2_8daaf6152771_e5627efa2ab1"},
{"application":"curl","library":"OpenSSL","ja4_fingerprint":"t13d1234h1_aaaaaaaaaaaa_bbbbbbbbbbbb"},
{"application":"Loader","notes":"known malware stealer","ja4_fingerprint":"t10d070600_c50f5591e341_1a3805c3aa63"}
]"#;
fn store() -> IntelStore {
let mut store = IntelStore::open_in_memory().unwrap();
store.import_ja4db(FEED).unwrap();
store
}
fn cfg() -> DetectConfig {
DetectConfig {
window_secs: 86_400,
rotation_threshold: 2,
monoculture_threshold: 2,
}
}
fn client_hello(ip: &str, ts: u64, ja4: &str) -> FingerprintEvent {
FingerprintEvent {
ts_nanos: ts,
src: format!("{ip}:1000").parse().unwrap(),
dst: "10.0.0.250:443".parse().unwrap(),
event: StreamEvent::ClientHello {
ja3: Ja3::from_digest([0u8; 16]),
ja3_raw: "raw".into(),
ja4: Ja4Family::new(ja4.into(), "raw".into()),
sni: None,
alpn: None,
},
}
}
fn http_request(ip: &str, ts: u64, ua: &str) -> FingerprintEvent {
FingerprintEvent {
ts_nanos: ts,
src: format!("{ip}:1000").parse().unwrap(),
dst: "10.0.0.250:80".parse().unwrap(),
event: StreamEvent::HttpRequest {
ja4h: Ja4Family::new(
"ge20nn000000_000000000000_000000000000".into(),
"raw".into(),
),
method: "GET".into(),
host: Some("example.com".into()),
user_agent: Some(ua.into()),
},
}
}
fn tcp_syn(ip: &str, ts: u64, ja4t: &str) -> FingerprintEvent {
FingerprintEvent {
ts_nanos: ts,
src: format!("{ip}:1000").parse().unwrap(),
dst: "10.0.0.250:443".parse().unwrap(),
event: StreamEvent::TcpSyn { ja4t: ja4t.into() },
}
}
fn fires(alerts: &[tlsfp_intel::Alert], rule: Rule) -> bool {
alerts.iter().any(|alert| alert.rule == rule)
}
#[test]
fn known_bad_fires_on_malicious_fingerprint() {
let mut store = store();
let alerts = store
.detect_with(
&client_hello("10.0.0.1", 1_000_000_000, MALWARE_JA4),
&cfg(),
)
.unwrap();
let known_bad = alerts
.iter()
.find(|alert| alert.rule == Rule::KnownBad)
.expect("a malicious fingerprint should raise known_bad");
assert_eq!(known_bad.severity, AlertSeverity::High);
assert!(known_bad.detail.contains("Loader"));
assert!(known_bad.detail.contains("prevalence"));
assert_eq!(known_bad.score, Some(1.0));
}
#[test]
fn known_bad_does_not_refire_for_the_same_address_and_fingerprint() {
let mut store = store();
let config = cfg();
let first = store
.detect_with(
&client_hello("10.0.0.1", 1_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
let second = store
.detect_with(
&client_hello("10.0.0.1", 2_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
let other = store
.detect_with(
&client_hello("10.0.0.99", 3_000_000_000, MALWARE_JA4),
&config,
)
.unwrap();
assert!(fires(&first, Rule::KnownBad));
assert!(!fires(&second, Rule::KnownBad));
assert!(fires(&other, Rule::KnownBad));
}
#[test]
fn ua_mismatch_browser_claim_over_tool_handshake() {
let mut store = store();
let config = cfg();
store
.detect_with(&client_hello("10.0.0.2", 1_000_000_000, CURL_JA4), &config)
.unwrap();
let alerts = store
.detect_with(
&http_request("10.0.0.2", 2_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
let mismatch = alerts
.iter()
.find(|alert| alert.rule == Rule::UaMismatch)
.expect("a browser User-Agent over a curl handshake is the headline mismatch");
assert_eq!(mismatch.severity, AlertSeverity::High);
assert!(mismatch.detail.to_lowercase().contains("curl"));
assert!(mismatch.title.to_lowercase().contains("chrome"));
}
#[test]
fn ua_mismatch_fires_from_the_tls_side_too() {
let mut store = store();
let config = cfg();
store
.detect_with(
&http_request("10.0.0.3", 1_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
let alerts = store
.detect_with(&client_hello("10.0.0.3", 2_000_000_000, CURL_JA4), &config)
.unwrap();
assert!(fires(&alerts, Rule::UaMismatch));
}
#[test]
fn an_honest_tool_user_agent_does_not_mismatch() {
let mut store = store();
let config = cfg();
store
.detect_with(&client_hello("10.0.0.4", 1_000_000_000, CURL_JA4), &config)
.unwrap();
let alerts = store
.detect_with(&http_request("10.0.0.4", 2_000_000_000, CURL_UA), &config)
.unwrap();
assert!(!fires(&alerts, Rule::UaMismatch));
}
#[test]
fn a_real_browser_does_not_mismatch_its_own_handshake() {
let mut store = store();
let config = cfg();
store
.detect_with(
&client_hello("10.0.0.5", 1_000_000_000, CHROME_JA4),
&config,
)
.unwrap();
let alerts = store
.detect_with(
&http_request("10.0.0.5", 2_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
assert!(!fires(&alerts, Rule::UaMismatch));
}
#[test]
fn os_mismatch_windows_claim_over_unix_stack() {
let mut store = store();
let config = cfg();
store
.detect_with(&tcp_syn("10.0.0.6", 1_000_000_000, UNIX_JA4T), &config)
.unwrap();
let alerts = store
.detect_with(
&http_request("10.0.0.6", 2_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
let mismatch = alerts
.iter()
.find(|alert| alert.rule == Rule::OsMismatch)
.expect("a Windows User-Agent over a Unix SYN should raise os_mismatch");
assert_eq!(mismatch.severity, AlertSeverity::Medium);
assert!(mismatch.title.contains("windows"));
assert!(mismatch.title.contains("unix"));
}
#[test]
fn os_mismatch_fires_from_the_syn_side_too() {
let mut store = store();
let config = cfg();
store
.detect_with(
&http_request("10.0.0.7", 1_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
let alerts = store
.detect_with(&tcp_syn("10.0.0.7", 2_000_000_000, UNIX_JA4T), &config)
.unwrap();
assert!(fires(&alerts, Rule::OsMismatch));
}
#[test]
fn a_consistent_operating_system_does_not_mismatch() {
let mut store = store();
let config = cfg();
store
.detect_with(&tcp_syn("10.0.0.8", 1_000_000_000, WINDOWS_JA4T), &config)
.unwrap();
let alerts = store
.detect_with(
&http_request("10.0.0.8", 2_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
assert!(!fires(&alerts, Rule::OsMismatch));
}
#[test]
fn first_seen_fires_once_per_fingerprint() {
let mut store = store();
let config = cfg();
let first = store
.detect_with(
&client_hello("10.0.0.9", 1_000_000_000, CHROME_JA4),
&config,
)
.unwrap();
assert!(fires(&first, Rule::FirstSeen));
let again = store
.detect_with(
&client_hello("10.0.0.10", 2_000_000_000, CHROME_JA4),
&config,
)
.unwrap();
assert!(!fires(&again, Rule::FirstSeen));
}
#[test]
fn rotation_fires_when_one_address_cycles_fingerprints() {
let mut store = store();
let config = cfg();
let ip = "10.0.0.11";
let first = store
.detect_with(
&client_hello(ip, 1_000_000_000, "t13d1516h2_111111111111_222222222222"),
&config,
)
.unwrap();
let second = store
.detect_with(
&client_hello(ip, 2_000_000_000, "t13d1516h2_333333333333_444444444444"),
&config,
)
.unwrap();
let third = store
.detect_with(
&client_hello(ip, 3_000_000_000, "t13d1516h2_555555555555_666666666666"),
&config,
)
.unwrap();
assert!(!fires(&first, Rule::FpRotation));
assert!(!fires(&second, Rule::FpRotation));
assert!(fires(&third, Rule::FpRotation));
}
#[test]
fn monoculture_fires_when_one_fingerprint_spans_addresses() {
let mut store = store();
let config = cfg();
let ja4 = "t13d1516h2_777777777777_888888888888";
let first = store
.detect_with(&client_hello("10.1.0.1", 1_000_000_000, ja4), &config)
.unwrap();
let second = store
.detect_with(&client_hello("10.1.0.2", 2_000_000_000, ja4), &config)
.unwrap();
let third = store
.detect_with(&client_hello("10.1.0.3", 3_000_000_000, ja4), &config)
.unwrap();
assert!(!fires(&first, Rule::Monoculture));
assert!(!fires(&second, Rule::Monoculture));
assert!(fires(&third, Rule::Monoculture));
}
#[test]
fn alerts_persist_and_round_trip_through_the_store() {
let mut store = store();
store
.detect_with(
&client_hello("10.2.0.1", 1_000_000_000, MALWARE_JA4),
&cfg(),
)
.unwrap();
let recent = store.recent_alerts(10).unwrap();
assert!(
recent
.iter()
.any(|alert| alert.rule == Rule::KnownBad && alert.severity == AlertSeverity::High)
);
let counts = store.alert_counts().unwrap();
assert!(
counts
.iter()
.any(|(rule, count)| *rule == Rule::KnownBad && *count >= 1)
);
}
#[test]
fn correlation_respects_the_time_window() {
let mut store = store();
let config = DetectConfig {
window_secs: 1,
rotation_threshold: 2,
monoculture_threshold: 2,
};
store
.detect_with(&client_hello("10.3.0.1", 0, CURL_JA4), &config)
.unwrap();
let alerts = store
.detect_with(
&http_request("10.3.0.1", 10_000_000_000, BROWSER_UA),
&config,
)
.unwrap();
assert!(!fires(&alerts, Rule::UaMismatch));
}