Skip to main content

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}