1use http::header::{HeaderName, HeaderValue};
12use once_cell::sync::Lazy;
13use regex::Regex;
14use sha2::{Digest, Sha256};
15use std::collections::{HashMap, HashSet};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Ja4Protocol {
24 TCP,
25 QUIC,
26}
27
28impl std::fmt::Display for Ja4Protocol {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 Ja4Protocol::TCP => write!(f, "TCP"),
32 Ja4Protocol::QUIC => write!(f, "QUIC"),
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum Ja4SniType {
40 Domain,
41 IP,
42 None,
43}
44
45impl std::fmt::Display for Ja4SniType {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 Ja4SniType::Domain => write!(f, "Domain"),
49 Ja4SniType::IP => write!(f, "IP"),
50 Ja4SniType::None => write!(f, "None"),
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Ja4Fingerprint {
62 pub raw: String,
64
65 pub protocol: Ja4Protocol,
68 pub tls_version: u8,
70 pub sni_type: Ja4SniType,
72 pub cipher_count: u8,
74 pub ext_count: u8,
76 pub alpn: String,
78 pub cipher_hash: String,
80 pub ext_hash: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Ja4hFingerprint {
89 pub raw: String,
91
92 pub method: String,
95 pub http_version: u8,
97 pub has_cookie: bool,
99 pub has_referer: bool,
101 pub accept_lang: String,
103 pub header_hash: String,
105 pub cookie_hash: String,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ClientFingerprint {
112 pub ja4: Option<Ja4Fingerprint>,
114 pub ja4h: Ja4hFingerprint,
116 pub combined_hash: String,
118}
119
120#[derive(Debug, Clone)]
122pub struct Ja4Analysis {
123 pub fingerprint: Ja4Fingerprint,
124 pub suspicious: bool,
125 pub issues: Vec<String>,
126 pub estimated_client: String,
127}
128
129#[derive(Debug, Clone)]
131pub struct Ja4hAnalysis {
132 pub fingerprint: Ja4hFingerprint,
133 pub suspicious: bool,
134 pub issues: Vec<String>,
135}
136
137static JA4_REGEX: Lazy<Regex> = Lazy::new(|| {
143 Regex::new(r"(?i)^([tq])(\d{2})([di]?)([0-9a-f]{2})([0-9a-f]{2})([a-z0-9]{2})_([0-9a-f]{12})_([0-9a-f]{12})$")
144 .expect("JA4 regex should compile")
145});
146
147static JA4H_REGEX: Lazy<Regex> = Lazy::new(|| {
149 Regex::new(r"^[a-z]{2}\d{2}[cn][rn][a-z0-9]{2}_[0-9a-f]{12}_[0-9a-f]{12}$")
150 .expect("JA4H regex should compile")
151});
152
153static METHOD_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
155 let mut m = HashMap::new();
156 m.insert("GET", "ge");
157 m.insert("POST", "po");
158 m.insert("PUT", "pu");
159 m.insert("DELETE", "de");
160 m.insert("HEAD", "he");
161 m.insert("OPTIONS", "op");
162 m.insert("PATCH", "pa");
163 m.insert("CONNECT", "co");
164 m.insert("TRACE", "tr");
165 m
166});
167
168static ALPN_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
170 let mut m = HashMap::new();
171 m.insert("h1", "http/1.1");
172 m.insert("h2", "h2");
173 m.insert("h3", "h3");
174 m.insert("00", "unknown");
175 m
176});
177
178static EXCLUDED_HEADERS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
180 let mut s = HashSet::new();
181 s.insert("cookie");
182 s.insert("referer");
183 s.insert(":method");
185 s.insert(":path");
186 s.insert(":scheme");
187 s.insert(":authority");
188 s.insert("host");
189 s.insert("content-length");
190 s.insert("content-type");
191 s
192});
193
194pub fn parse_ja4_from_header(header: Option<&str>) -> Option<Ja4Fingerprint> {
216 let header = header?;
217
218 let normalized = header.trim();
220 if normalized.is_empty() || normalized.len() > 100 {
221 return None;
222 }
223
224 let caps = JA4_REGEX.captures(normalized)?;
226
227 let protocol_char = caps.get(1)?.as_str();
229 let version_str = caps.get(2)?.as_str();
230 let sni_char = caps.get(3).map(|m| m.as_str()).unwrap_or("");
231 let cipher_count_str = caps.get(4)?.as_str();
232 let ext_count_str = caps.get(5)?.as_str();
233 let alpn_str = caps.get(6)?.as_str();
234 let cipher_hash = caps.get(7)?.as_str();
235 let ext_hash = caps.get(8)?.as_str();
236
237 let tls_version = version_str.parse::<u8>().ok()?;
239 let cipher_count = u8::from_str_radix(cipher_count_str, 16).ok()?;
240 let ext_count = u8::from_str_radix(ext_count_str, 16).ok()?;
241
242 if !(10..=13).contains(&tls_version) {
244 return None;
245 }
246
247 if cipher_hash.len() != 12 || ext_hash.len() != 12 {
249 return None;
250 }
251
252 let protocol = if protocol_char.to_lowercase() == "q" {
254 Ja4Protocol::QUIC
255 } else {
256 Ja4Protocol::TCP
257 };
258
259 let sni_type = match sni_char {
260 "d" => Ja4SniType::Domain,
261 "i" => Ja4SniType::IP,
262 _ => Ja4SniType::None,
263 };
264
265 let alpn = ALPN_MAP
266 .get(alpn_str.to_lowercase().as_str())
267 .copied()
268 .unwrap_or(alpn_str)
269 .to_string();
270
271 Some(Ja4Fingerprint {
272 raw: normalized.to_lowercase(),
273 protocol,
274 tls_version,
275 sni_type,
276 cipher_count,
277 ext_count,
278 alpn,
279 cipher_hash: cipher_hash.to_lowercase(),
280 ext_hash: ext_hash.to_lowercase(),
281 })
282}
283
284pub struct HttpHeaders<'a> {
290 pub headers: &'a [(HeaderName, HeaderValue)],
292 pub method: &'a str,
294 pub http_version: &'a str,
296}
297
298pub fn generate_ja4h(request: &HttpHeaders<'_>) -> Ja4hFingerprint {
312 let method = METHOD_MAP
314 .get(request.method.to_uppercase().as_str())
315 .copied()
316 .unwrap_or("xx")
317 .to_string();
318
319 let http_version = get_http_version(request.http_version);
321
322 let mut cookie_value: Option<&str> = None;
324 let mut referer_value: Option<&str> = None;
325 let mut accept_lang_value: Option<&str> = None;
326
327 for (name, value) in request.headers.iter() {
328 let Ok(value_str) = value.to_str() else {
329 continue;
330 };
331 match name.as_str() {
332 "cookie" => cookie_value = Some(value_str),
333 "referer" => referer_value = Some(value_str),
334 "accept-language" => accept_lang_value = Some(value_str),
335 _ => {}
336 }
337 }
338
339 let has_cookie = cookie_value.is_some();
340 let has_referer = referer_value.is_some();
341 let cookie_flag = if has_cookie { "c" } else { "n" };
342 let referer_flag = if has_referer { "r" } else { "n" };
343
344 let accept_lang = extract_accept_lang(accept_lang_value);
346
347 let header_hash = hash_headers(request.headers);
349
350 let cookie_hash = if let Some(cookies) = cookie_value {
352 hash_cookies(cookies)
353 } else {
354 "000000000000".to_string()
355 };
356
357 let raw = format!(
359 "{}{}{}{}{}_{}_{}",
360 method, http_version, cookie_flag, referer_flag, accept_lang, header_hash, cookie_hash
361 );
362
363 Ja4hFingerprint {
364 raw,
365 method,
366 http_version,
367 has_cookie,
368 has_referer,
369 accept_lang,
370 header_hash,
371 cookie_hash,
372 }
373}
374
375fn get_http_version(version: &str) -> u8 {
377 match version {
378 "2.0" | "2" => 20,
379 "3.0" | "3" => 30,
380 "1.0" => 10,
381 _ => 11, }
383}
384
385fn extract_accept_lang(header: Option<&str>) -> String {
392 let Some(value) = header else {
393 return "00".to_string();
394 };
395
396 if value.is_empty() {
397 return "00".to_string();
398 }
399
400 let first_lang = value
402 .split(',')
403 .next()
404 .and_then(|s| s.split(';').next())
405 .and_then(|s| s.split('-').next())
406 .map(|s| s.trim().to_lowercase())
407 .unwrap_or_default();
408
409 if first_lang.len() < 2 {
410 return "00".to_string();
411 }
412
413 first_lang[..2].to_string()
414}
415
416fn hash_headers(headers: &[(HeaderName, HeaderValue)]) -> String {
423 let mut names: Vec<&str> = headers
424 .iter()
425 .map(|(name, _)| name.as_str())
426 .filter(|name| !EXCLUDED_HEADERS.contains(*name))
427 .collect();
428
429 if names.is_empty() {
430 return "000000000000".to_string();
431 }
432
433 names.sort();
434 sha256_first12(&names.join(","))
435}
436
437fn hash_cookies(cookie_header: &str) -> String {
445 let mut cookie_names: Vec<String> = cookie_header
446 .split(';')
447 .filter_map(|c| {
448 let name = c.split('=').next()?.trim().to_lowercase();
449 if name.is_empty() {
450 None
451 } else {
452 Some(name)
453 }
454 })
455 .collect();
456
457 if cookie_names.is_empty() {
458 return "000000000000".to_string();
459 }
460
461 cookie_names.sort();
462 sha256_first12(&cookie_names.join(","))
463}
464
465pub fn extract_client_fingerprint(
473 ja4_header: Option<&str>,
474 request: &HttpHeaders<'_>,
475) -> ClientFingerprint {
476 let ja4 = parse_ja4_from_header(ja4_header);
477 let ja4h = generate_ja4h(request);
478
479 let mut hasher = Sha256::new();
481 if let Some(ref fp) = ja4 {
482 hasher.update(fp.raw.as_bytes());
483 }
484 hasher.update(ja4h.raw.as_bytes());
485 let result = hasher.finalize();
486 let combined_hash = hex::encode(&result[..8]); ClientFingerprint {
489 ja4,
490 ja4h,
491 combined_hash,
492 }
493}
494
495pub fn sha256_first12(input: &str) -> String {
501 let mut hasher = Sha256::new();
502 hasher.update(input.as_bytes());
503 let result = hasher.finalize();
504 hex::encode(&result[..6]) }
506
507pub fn is_valid_ja4(fingerprint: &str) -> bool {
509 parse_ja4_from_header(Some(fingerprint)).is_some()
510}
511
512pub fn is_valid_ja4h(fingerprint: &str) -> bool {
514 JA4H_REGEX.is_match(&fingerprint.to_lowercase())
515}
516
517pub fn fingerprints_match(fp1: Option<&str>, fp2: Option<&str>) -> bool {
519 match (fp1, fp2) {
520 (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
521 _ => false,
522 }
523}
524
525pub fn matches_pattern(fingerprint: &str, pattern: &str) -> bool {
532 if fingerprint.is_empty() || pattern.is_empty() {
533 return false;
534 }
535
536 let escaped = regex::escape(pattern);
538 let regex_pattern = escaped.replace(r"\*", ".*");
539
540 match Regex::new(&format!("^(?i){}$", regex_pattern)) {
541 Ok(re) => re.is_match(fingerprint),
542 Err(_) => false,
543 }
544}
545
546pub fn analyze_ja4(fingerprint: &Ja4Fingerprint) -> Ja4Analysis {
552 let mut issues = Vec::new();
553
554 if fingerprint.tls_version < 12 {
556 issues.push(format!(
557 "Outdated TLS version: 1.{}",
558 fingerprint.tls_version - 10
559 ));
560 }
561
562 if fingerprint.tls_version >= 13 && fingerprint.alpn == "unknown" {
564 issues.push("Missing ALPN with TLS 1.3 (unusual for browsers)".to_string());
565 }
566
567 if fingerprint.cipher_count < 5 {
569 issues.push(format!(
570 "Low cipher count: {} (typical browsers offer 10+)",
571 fingerprint.cipher_count
572 ));
573 }
574
575 if fingerprint.ext_count < 5 {
577 issues.push(format!(
578 "Low extension count: {} (typical browsers have 10+)",
579 fingerprint.ext_count
580 ));
581 }
582
583 let estimated_client = estimate_client_from_ja4(fingerprint);
584
585 Ja4Analysis {
586 fingerprint: fingerprint.clone(),
587 suspicious: !issues.is_empty(),
588 issues,
589 estimated_client,
590 }
591}
592
593fn estimate_client_from_ja4(fingerprint: &Ja4Fingerprint) -> String {
595 if fingerprint.tls_version >= 13 && fingerprint.alpn == "h2" && fingerprint.cipher_count >= 10 {
597 return "modern-browser".to_string();
598 }
599
600 if fingerprint.tls_version == 12 && fingerprint.alpn == "h2" {
602 return "browser".to_string();
603 }
604
605 if fingerprint.alpn == "http/1.1" && fingerprint.tls_version >= 12 {
607 return "api-client".to_string();
608 }
609
610 if fingerprint.tls_version < 12 || fingerprint.cipher_count < 5 || fingerprint.ext_count < 5 {
612 return "bot-or-script".to_string();
613 }
614
615 "unknown".to_string()
616}
617
618pub fn analyze_ja4h(fingerprint: &Ja4hFingerprint) -> Ja4hAnalysis {
620 let mut issues = Vec::new();
621
622 if fingerprint.accept_lang == "00" {
624 issues.push("No Accept-Language header (unusual for browsers)".to_string());
625 }
626
627 if fingerprint.http_version == 10 {
629 issues.push("HTTP/1.0 (very rare, possibly script)".to_string());
630 }
631
632 Ja4hAnalysis {
633 fingerprint: fingerprint.clone(),
634 suspicious: !issues.is_empty(),
635 issues,
636 }
637}
638
639#[cfg(test)]
644mod tests {
645 use super::*;
646
647 fn test_limit_usize(env_key: &str, default: usize, min: usize) -> usize {
648 std::env::var(env_key)
649 .ok()
650 .and_then(|value| value.parse::<usize>().ok())
651 .map(|value| value.max(min).min(default))
652 .unwrap_or(default)
653 }
654
655 fn header(name: &str, value: &str) -> (HeaderName, HeaderValue) {
656 let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
657 let header_value = HeaderValue::from_str(value).expect("valid header value");
658 (header_name, header_value)
659 }
660
661 #[test]
664 fn test_parse_ja4_valid_tcp_tls13() {
665 let result = parse_ja4_from_header(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"));
666 assert!(result.is_some());
667 let fp = result.unwrap();
668 assert_eq!(fp.protocol, Ja4Protocol::TCP);
669 assert_eq!(fp.tls_version, 13);
670 assert_eq!(fp.sni_type, Ja4SniType::Domain);
671 assert_eq!(fp.cipher_count, 0x15); assert_eq!(fp.ext_count, 0x16); assert_eq!(fp.alpn, "h2");
674 assert_eq!(fp.cipher_hash, "8daaf6152771");
675 assert_eq!(fp.ext_hash, "e5627efa2ab1");
676 }
677
678 #[test]
679 fn test_parse_ja4_valid_quic_tls13() {
680 let result = parse_ja4_from_header(Some("q13d0a0bh3_1234567890ab_abcdef123456"));
681 assert!(result.is_some());
682 let fp = result.unwrap();
683 assert_eq!(fp.protocol, Ja4Protocol::QUIC);
684 assert_eq!(fp.tls_version, 13);
685 assert_eq!(fp.sni_type, Ja4SniType::Domain);
686 assert_eq!(fp.alpn, "h3");
687 }
688
689 #[test]
690 fn test_parse_ja4_valid_tls12_ip_sni() {
691 let result = parse_ja4_from_header(Some("t12i0c10h1_aabbccddeeff_112233445566"));
692 assert!(result.is_some());
693 let fp = result.unwrap();
694 assert_eq!(fp.tls_version, 12);
695 assert_eq!(fp.sni_type, Ja4SniType::IP);
696 assert_eq!(fp.alpn, "http/1.1");
697 }
698
699 #[test]
700 fn test_parse_ja4_valid_no_sni() {
701 let result = parse_ja4_from_header(Some("t130510h2_aabbccddeeff_112233445566"));
702 assert!(result.is_some());
703 let fp = result.unwrap();
704 assert_eq!(fp.sni_type, Ja4SniType::None);
705 }
706
707 #[test]
708 fn test_parse_ja4_invalid_format() {
709 assert!(parse_ja4_from_header(Some("invalid")).is_none());
710 assert!(parse_ja4_from_header(Some("")).is_none());
711 assert!(parse_ja4_from_header(None).is_none());
712 assert!(parse_ja4_from_header(Some("t13d1516h2_short_hash")).is_none());
713 }
714
715 #[test]
716 fn test_parse_ja4_too_long() {
717 let long_input = "a".repeat(200);
718 assert!(parse_ja4_from_header(Some(&long_input)).is_none());
719 }
720
721 #[test]
722 fn test_parse_ja4_case_insensitive() {
723 let result1 = parse_ja4_from_header(Some("T13D1516H2_8DAAF6152771_E5627EFA2AB1"));
724 let result2 = parse_ja4_from_header(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"));
725 assert!(result1.is_some());
726 assert!(result2.is_some());
727 assert_eq!(result1.unwrap().raw, result2.unwrap().raw);
729 }
730
731 #[test]
734 fn test_generate_ja4h_basic() {
735 let headers = vec![
736 header("Accept", "text/html"),
737 header("User-Agent", "Mozilla/5.0"),
738 ];
739 let request = HttpHeaders {
740 headers: &headers,
741 method: "GET",
742 http_version: "1.1",
743 };
744
745 let result = generate_ja4h(&request);
746
747 assert_eq!(result.method, "ge");
748 assert_eq!(result.http_version, 11);
749 assert!(!result.has_cookie);
750 assert!(!result.has_referer);
751 assert_eq!(result.accept_lang, "00");
752 assert_eq!(result.cookie_hash, "000000000000");
753 }
754
755 #[test]
756 fn test_generate_ja4h_with_cookie() {
757 let headers = vec![
758 header("Cookie", "session=abc123; user=test"),
759 header("Accept", "text/html"),
760 ];
761 let request = HttpHeaders {
762 headers: &headers,
763 method: "POST",
764 http_version: "2.0",
765 };
766
767 let result = generate_ja4h(&request);
768
769 assert_eq!(result.method, "po");
770 assert_eq!(result.http_version, 20);
771 assert!(result.has_cookie);
772 assert!(!result.has_referer);
773 assert_ne!(result.cookie_hash, "000000000000");
774 }
775
776 #[test]
777 fn test_generate_ja4h_with_referer() {
778 let headers = vec![
779 header("Referer", "https://example.com"),
780 header("Accept", "text/html"),
781 ];
782 let request = HttpHeaders {
783 headers: &headers,
784 method: "GET",
785 http_version: "1.1",
786 };
787
788 let result = generate_ja4h(&request);
789
790 assert!(!result.has_cookie);
791 assert!(result.has_referer);
792 }
793
794 #[test]
795 fn test_generate_ja4h_accept_language() {
796 let headers = vec![header("Accept-Language", "en-US,en;q=0.9,fr;q=0.8")];
797 let request = HttpHeaders {
798 headers: &headers,
799 method: "GET",
800 http_version: "1.1",
801 };
802
803 let result = generate_ja4h(&request);
804 assert_eq!(result.accept_lang, "en");
805 }
806
807 #[test]
808 fn test_generate_ja4h_french_language() {
809 let headers = vec![header("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")];
810 let request = HttpHeaders {
811 headers: &headers,
812 method: "GET",
813 http_version: "1.1",
814 };
815
816 let result = generate_ja4h(&request);
817 assert_eq!(result.accept_lang, "fr");
818 }
819
820 #[test]
821 fn test_generate_ja4h_http_versions() {
822 for (version, expected) in [("1.0", 10), ("1.1", 11), ("2.0", 20), ("3.0", 30)] {
823 let headers: Vec<(HeaderName, HeaderValue)> = Vec::new();
824 let request = HttpHeaders {
825 headers: &headers,
826 method: "GET",
827 http_version: version,
828 };
829 let result = generate_ja4h(&request);
830 assert_eq!(
831 result.http_version, expected,
832 "Failed for version {}",
833 version
834 );
835 }
836 }
837
838 #[test]
839 fn test_generate_ja4h_all_methods() {
840 let methods = [
841 ("GET", "ge"),
842 ("POST", "po"),
843 ("PUT", "pu"),
844 ("DELETE", "de"),
845 ("HEAD", "he"),
846 ("OPTIONS", "op"),
847 ("PATCH", "pa"),
848 ("CONNECT", "co"),
849 ("TRACE", "tr"),
850 ];
851
852 for (method, expected) in methods {
853 let headers: Vec<(HeaderName, HeaderValue)> = Vec::new();
854 let request = HttpHeaders {
855 headers: &headers,
856 method,
857 http_version: "1.1",
858 };
859 let result = generate_ja4h(&request);
860 assert_eq!(result.method, expected, "Failed for method {}", method);
861 }
862 }
863
864 #[test]
867 fn test_extract_client_fingerprint_with_ja4() {
868 let headers = vec![header("Accept", "text/html")];
869 let request = HttpHeaders {
870 headers: &headers,
871 method: "GET",
872 http_version: "1.1",
873 };
874
875 let result =
876 extract_client_fingerprint(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"), &request);
877
878 assert!(result.ja4.is_some());
879 assert_eq!(result.combined_hash.len(), 16);
880 }
881
882 #[test]
883 fn test_extract_client_fingerprint_without_ja4() {
884 let headers = vec![header("Accept", "text/html")];
885 let request = HttpHeaders {
886 headers: &headers,
887 method: "GET",
888 http_version: "1.1",
889 };
890
891 let result = extract_client_fingerprint(None, &request);
892
893 assert!(result.ja4.is_none());
894 assert_eq!(result.combined_hash.len(), 16);
895 }
896
897 #[test]
900 fn test_sha256_first12() {
901 let result = sha256_first12("test");
902 assert_eq!(result.len(), 12);
903 assert_eq!(result, "9f86d081884c");
905 }
906
907 #[test]
908 fn test_is_valid_ja4() {
909 assert!(is_valid_ja4("t13d1516h2_8daaf6152771_e5627efa2ab1"));
910 assert!(!is_valid_ja4("invalid"));
911 assert!(!is_valid_ja4(""));
912 }
913
914 #[test]
915 fn test_is_valid_ja4h() {
916 assert!(is_valid_ja4h("ge11cnrn_a1b2c3d4e5f6_000000000000"));
917 assert!(!is_valid_ja4h("invalid"));
918 assert!(!is_valid_ja4h(""));
919 }
920
921 #[test]
922 fn test_fingerprints_match() {
923 assert!(fingerprints_match(Some("ABC"), Some("abc")));
924 assert!(fingerprints_match(Some("abc"), Some("ABC")));
925 assert!(!fingerprints_match(Some("abc"), Some("def")));
926 assert!(!fingerprints_match(None, Some("abc")));
927 assert!(!fingerprints_match(Some("abc"), None));
928 assert!(!fingerprints_match(None, None));
929 }
930
931 #[test]
932 fn test_matches_pattern() {
933 assert!(matches_pattern(
935 "t13d1516h2_8daaf6152771_e5627efa2ab1",
936 "t13*"
937 ));
938 assert!(!matches_pattern(
939 "t12d1516h2_8daaf6152771_e5627efa2ab1",
940 "t13*"
941 ));
942
943 assert!(matches_pattern(
945 "t13d1516h2_8daaf6152771_e5627efa2ab1",
946 "*_8daaf6152771_*"
947 ));
948
949 assert!(matches_pattern(
951 "t13d1516h2_8daaf6152771_e5627efa2ab1",
952 "*e5627efa2ab1"
953 ));
954 }
955
956 #[test]
959 fn test_analyze_ja4_modern_browser() {
960 let fp = Ja4Fingerprint {
961 raw: "t13d1516h2_8daaf6152771_e5627efa2ab1".to_string(),
962 protocol: Ja4Protocol::TCP,
963 tls_version: 13,
964 sni_type: Ja4SniType::Domain,
965 cipher_count: 15,
966 ext_count: 16,
967 alpn: "h2".to_string(),
968 cipher_hash: "8daaf6152771".to_string(),
969 ext_hash: "e5627efa2ab1".to_string(),
970 };
971
972 let analysis = analyze_ja4(&fp);
973 assert!(!analysis.suspicious);
974 assert_eq!(analysis.estimated_client, "modern-browser");
975 }
976
977 #[test]
978 fn test_analyze_ja4_suspicious_bot() {
979 let fp = Ja4Fingerprint {
980 raw: "t100302h1_8daaf6152771_e5627efa2ab1".to_string(),
981 protocol: Ja4Protocol::TCP,
982 tls_version: 10,
983 sni_type: Ja4SniType::None,
984 cipher_count: 3,
985 ext_count: 2,
986 alpn: "http/1.1".to_string(),
987 cipher_hash: "8daaf6152771".to_string(),
988 ext_hash: "e5627efa2ab1".to_string(),
989 };
990
991 let analysis = analyze_ja4(&fp);
992 assert!(analysis.suspicious);
993 assert!(!analysis.issues.is_empty());
994 assert_eq!(analysis.estimated_client, "bot-or-script");
995 }
996
997 #[test]
998 fn test_analyze_ja4h_normal() {
999 let fp = Ja4hFingerprint {
1000 raw: "ge11cren_a1b2c3d4e5f6_aabbccddeeff".to_string(),
1001 method: "ge".to_string(),
1002 http_version: 11,
1003 has_cookie: true,
1004 has_referer: true,
1005 accept_lang: "en".to_string(),
1006 header_hash: "a1b2c3d4e5f6".to_string(),
1007 cookie_hash: "aabbccddeeff".to_string(),
1008 };
1009
1010 let analysis = analyze_ja4h(&fp);
1011 assert!(!analysis.suspicious);
1012 assert!(analysis.issues.is_empty());
1013 }
1014
1015 #[test]
1016 fn test_analyze_ja4h_suspicious() {
1017 let fp = Ja4hFingerprint {
1018 raw: "ge10nn00_a1b2c3d4e5f6_000000000000".to_string(),
1019 method: "ge".to_string(),
1020 http_version: 10,
1021 has_cookie: false,
1022 has_referer: false,
1023 accept_lang: "00".to_string(),
1024 header_hash: "a1b2c3d4e5f6".to_string(),
1025 cookie_hash: "000000000000".to_string(),
1026 };
1027
1028 let analysis = analyze_ja4h(&fp);
1029 assert!(analysis.suspicious);
1030 assert!(analysis.issues.iter().any(|i| i.contains("HTTP/1.0")));
1031 assert!(analysis
1032 .issues
1033 .iter()
1034 .any(|i| i.contains("Accept-Language")));
1035 }
1036
1037 #[test]
1040 #[cfg_attr(not(feature = "heavy-tests"), ignore)]
1041 fn test_ja4_parsing_performance() {
1042 let input = "t13d1516h2_8daaf6152771_e5627efa2ab1";
1044 let iterations = test_limit_usize("SYNAPSE_TEST_PERF_ITERATIONS", 10_000, 100);
1045 eprintln!("test_ja4_parsing_performance: iterations={}", iterations);
1046 let start = std::time::Instant::now();
1047
1048 for _ in 0..iterations {
1049 let _ = parse_ja4_from_header(Some(input));
1050 }
1051
1052 let elapsed = start.elapsed();
1053 assert!(
1056 elapsed.as_millis() < 500,
1057 "JA4 parsing too slow: {:?}",
1058 elapsed
1059 );
1060 }
1061
1062 #[test]
1063 #[cfg_attr(not(feature = "heavy-tests"), ignore)]
1064 fn test_ja4h_generation_performance() {
1065 let headers = vec![
1066 header("Accept", "text/html"),
1067 header("User-Agent", "Mozilla/5.0"),
1068 header("Accept-Language", "en-US"),
1069 header("Cookie", "session=abc; user=test"),
1070 ];
1071 let request = HttpHeaders {
1072 headers: &headers,
1073 method: "GET",
1074 http_version: "1.1",
1075 };
1076
1077 let start = std::time::Instant::now();
1078
1079 let iterations = test_limit_usize("SYNAPSE_TEST_PERF_ITERATIONS", 10_000, 100);
1080 eprintln!(
1081 "test_ja4h_generation_performance: iterations={}",
1082 iterations
1083 );
1084
1085 for _ in 0..iterations {
1086 let _ = generate_ja4h(&request);
1087 }
1088
1089 let elapsed = start.elapsed();
1090 #[cfg(debug_assertions)]
1093 let max_time_ms = 1000;
1094 #[cfg(not(debug_assertions))]
1095 let max_time_ms = 200;
1096
1097 assert!(
1098 elapsed.as_millis() < max_time_ms,
1099 "JA4H generation too slow: {:?}",
1100 elapsed
1101 );
1102 }
1103}