keyhog_scanner/jwt.rs
1//! JWT structural validation.
2//!
3//! A bare JWT regex (three base64url segments separated by dots) catches an
4//! enormous number of false positives - Etag headers, hash digests, opaque
5//! session IDs, tracking pixels, etc. This module decodes the header +
6//! payload and validates the JWT shape (`alg`/`typ`/`exp`) so we can:
7//!
8//! 1. Boost confidence on credentials that ARE real JWTs (correctly
9//! structured header + valid algorithm).
10//! 2. Suppress credentials that LOOK like JWTs but aren't (random base64,
11//! malformed header).
12//! 3. Surface metadata: `alg`, `iss`, `sub`, `aud`, `exp` as evidence in
13//! the finding output, helping responders rotate the right credential.
14//! 4. Flag `alg=none` JWTs as a SECURITY ANOMALY - these are unsigned,
15//! forgeable, and almost always indicate a misconfiguration or active
16//! attack.
17
18#![deny(unsafe_code)]
19
20use serde::Deserialize;
21use std::collections::BTreeMap;
22
23/// Result of a JWT structural check.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct JwtAnalysis {
26 /// Header `alg` field (e.g. `RS256`, `HS256`, `none`).
27 pub alg: String,
28 /// Header `typ` field when present (typically `JWT` or `at+jwt`).
29 pub typ: Option<String>,
30 /// Header `kid` field - useful for key-rotation forensics.
31 pub kid: Option<String>,
32 /// Payload `iss` claim - surfaces the issuer service.
33 pub iss: Option<String>,
34 /// Payload `sub` claim - subject (user/service identifier).
35 pub sub: Option<String>,
36 /// Payload `aud` claim - single audience or comma-joined list.
37 pub aud: Option<String>,
38 /// Payload `exp` claim, if numeric.
39 pub exp: Option<i64>,
40 /// Whether the JWT has expired relative to wall-clock time
41 /// (`SystemTime::now` as Unix epoch seconds, compared against `exp`).
42 pub expired: Option<bool>,
43 /// Anomalies detected during analysis. Non-empty implies a suspicious
44 /// JWT that warrants higher reporting severity.
45 pub anomalies: Vec<JwtAnomaly>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum JwtAnomaly {
51 /// `alg = "none"` - unsigned token. Should never appear in production
52 /// credentials; almost always a misconfiguration or active forgery
53 /// attack. RFC 7519 §6 calls this out as risky.
54 AlgNone,
55 /// Algorithm not on the standard registry list. Legitimate JWTs use a
56 /// well-known algorithm (RS256, HS256, ES256, …); custom values are
57 /// rare and frequently indicate fake / handcrafted tokens.
58 UnknownAlg(String),
59 /// `typ` present but not in the standard set (`JWT`, `at+jwt`, `id+jwt`,
60 /// `dpop+jwt`, `logout+jwt`).
61 NonStandardTyp(String),
62 /// Token already expired.
63 Expired,
64}
65
66/// Render anomalies into a `metadata` map suitable for SARIF properties or
67/// the text reporter. Returns `None` when there are no anomalies.
68pub(crate) fn anomalies_to_metadata(analysis: &JwtAnalysis) -> Option<BTreeMap<String, String>> {
69 if analysis.anomalies.is_empty() {
70 return None;
71 }
72 let mut out = BTreeMap::new();
73 for anomaly in &analysis.anomalies {
74 match anomaly {
75 JwtAnomaly::AlgNone => {
76 out.insert(
77 "jwt.alg_none".to_string(),
78 "true (unsigned token: RFC 7519 §6 risk)".to_string(),
79 );
80 }
81 JwtAnomaly::UnknownAlg(alg) => {
82 out.insert("jwt.unknown_alg".to_string(), alg.clone());
83 }
84 JwtAnomaly::NonStandardTyp(typ) => {
85 out.insert("jwt.non_standard_typ".to_string(), typ.clone());
86 }
87 JwtAnomaly::Expired => {
88 out.insert("jwt.expired".to_string(), "true".to_string());
89 }
90 }
91 }
92 Some(out)
93}
94
95/// Wire the structural analysis of `credential` into a finding's `metadata`
96/// map. Returns `None` when `credential` is not a parseable JWT (so non-JWT
97/// findings carry no JWT keys); otherwise returns the claim evidence the
98/// module doc promises - `jwt.alg`, and any of `jwt.iss` / `jwt.sub` /
99/// `jwt.aud` / `jwt.exp` that are present - PLUS every anomaly key from
100/// [`anomalies_to_metadata`] (notably `jwt.alg_none` for an unsigned forgery).
101///
102/// This is the single, shared bridge between the fully-built [`analyze`] and
103/// the scan output: the in-process finalize, the verify skip branch, and the
104/// daemon-route finalize all call it, so the JWT evidence reaches the operator
105/// regardless of route (no `jwt.alg_none` divergence between in-process and
106/// daemon). The keys use a `String`/`String` shape so a `VerifiedFinding`'s
107/// `HashMap<String, String>` metadata can absorb them directly.
108/// Default redacted claims (KH-1350). Prefer [`finding_metadata_with_secrets`]
109/// when `--show-secrets` is set so iss/sub/aud are revealed (KH-1458).
110pub fn finding_metadata(credential: &str) -> Option<std::collections::HashMap<String, String>> {
111 finding_metadata_with_secrets(credential, false)
112}
113
114/// JWT finding metadata. When `show_secrets` is false (default), iss/sub/aud
115/// are length-redacted (KH-1350). When true, claim values are included so an
116/// operator who already opted into plaintext credentials can inspect issuer
117/// and subject without re-decoding the JWT (KH-1458).
118pub fn finding_metadata_with_secrets(
119 credential: &str,
120 show_secrets: bool,
121) -> Option<std::collections::HashMap<String, String>> {
122 let analysis = analyze(credential)?;
123 // At most eight keys: jwt.alg + up to four claim keys (iss/sub/aud/exp) +
124 // up to three anomaly keys (one alg anomaly, non_standard_typ, expired).
125 // Reserve up front so this per-finding map never rehashes. Byte-identical
126 // output (capacity does not affect HashMap contents or equality).
127 let mut meta = std::collections::HashMap::with_capacity(8);
128
129 // The algorithm is the primary structural evidence and is always present
130 // (`analyze` substitutes `<missing>` when the header omits it), so surface
131 // it unconditionally for any real JWT.
132 meta.insert("jwt.alg".to_string(), analysis.alg.clone());
133 // KH-1350 / KH-1458: redact iss/sub/aud by default; reveal under show_secrets.
134 if let Some(iss) = &analysis.iss {
135 meta.insert(
136 "jwt.iss".to_string(),
137 if show_secrets {
138 iss.clone()
139 } else {
140 redact_jwt_claim(iss)
141 },
142 );
143 }
144 if let Some(sub) = &analysis.sub {
145 meta.insert(
146 "jwt.sub".to_string(),
147 if show_secrets {
148 sub.clone()
149 } else {
150 redact_jwt_claim(sub)
151 },
152 );
153 }
154 if let Some(aud) = &analysis.aud {
155 meta.insert(
156 "jwt.aud".to_string(),
157 if show_secrets {
158 aud.clone()
159 } else {
160 redact_jwt_claim(aud)
161 },
162 );
163 }
164 if let Some(exp) = analysis.exp {
165 meta.insert("jwt.exp".to_string(), exp.to_string());
166 }
167
168 // Anomaly keys (jwt.alg_none / jwt.unknown_alg / jwt.non_standard_typ /
169 // jwt.expired). The dedicated `alg=none` key is the load-bearing security
170 // signal: an unsigned, trivially forgeable token.
171 if let Some(anomalies) = anomalies_to_metadata(&analysis) {
172 for (k, v) in anomalies {
173 meta.insert(k, v);
174 }
175 }
176
177 Some(meta)
178}
179
180/// Redact a JWT claim for report metadata: keep length, drop the value.
181fn redact_jwt_claim(value: &str) -> String {
182 format!("<redacted {} chars>", value.chars().count())
183}
184
185/// Returns `true` when `s` looks like a JWT (three base64url segments).
186/// Cheap shape check - does NOT decode.
187pub(crate) fn looks_like_jwt(s: &str) -> bool {
188 jwt_segments(s).is_some()
189}
190
191/// The base64url encoding of a JWT header's opening `{"`: every JWT/JWS begins
192/// `eyJ…` because the header JSON starts `{"alg"…`. SINGLE OWNER of this marker:
193/// it is the load-bearing prefix of the `jwt-token` (and every JWT-shaped vendor)
194/// detector pattern, and scanner logic keys off it in the entropy plausibility
195/// gate and the canonical-shape suppression check, those were three bare `"eyJ"`
196/// literals free to drift and are now this const, bound to the jwt-token detector
197/// by a guard test.
198pub(crate) const JWT_BASE64_HEADER_PREFIX: &str = "eyJ";
199
200/// True when `s` opens with the JWT/JWS base64url header marker (`eyJ`). This is
201/// only the cheap PREFIX check; callers needing full JWT validation add their own
202/// segment/dot conditions (or use the structural [`jwt_segments`]).
203pub(crate) fn has_jwt_header_prefix(s: &str) -> bool {
204 s.starts_with(JWT_BASE64_HEADER_PREFIX)
205}
206
207fn jwt_segments(s: &str) -> Option<(&str, &str, &str)> {
208 let s = s.trim();
209 const MAX_JWT_SEGMENT_LEN: usize = 16 * 1024; // 16KB limit per segment
210
211 let mut parts = s.split('.');
212 let (Some(h), Some(p), Some(sig), None) =
213 (parts.next(), parts.next(), parts.next(), parts.next())
214 else {
215 return None;
216 };
217
218 // Length gate to prevent quadratic DoS on pathological inputs (millions of dots)
219 if h.len() > MAX_JWT_SEGMENT_LEN
220 || p.len() > MAX_JWT_SEGMENT_LEN
221 || sig.len() > MAX_JWT_SEGMENT_LEN
222 {
223 return None;
224 }
225
226 if !h.is_empty()
227 && !p.is_empty()
228 && !sig.is_empty()
229 && h.bytes().all(is_base64url_byte)
230 && p.bytes().all(is_base64url_byte)
231 && sig.bytes().all(is_base64url_byte)
232 {
233 Some((h, p, sig))
234 } else {
235 None
236 }
237}
238
239/// Full structural analysis. Returns `None` if `s` is not a parseable JWT
240/// (missing dots, non-base64url header/payload, malformed JSON inside).
241///
242/// Signature verification is intentionally NOT performed - that requires
243/// the issuer's public key, which we don't have. Structural validation is
244/// the high-recall layer; the verifier crate handles cryptographic checks
245/// for services that expose them.
246pub(crate) fn analyze(s: &str) -> Option<JwtAnalysis> {
247 let (header_b64, payload_b64, _signature_b64) = jwt_segments(s)?;
248 // We don't read the signature segment beyond the shape check.
249
250 let header_json = decode_b64url(header_b64)?;
251 let payload_json = decode_b64url(payload_b64)?;
252
253 if !check_nesting_depth(&header_json, 15) || !check_nesting_depth(&payload_json, 15) {
254 return None;
255 }
256
257 let header: JwtHeader = serde_json::from_slice(&header_json).ok()?; // LAW10: malformed input => None (fail-closed at the boundary; not a valid value), recall-safe
258 let mut payload: JwtPayload = serde_json::from_slice(&payload_json).ok()?; // LAW10: malformed input => None (fail-closed at the boundary; not a valid value), recall-safe
259 let aud = payload.take_aud();
260 let iss = payload.iss.take();
261 let sub = payload.sub.take();
262
263 let mut anomalies = Vec::new();
264
265 let alg = header.alg.unwrap_or_else(|| "<missing>".to_string()); // LAW10: absent path/field => display placeholder; reporting-only, recall-safe
266 if alg.eq_ignore_ascii_case("none") {
267 anomalies.push(JwtAnomaly::AlgNone);
268 } else if !is_known_alg(&alg) {
269 anomalies.push(JwtAnomaly::UnknownAlg(alg.clone()));
270 }
271
272 if let Some(typ) = header.typ.as_deref() {
273 if !is_standard_typ(typ) {
274 anomalies.push(JwtAnomaly::NonStandardTyp(typ.to_string()));
275 }
276 }
277
278 let exp = payload.exp.take().and_then(json_i64);
279
280 let expired = exp.map(|exp_val| {
281 let now = std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .map(|d| d.as_secs() as i64)
284 .unwrap_or(0); // LAW10: empty/absent => documented numeric/sentinel default, recall-safe
285 let is_expired = now >= exp_val;
286 if is_expired {
287 anomalies.push(JwtAnomaly::Expired);
288 }
289 is_expired
290 });
291
292 Some(JwtAnalysis {
293 alg,
294 typ: header.typ,
295 kid: header.kid,
296 iss,
297 sub,
298 aud,
299 exp,
300 expired,
301 anomalies,
302 })
303}
304
305fn json_i64(value: serde_json::Value) -> Option<i64> {
306 match value {
307 serde_json::Value::Number(number) => number.as_i64(),
308 _ => None,
309 }
310}
311
312#[inline]
313fn is_base64url_byte(b: u8) -> bool {
314 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'='
315}
316
317fn decode_b64url(s: &str) -> Option<Vec<u8>> {
318 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
319 use base64::Engine;
320 // Strip any padding the input might have (base64url is unpadded by spec).
321 let trimmed = s.trim_end_matches('=');
322 URL_SAFE_NO_PAD.decode(trimmed).ok() // LAW10: malformed input => None (fail-closed at the boundary; not a valid value), recall-safe
323}
324
325fn is_known_alg(alg: &str) -> bool {
326 matches!(
327 alg,
328 "RS256"
329 | "RS384"
330 | "RS512"
331 | "HS256"
332 | "HS384"
333 | "HS512"
334 | "ES256"
335 | "ES384"
336 | "ES512"
337 | "ES256K"
338 | "PS256"
339 | "PS384"
340 | "PS512"
341 | "EdDSA"
342 )
343}
344
345fn is_standard_typ(typ: &str) -> bool {
346 matches!(typ, "JWT" | "at+jwt" | "id+jwt" | "dpop+jwt" | "logout+jwt")
347}
348
349#[derive(Deserialize)]
350struct JwtHeader {
351 alg: Option<String>,
352 typ: Option<String>,
353 kid: Option<String>,
354}
355
356#[derive(Deserialize)]
357struct JwtPayload {
358 iss: Option<String>,
359 sub: Option<String>,
360 #[serde(default)]
361 aud: serde_json::Value,
362 exp: Option<serde_json::Value>,
363}
364
365impl JwtPayload {
366 fn take_aud(&mut self) -> Option<String> {
367 match std::mem::take(&mut self.aud) {
368 serde_json::Value::String(s) if !s.is_empty() => Some(s),
369 serde_json::Value::Array(items) if !items.is_empty() => join_audience_strings(items),
370 _ => None,
371 }
372 }
373}
374
375fn join_audience_strings(items: Vec<serde_json::Value>) -> Option<String> {
376 let mut strings = items.into_iter().filter_map(|value| match value {
377 serde_json::Value::String(value) => Some(value),
378 _ => None,
379 });
380 let mut joined = strings.next()?;
381 for audience in strings {
382 joined.push(',');
383 joined.push_str(&audience);
384 }
385 Some(joined)
386}
387
388fn check_nesting_depth(json: &[u8], max_depth: usize) -> bool {
389 let mut depth = 0;
390 let mut in_string = false;
391 let mut escaped = false;
392 for &b in json {
393 if escaped {
394 escaped = false;
395 continue;
396 }
397 if b == b'\\' {
398 if in_string {
399 escaped = true;
400 }
401 continue;
402 }
403 if b == b'"' {
404 in_string = !in_string;
405 continue;
406 }
407 if !in_string {
408 if b == b'{' || b == b'[' {
409 depth += 1;
410 if depth > max_depth {
411 return false;
412 }
413 } else if b == b'}' || b == b']' {
414 depth = depth.saturating_sub(1);
415 }
416 }
417 }
418 true
419}