Skip to main content

har/analysis/
jwt.rs

1use crate::filter::Filter;
2use crate::jwt::{JwtSummary, decode_jwt, summarize, token_hash};
3use crate::model::{Capture, Entry};
4use ahash::AHashMap;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct JwtResult {
9    pub tokens: Vec<JwtOccurrence>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct JwtOccurrence {
14    pub token_hash: String,
15    pub source: String,
16    pub summary: JwtSummary,
17    pub occurrences: usize,
18    pub first_entry_id: String,
19    pub last_entry_id: String,
20    pub raw_token: Option<String>,
21}
22
23struct Acc {
24    source: String,
25    first_id: String,
26    last_id: String,
27    count: usize,
28    ref_ms: Option<i64>,
29}
30
31/// Find and decode JWTs across headers, query, cookies, and bodies.
32pub fn compute_jwt(cap: &Capture, filter: &Filter, top: usize, unsafe_include: bool) -> JwtResult {
33    let mut map: AHashMap<String, Acc> = AHashMap::new();
34
35    for e in cap.entries.iter().filter(|e| filter.matches(e)) {
36        let ref_ms = cap.meta.start_ms.map(|s| s + e.started_offset_ms as i64);
37        for (token, source) in scan_entry(e) {
38            let acc = map.entry(token).or_insert_with(|| Acc {
39                source,
40                first_id: e.id.clone(),
41                last_id: e.id.clone(),
42                count: 0,
43                ref_ms,
44            });
45            acc.count += 1;
46            acc.last_id = e.id.clone();
47        }
48    }
49
50    let mut tokens: Vec<JwtOccurrence> = map
51        .into_iter()
52        .filter_map(|(token, acc)| {
53            let parts = decode_jwt(&token)?;
54            let summary = summarize(&parts, acc.ref_ms);
55            Some(JwtOccurrence {
56                token_hash: token_hash(&token),
57                source: acc.source,
58                summary,
59                occurrences: acc.count,
60                first_entry_id: acc.first_id,
61                last_entry_id: acc.last_id,
62                raw_token: if unsafe_include { Some(token) } else { None },
63            })
64        })
65        .collect();
66
67    tokens.sort_by(|a, b| {
68        let ax = a.summary.expired == Some(true);
69        let bx = b.summary.expired == Some(true);
70        bx.cmp(&ax)
71            .then(b.occurrences.cmp(&a.occurrences))
72            .then(a.token_hash.cmp(&b.token_hash))
73    });
74    tokens.truncate(top);
75    JwtResult { tokens }
76}
77
78/// Scan an entry's headers, query, and bodies for JWTs; returns (token, source).
79fn scan_entry(e: &Entry) -> Vec<(String, String)> {
80    let mut found = Vec::new();
81    for (n, v) in &e.req_headers {
82        for t in scan_jwts(v) {
83            found.push((t, format!("req.header.{}", n.to_ascii_lowercase())));
84        }
85    }
86    for (n, v) in &e.resp_headers {
87        for t in scan_jwts(v) {
88            found.push((t, format!("resp.header.{}", n.to_ascii_lowercase())));
89        }
90    }
91    for (k, v) in &e.query {
92        for t in scan_jwts(v) {
93            found.push((t, format!("query.{k}")));
94        }
95    }
96    if let Some(b) = &e.req_body {
97        for t in scan_jwts(b) {
98            found.push((t, "req.body".to_string()));
99        }
100    }
101    if let Some(b) = &e.resp_body {
102        for t in scan_jwts(b) {
103            found.push((t, "resp.body".to_string()));
104        }
105    }
106    found
107}
108
109/// Extract decodable JWT substrings from free text (tokenize on non-JWT chars).
110fn scan_jwts(text: &str) -> Vec<String> {
111    let mut out = Vec::new();
112    for cand in
113        text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'))
114    {
115        if cand.matches('.').count() == 2 && cand.len() >= 20 && decode_jwt(cand).is_some() {
116            out.push(cand.to_string());
117        }
118    }
119    out
120}
121
122/// Render JWTs as deterministic terminal text.
123pub fn render_jwt_text(r: &JwtResult) -> String {
124    let mut out = String::new();
125    out.push_str("== wiretrail jwt ==\n");
126    for t in &r.tokens {
127        let exp = match t.summary.expired {
128            Some(true) => " [EXPIRED]",
129            _ => "",
130        };
131        out.push_str(&format!(
132            "\n{} ({}x, {}){}\n",
133            t.token_hash, t.occurrences, t.source, exp
134        ));
135        if let Some(iss) = &t.summary.iss {
136            out.push_str(&format!("  iss: {iss}\n"));
137        }
138        if let Some(aud) = &t.summary.aud {
139            out.push_str(&format!("  aud: {aud}\n"));
140        }
141        if let Some(sub) = &t.summary.sub_hash {
142            out.push_str(&format!("  sub (hashed): {sub}\n"));
143        }
144        if let Some(exp) = t.summary.exp {
145            out.push_str(&format!(
146                "  exp: {} ({})\n",
147                exp,
148                match t.summary.seconds_to_expiry {
149                    Some(s) if s < 0 => format!("expired {}s ago", -s),
150                    Some(s) => format!("{s}s left"),
151                    None => "unknown".to_string(),
152                }
153            ));
154        }
155        if let Some(hint) = &t.summary.clock_skew_hint {
156            out.push_str(&format!("  warning: {hint}\n"));
157        }
158    }
159    out
160}
161
162#[cfg(test)]
163mod tests {
164    use super::compute_jwt;
165    use crate::filter::Filter;
166    use crate::model::{Entry, sample_capture, sample_entry};
167
168    const SAMPLE: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
169
170    fn with_bearer(index: usize) -> Entry {
171        let mut e = sample_entry(index, "api.x", "GET", "/me", 200);
172        e.req_headers = vec![("Authorization".to_string(), format!("Bearer {SAMPLE}"))];
173        e
174    }
175
176    #[test]
177    fn finds_and_decodes_bearer_jwt() {
178        let cap = sample_capture(vec![with_bearer(0), with_bearer(1)]);
179        let r = compute_jwt(&cap, &Filter::parse(&[]).unwrap(), 10, false);
180        assert_eq!(r.tokens.len(), 1);
181        let t = &r.tokens[0];
182        assert_eq!(t.occurrences, 2);
183        assert_eq!(t.source, "req.header.authorization");
184        assert_eq!(t.summary.iat, Some(1516239022));
185        assert!(t.raw_token.is_none()); // redacted by default
186    }
187
188    #[test]
189    fn unsafe_includes_raw_token() {
190        let cap = sample_capture(vec![with_bearer(0)]);
191        let r = compute_jwt(&cap, &Filter::parse(&[]).unwrap(), 10, true);
192        assert_eq!(r.tokens[0].raw_token.as_deref(), Some(SAMPLE));
193    }
194
195    #[test]
196    fn finds_jwt_in_body() {
197        let mut e = sample_entry(0, "api.x", "POST", "/login", 200);
198        e.resp_body = Some(format!(r#"{{"access_token":"{SAMPLE}"}}"#));
199        let r = compute_jwt(
200            &sample_capture(vec![e]),
201            &Filter::parse(&[]).unwrap(),
202            10,
203            false,
204        );
205        assert_eq!(r.tokens.len(), 1);
206        assert_eq!(r.tokens[0].source, "resp.body");
207    }
208}