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
31pub 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
78fn 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
109fn 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
122pub 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()); }
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}