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