Skip to main content

leyline/audit/
ja4h.rs

1use std::fmt;
2
3use crate::audit::hash12;
4use crate::trace::masked;
5
6pub struct Ja4hInput<'a> {
7    pub method: &'a str,
8    pub http_version: &'a str,
9    pub headers: &'a [(String, String)],
10}
11
12impl fmt::Debug for Ja4hInput<'_> {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        f.debug_struct("Ja4hInput")
15            .field("method", &self.method)
16            .field("http_version", &self.http_version)
17            .field(
18                "headers",
19                &masked(self.headers.iter().map(|(k, v)| (k.as_str(), v.as_bytes()))),
20            )
21            .finish()
22    }
23}
24
25pub fn compute_ja4h(input: &Ja4hInput<'_>) -> String {
26    let a = section_a(input);
27    let b = section_b(input);
28    let c = section_c(input);
29    let d = section_d(input);
30    format!("{a}_{b}_{c}_{d}")
31}
32
33fn section_a(input: &Ja4hInput<'_>) -> String {
34    let method = &input.method.to_lowercase();
35    let method2 = if method.len() >= 2 {
36        &method[..2]
37    } else {
38        method.as_str()
39    };
40
41    let version = match input.http_version {
42        "1.0" => "10",
43        "1.1" => "11",
44        "2" | "2.0" => "20",
45        "3" | "3.0" => "30",
46        _ => "00",
47    };
48
49    let has_cookie = input.headers.iter().any(|(n, _)| n == "cookie");
50    let has_referer = input.headers.iter().any(|(n, _)| n == "referer");
51    let cookie_flag = if has_cookie { "c" } else { "n" };
52    let referer_flag = if has_referer { "r" } else { "n" };
53
54    let count = input
55        .headers
56        .iter()
57        .filter(|(n, _)| n != "cookie" && n != "referer" && !n.starts_with(':'))
58        .count()
59        .min(99);
60
61    let lang = input
62        .headers
63        .iter()
64        .find(|(n, _)| n == "accept-language")
65        .map(|(_, v)| {
66            let first_tag = v.split(',').next().unwrap_or("");
67            let first_tag = first_tag.split(';').next().unwrap_or("").trim();
68            let cleaned: String = first_tag.chars().filter(|c| *c != '-').collect();
69            let mut s = cleaned;
70            while s.len() < 4 {
71                s.push('0');
72            }
73            s[..4].to_string()
74        })
75        .unwrap_or_else(|| "0000".to_string());
76
77    format!("{method2}{version}{cookie_flag}{referer_flag}{count:02}{lang}")
78}
79
80fn section_b(input: &Ja4hInput<'_>) -> String {
81    let mut names: Vec<&str> = input
82        .headers
83        .iter()
84        .map(|(n, _)| n.as_str())
85        .filter(|n| *n != "cookie" && *n != "referer" && !n.starts_with(':'))
86        .collect();
87    names.sort();
88    let s = names.join(",");
89    hash12(&s)
90}
91
92fn section_c(input: &Ja4hInput<'_>) -> String {
93    let cookie_val = input
94        .headers
95        .iter()
96        .find(|(n, _)| n == "cookie")
97        .map(|(_, v)| v.as_str());
98
99    match cookie_val {
100        Some(val) => {
101            let mut names: Vec<&str> = val
102                .split(';')
103                .filter_map(|pair| {
104                    let pair = pair.trim();
105                    pair.split('=').next().map(|n| n.trim())
106                })
107                .filter(|n| !n.is_empty())
108                .collect();
109            names.sort();
110            hash12(&names.join(","))
111        }
112        None => "000000000000".to_string(),
113    }
114}
115
116fn section_d(input: &Ja4hInput<'_>) -> String {
117    let cookie_val = input
118        .headers
119        .iter()
120        .find(|(n, _)| n == "cookie")
121        .map(|(_, v)| v.as_str());
122
123    match cookie_val {
124        Some(val) => {
125            let mut pairs: Vec<&str> = val
126                .split(';')
127                .map(|p| p.trim())
128                .filter(|p| !p.is_empty())
129                .collect();
130            pairs.sort_by_key(|p| p.split('=').next().unwrap_or(""));
131            hash12(&pairs.join(","))
132        }
133        None => "000000000000".to_string(),
134    }
135}
136
137#[cfg(test)]
138mod tests;