Skip to main content

leyline/audit/
compare.rs

1use serde::Deserialize;
2
3use crate::audit::AuditData;
4use crate::core::{Error, Result};
5
6mod headers;
7
8pub use headers::HeaderOutcome;
9
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct Observed {
13    pub ja4: Option<String>,
14    pub ja3: Option<String>,
15    pub h2_fingerprint: Option<String>,
16    pub headers: Vec<(String, String)>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum FieldOutcome {
22    Match,
23    Mismatch { expected: String, observed: String },
24    Informational { expected: String, observed: String },
25    NotReported,
26    Absent { expected: String },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct FingerprintReport {
32    pub ja4: FieldOutcome,
33    pub ja3: FieldOutcome,
34    pub h2_fingerprint: FieldOutcome,
35    pub headers: Vec<HeaderOutcome>,
36    pub header_order: FieldOutcome,
37}
38
39#[derive(Deserialize, Default)]
40struct EchoAll {
41    #[serde(default)]
42    tls: EchoTls,
43    #[serde(default)]
44    http2: EchoHttp2,
45    #[serde(default)]
46    http1: headers::EchoHttp1,
47}
48
49#[derive(Deserialize, Default)]
50struct EchoTls {
51    ja4: Option<String>,
52    ja3_hash: Option<String>,
53}
54
55#[derive(Deserialize, Default)]
56struct EchoHttp2 {
57    akamai_fingerprint: Option<String>,
58    #[serde(default)]
59    sent_frames: Vec<headers::EchoFrame>,
60}
61
62impl Observed {
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    pub fn ja4(mut self, value: impl Into<String>) -> Self {
68        self.ja4 = Some(value.into());
69        self
70    }
71
72    pub fn ja3(mut self, value: impl Into<String>) -> Self {
73        self.ja3 = Some(value.into());
74        self
75    }
76
77    pub fn h2_fingerprint(mut self, value: impl Into<String>) -> Self {
78        self.h2_fingerprint = Some(value.into());
79        self
80    }
81
82    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
83        self.headers.push((name.to_ascii_lowercase(), value.into()));
84        self
85    }
86
87    pub fn from_json(text: &str) -> Result<Observed> {
88        let echo: EchoAll = serde_json::from_str(text).map_err(Error::from_json)?;
89        Ok(Observed {
90            headers: headers::echoed(&echo.http2.sent_frames, &echo.http1),
91            ja4: echo.tls.ja4,
92            ja3: echo.tls.ja3_hash,
93            h2_fingerprint: echo.http2.akamai_fingerprint,
94        })
95    }
96}
97
98impl FieldOutcome {
99    pub fn is_mismatch(&self) -> bool {
100        matches!(
101            self,
102            FieldOutcome::Mismatch { .. } | FieldOutcome::Absent { .. }
103        )
104    }
105}
106
107impl FingerprintReport {
108    pub fn is_match(&self) -> bool {
109        [
110            &self.ja4,
111            &self.ja3,
112            &self.h2_fingerprint,
113            &self.header_order,
114        ]
115        .iter()
116        .all(|outcome| !outcome.is_mismatch())
117            && self.headers.iter().all(|h| !h.outcome.is_mismatch())
118    }
119}
120
121impl AuditData {
122    pub fn compare(&self, observed: &Observed) -> FingerprintReport {
123        FingerprintReport {
124            ja4: outcome(&self.ja4, observed.ja4.as_deref(), false),
125            ja3: outcome(&self.ja3, observed.ja3.as_deref(), self.permutes_extensions),
126            h2_fingerprint: outcome(
127                &self.h2_fingerprint,
128                observed.h2_fingerprint.as_deref(),
129                false,
130            ),
131            headers: headers::compare(&self.request_headers, &observed.headers),
132            header_order: headers::order(&self.request_headers, &observed.headers),
133        }
134    }
135}
136
137fn outcome(expected: &str, observed: Option<&str>, order_varies: bool) -> FieldOutcome {
138    let Some(observed) = observed else {
139        return FieldOutcome::NotReported;
140    };
141    if expected.eq_ignore_ascii_case(observed.trim()) {
142        return FieldOutcome::Match;
143    }
144    let expected = expected.to_owned();
145    let observed = observed.to_owned();
146    if order_varies {
147        FieldOutcome::Informational { expected, observed }
148    } else {
149        FieldOutcome::Mismatch { expected, observed }
150    }
151}
152
153impl std::fmt::Display for FieldOutcome {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        match self {
156            FieldOutcome::Match => f.write_str("match"),
157            FieldOutcome::Mismatch { expected, observed } => {
158                write!(f, "mismatch (expected {expected}, observed {observed})")
159            }
160            FieldOutcome::Informational { expected, observed } => {
161                write!(
162                    f,
163                    "informational (expected {expected}, observed {observed})"
164                )
165            }
166            FieldOutcome::NotReported => f.write_str("not reported"),
167            FieldOutcome::Absent { expected } => write!(f, "absent (expected {expected})"),
168        }
169    }
170}
171
172impl std::fmt::Display for FingerprintReport {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        writeln!(f, "ja4: {}", self.ja4)?;
175        writeln!(f, "ja3: {}", self.ja3)?;
176        write!(f, "h2: {}", self.h2_fingerprint)?;
177        write!(f, "\nheader order: {}", self.header_order)?;
178        for header in &self.headers {
179            write!(f, "\nheader {}: {}", header.name, header.outcome)?;
180        }
181        Ok(())
182    }
183}