368 lines
11 KiB
Rust
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));
|
|
}
|