1use std::collections::HashSet;
6
7use async_trait::async_trait;
8use base64::engine::general_purpose::{STANDARD as BASE64_STD, URL_SAFE_NO_PAD};
9use base64::Engine;
10use chrono::Utc;
11use hmac::{Hmac, Mac};
12use once_cell::sync::Lazy;
13use regex::Regex;
14use serde_json::Value;
15use sha2::Sha256;
16use url::Url;
17
18use crate::{
19 config::Config,
20 error::CapturedError,
21 http_client::HttpClient,
22 reports::{Finding, Severity},
23};
24
25use super::{common::http_utils::is_json_content_type, Scanner};
26
27type HmacSha256 = Hmac<Sha256>;
28
29static JWT_RE: Lazy<Regex> =
30 Lazy::new(|| Regex::new(r"eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+").unwrap());
31
32static SENSITIVE_CLAIMS: &[&str] = &[
33 "email",
34 "role",
35 "roles",
36 "is_admin",
37 "admin",
38 "permissions",
39 "scope",
40];
41
42const LONG_LIVED_SECS: i64 = 60 * 60 * 24 * 30; static WEAK_SECRET_LIST: Lazy<Vec<String>> = Lazy::new(|| {
45 include_str!("../../assets/jwt_secrets.txt")
46 .lines()
47 .map(|s| s.trim())
48 .filter(|s| !s.is_empty())
49 .map(|s| s.to_string())
50 .collect()
51});
52
53pub struct JwtScanner;
54
55impl JwtScanner {
56 pub fn new(_config: &Config) -> Self {
57 Self
58 }
59}
60
61#[async_trait]
62impl Scanner for JwtScanner {
63 fn name(&self) -> &'static str {
64 "jwt"
65 }
66
67 async fn scan(
68 &self,
69 url: &str,
70 client: &HttpClient,
71 config: &Config,
72 ) -> (Vec<Finding>, Vec<CapturedError>) {
73 let mut findings = Vec::new();
74 let mut errors = Vec::new();
75
76 let resp = match client.get(url).await {
77 Ok(r) => r,
78 Err(e) => {
79 errors.push(e);
80 return (findings, errors);
81 }
82 };
83 let baseline_status = resp.status;
84
85 let mut seen = HashSet::new();
86
87 for (header_name, header_value) in &resp.headers {
89 if matches!(
90 header_name.as_str(),
91 "set-cookie" | "authorization" | "x-auth-token" | "x-access-token" | "x-id-token"
92 ) {
93 for m in JWT_RE.find_iter(header_value) {
94 let token = m.as_str().to_string();
95 if seen.insert(token.clone()) {
96 analyze_jwt(
97 url,
98 &token,
99 client,
100 config,
101 baseline_status,
102 &mut findings,
103 &mut errors,
104 )
105 .await;
106 }
107 }
108 }
109 }
110
111 let ct = resp
112 .headers
113 .get("content-type")
114 .map(|s| s.as_str())
115 .unwrap_or("");
116 let scannable = ct.is_empty()
117 || is_json_content_type(ct)
118 || ct.contains("text/")
119 || ct.contains("javascript")
120 || ct.contains("xml");
121 if !scannable {
122 return (findings, errors);
123 }
124
125 for m in JWT_RE.find_iter(&resp.body) {
126 let token = m.as_str().to_string();
127 if !seen.insert(token.clone()) {
128 continue;
129 }
130
131 analyze_jwt(
132 url,
133 &token,
134 client,
135 config,
136 baseline_status,
137 &mut findings,
138 &mut errors,
139 )
140 .await;
141 }
142
143 (findings, errors)
144 }
145}
146
147async fn analyze_jwt(
148 url: &str,
149 token: &str,
150 client: &HttpClient,
151 config: &Config,
152 baseline_status: u16,
153 findings: &mut Vec<Finding>,
154 errors: &mut Vec<CapturedError>,
155) {
156 let parts: Vec<&str> = token.split('.').collect();
157 if parts.len() != 3 {
158 return;
159 }
160
161 let header = match decode_json(parts[0]) {
162 Some(v) => v,
163 None => {
164 errors.push(CapturedError::from_str(
165 "jwt/decode",
166 Some(url.to_string()),
167 "Failed to decode JWT header segment as JSON",
168 ));
169 return;
170 }
171 };
172 let payload = match decode_json(parts[1]) {
173 Some(v) => v,
174 None => {
175 errors.push(CapturedError::from_str(
176 "jwt/decode",
177 Some(url.to_string()),
178 "Failed to decode JWT payload segment as JSON",
179 ));
180 return;
181 }
182 };
183 let signature = match decode_segment(parts[2]) {
184 Some(v) => v,
185 None => {
186 errors.push(CapturedError::from_str(
187 "jwt/decode",
188 Some(url.to_string()),
189 "Failed to decode JWT signature segment",
190 ));
191 return;
192 }
193 };
194
195 let alg = header.get("alg").and_then(Value::as_str).unwrap_or("");
196
197 if let Some(kid) = header.get("kid").and_then(Value::as_str) {
199 if kid.contains("..")
200 || kid.starts_with("http://")
201 || kid.starts_with("https://")
202 || kid.starts_with("file:")
203 {
204 findings.push(
205 Finding::new(
206 url,
207 "jwt/suspicious-kid",
208 "JWT kid header looks suspicious",
209 Severity::Low,
210 "The JWT `kid` header contains a path or URL-like value. Some implementations\n load key material from filesystem/URLs and may be vulnerable to injection.",
211 "jwt",
212 )
213 .with_evidence(format!("kid: {kid}"))
214 .with_remediation(
215 "Treat `kid` as an opaque identifier and disallow path/URL resolution.",
216 ),
217 );
218 }
219 }
220
221 if alg.eq_ignore_ascii_case("none") {
222 findings.push(
223 Finding::new(
224 url,
225 "jwt/alg-none",
226 "JWT uses alg=none",
227 Severity::Critical,
228 "JWTs signed with alg=none can be forged without a secret key.",
229 "jwt",
230 )
231 .with_evidence(format!("Token: {}", redact_token(token)))
232 .with_remediation(
233 "Disallow alg=none and enforce signature verification with a strong key.",
234 ),
235 );
236 }
237
238 let mut hits = Vec::new();
239 for key in SENSITIVE_CLAIMS {
240 if payload.get(*key).is_some() {
241 hits.push(*key);
242 }
243 }
244
245 if !hits.is_empty() {
246 findings.push(
247 Finding::new(
248 url,
249 "jwt/sensitive-claims",
250 "JWT contains sensitive claims",
251 Severity::Medium,
252 format!(
253 "JWT payload exposes potentially sensitive claims: {}",
254 hits.join(", ")
255 ),
256 "jwt",
257 )
258 .with_evidence(format!("Token: {}", redact_token(token)))
259 .with_remediation(
260 "Minimize sensitive data in JWTs and prefer opaque tokens when possible.",
261 ),
262 );
263 }
264
265 match payload.get("exp").and_then(Value::as_i64) {
266 Some(exp) => {
267 let now = Utc::now().timestamp();
268 if exp - now > LONG_LIVED_SECS {
269 findings.push(
270 Finding::new(
271 url,
272 "jwt/long-lived",
273 "JWT has a long expiration window",
274 Severity::Medium,
275 "JWT expiration is far in the future; long-lived tokens increase risk if leaked.",
276 "jwt",
277 )
278 .with_evidence(format!("exp: {exp}, now: {now}"))
279 .with_remediation(
280 "Use short-lived access tokens and rotate/refresh them frequently.",
281 ),
282 );
283 }
284 }
285 None => {
286 findings.push(
287 Finding::new(
288 url,
289 "jwt/no-exp",
290 "JWT missing exp claim",
291 Severity::Medium,
292 "JWT has no exp claim; tokens without expiration increase risk if leaked.",
293 "jwt",
294 )
295 .with_evidence(format!("Token: {}", redact_token(token)))
296 .with_remediation("Include a short-lived exp claim and rotate tokens regularly."),
297 );
298 }
299 }
300
301 if alg.eq_ignore_ascii_case("hs256") {
302 let url_owned = url.to_string();
303 let header_owned = parts[0].to_string();
304 let payload_owned = parts[1].to_string();
305 let signature_owned = signature.clone();
306 match tokio::task::spawn_blocking(move || {
307 weak_secret_match(&url_owned, &header_owned, &payload_owned, &signature_owned)
308 })
309 .await
310 {
311 Ok(Some(secret)) => {
312 findings.push(
313 Finding::new(
314 url,
315 "jwt/weak-secret",
316 "JWT signed with weak HS256 secret",
317 Severity::Critical,
318 format!(
319 "JWT signature verifies with a weak secret candidate: '{secret}'.",
320 ),
321 "jwt",
322 )
323 .with_evidence(format!("Token: {}", redact_token(token)))
324 .with_remediation(
325 "Use a strong, high-entropy secret for HS256 or move to asymmetric signing (RS256/ES256).",
326 ),
327 );
328 }
329 Ok(None) => {}
330 Err(e) => errors.push(CapturedError::from_str(
331 "jwt/weak_secret_probe",
332 Some(url.to_string()),
333 format!("weak-secret blocking task failed: {e}"),
334 )),
335 }
336 }
337
338 if config.active_checks && alg.eq_ignore_ascii_case("rs256") {
339 if let Some(finding) = attempt_alg_confusion(
340 url,
341 Some(&header),
342 parts[1],
343 client,
344 baseline_status,
345 errors,
346 )
347 .await
348 {
349 findings.push(finding);
350 }
351 }
352}
353
354fn decode_segment(seg: &str) -> Option<Vec<u8>> {
355 URL_SAFE_NO_PAD.decode(seg).ok()
356}
357
358fn decode_json(seg: &str) -> Option<Value> {
359 let bytes = decode_segment(seg)?;
360 serde_json::from_slice(&bytes).ok()
361}
362
363fn weak_secret_match(url: &str, header_b64: &str, payload_b64: &str, sig: &[u8]) -> Option<String> {
364 let mut host_candidates: Vec<String> = Vec::new();
365
366 if let Ok(parsed) = Url::parse(url) {
367 if let Some(host) = parsed.host_str() {
368 host_candidates.push(host.to_string());
369 if let Some(root) = host.split('.').next() {
370 if !root.is_empty() {
371 host_candidates.push(root.to_string());
372 }
373 }
374 }
375 }
376
377 let signing_input = format!("{header_b64}.{payload_b64}");
378
379 for secret in WEAK_SECRET_LIST
380 .iter()
381 .map(String::as_str)
382 .chain(host_candidates.iter().map(String::as_str))
383 {
384 if let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) {
385 mac.update(signing_input.as_bytes());
386 if mac.verify_slice(sig).is_ok() {
387 return Some(secret.to_string());
388 }
389 }
390 }
391
392 None
393}
394
395fn redact_token(token: &str) -> String {
396 let chars: Vec<char> = token.chars().collect();
397 if chars.len() <= 16 {
398 return token.to_string();
399 }
400 let head: String = chars[..8].iter().collect();
401 let tail: String = chars[chars.len() - 8..].iter().collect();
402 format!("{head}…{tail}")
403}
404
405async fn attempt_alg_confusion(
406 url: &str,
407 header: Option<&Value>,
408 payload_b64: &str,
409 client: &HttpClient,
410 baseline_status: u16,
411 errors: &mut Vec<CapturedError>,
412) -> Option<Finding> {
413 if baseline_status >= 400 {
414 return None;
415 }
416
417 let header = header?;
418 let has_key_hint = header.get("x5c").is_some() || header.get("jwk").is_some();
419 if !has_key_hint {
420 return None;
421 }
422
423 let secret_candidates = derive_alg_confusion_secrets(header);
427 if secret_candidates.is_empty() {
428 errors.push(CapturedError::from_str(
429 "jwt/alg_confusion",
430 Some(url.to_string()),
431 "Unable to derive RSA key material from JWT header (expected valid jwk {kty,n,e} or x5c certificate)",
432 ));
433 return None;
434 }
435
436 let unauth_status = match client.get_without_auth(url).await {
437 Ok(r) => Some(r.status),
438 Err(mut e) => {
439 e.context = "jwt/alg_confusion_baseline".to_string();
440 errors.push(e);
441 None
442 }
443 };
444
445 for (secret_source, secret) in secret_candidates {
446 let mut new_header = header.clone();
447 if let Some(obj) = new_header.as_object_mut() {
448 obj.insert("alg".to_string(), Value::String("HS256".to_string()));
449 }
450
451 let header_json = match serde_json::to_vec(&new_header) {
452 Ok(v) => v,
453 Err(_) => continue,
454 };
455 let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
456
457 let signing_input = format!("{header_b64}.{payload_b64}");
458 let mut mac = match HmacSha256::new_from_slice(&secret) {
459 Ok(m) => m,
460 Err(_) => continue,
461 };
462 mac.update(signing_input.as_bytes());
463 let sig = mac.finalize().into_bytes();
464 let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
465
466 let forged = format!("{header_b64}.{payload_b64}.{sig_b64}");
467 let auth_header = format!("Bearer {forged}");
468
469 let extra = vec![("Authorization".to_string(), auth_header)];
470 let resp = match client.get_with_headers(url, &extra).await {
471 Ok(r) => r,
472 Err(mut e) => {
473 e.message = format!("alg_confusion_probe[{secret_source}]: {}", e.message);
474 errors.push(e);
475 continue;
476 }
477 };
478
479 if resp.status < 400 && matches!(unauth_status, Some(status) if status >= 400) {
480 return Some(
481 Finding::new(
482 url,
483 "jwt/alg-confusion",
484 "JWT RS256 -> HS256 confusion",
485 Severity::Critical,
486 "A forged HS256 token signed with derived public key material appears to be accepted.",
487 "jwt",
488 )
489 .with_evidence(format!(
490 "baseline_status: {baseline_status}, unauth_status: {}, forged_status: {}, key_source: {secret_source}",
491 unauth_status
492 .map(|s| s.to_string())
493 .unwrap_or_else(|| "unknown".to_string()),
494 resp.status,
495 ))
496 .with_remediation(
497 "Reject HS256 tokens when using RS256 keys; ensure key type matches algorithm.",
498 ),
499 );
500 }
501 }
502
503 None
504}
505
506fn derive_alg_confusion_secrets(header: &Value) -> Vec<(String, Vec<u8>)> {
507 let mut candidates: Vec<(String, Vec<u8>)> = Vec::new();
508 let mut seen: HashSet<Vec<u8>> = HashSet::new();
509
510 let mut push_candidate = |label: &str, secret: Vec<u8>| {
511 if secret.is_empty() {
512 return;
513 }
514 if seen.insert(secret.clone()) {
515 candidates.push((label.to_string(), secret));
516 }
517 };
518
519 if let Some((modulus, exponent)) = header.get("jwk").and_then(extract_rsa_components_from_jwk) {
520 if let Some(spki_der) = build_rsa_spki_der(&modulus, &exponent) {
521 push_candidate("jwk-spki-der", spki_der.clone());
522 push_candidate(
523 "jwk-spki-pem",
524 pem_encode("PUBLIC KEY", &spki_der).into_bytes(),
525 );
526 }
527 }
528
529 if let Some(x5c_der) = header
530 .get("x5c")
531 .and_then(Value::as_array)
532 .and_then(|arr| arr.first())
533 .and_then(Value::as_str)
534 .and_then(|s| BASE64_STD.decode(s.as_bytes()).ok())
535 {
536 push_candidate("x5c-cert-der", x5c_der.clone());
537 push_candidate(
538 "x5c-cert-pem",
539 pem_encode("CERTIFICATE", &x5c_der).into_bytes(),
540 );
541
542 if let Some(spki_der) = extract_subject_public_key_info_der_from_certificate(&x5c_der) {
543 push_candidate("x5c-spki-der", spki_der.clone());
544 push_candidate(
545 "x5c-spki-pem",
546 pem_encode("PUBLIC KEY", &spki_der).into_bytes(),
547 );
548 }
549 }
550
551 candidates
552}
553
554fn extract_rsa_components_from_jwk(jwk: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
555 let obj = jwk.as_object()?;
556 let kty = obj.get("kty").and_then(Value::as_str).unwrap_or_default();
557 if !kty.eq_ignore_ascii_case("RSA") {
558 return None;
559 }
560
561 let n = obj.get("n").and_then(Value::as_str)?;
562 let e = obj.get("e").and_then(Value::as_str)?;
563 let mut modulus = URL_SAFE_NO_PAD.decode(n.as_bytes()).ok()?;
564 let mut exponent = URL_SAFE_NO_PAD.decode(e.as_bytes()).ok()?;
565 trim_leading_zeros(&mut modulus);
566 trim_leading_zeros(&mut exponent);
567 if modulus.is_empty() || exponent.is_empty() {
568 return None;
569 }
570 Some((modulus, exponent))
571}
572
573fn trim_leading_zeros(bytes: &mut Vec<u8>) {
574 if bytes.len() <= 1 {
575 return;
576 }
577 let first_non_zero = bytes
578 .iter()
579 .position(|&b| b != 0)
580 .unwrap_or(bytes.len().saturating_sub(1));
581 if first_non_zero > 0 {
582 bytes.drain(0..first_non_zero);
583 }
584}
585
586fn build_rsa_spki_der(modulus: &[u8], exponent: &[u8]) -> Option<Vec<u8>> {
587 let rsa_pkcs1 = build_rsa_pkcs1_der(modulus, exponent)?;
588 let alg_id_value: Vec<u8> = vec![
589 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01,
591 0x01, 0x05, 0x00, ];
594 let alg_id = der_tlv(0x30, &alg_id_value);
595
596 let mut bit_string = Vec::with_capacity(1 + rsa_pkcs1.len());
597 bit_string.push(0x00); bit_string.extend_from_slice(&rsa_pkcs1);
599 let subject_public_key = der_tlv(0x03, &bit_string);
600
601 let mut spki_value = Vec::with_capacity(alg_id.len() + subject_public_key.len());
602 spki_value.extend_from_slice(&alg_id);
603 spki_value.extend_from_slice(&subject_public_key);
604 Some(der_tlv(0x30, &spki_value))
605}
606
607fn build_rsa_pkcs1_der(modulus: &[u8], exponent: &[u8]) -> Option<Vec<u8>> {
608 if modulus.is_empty() || exponent.is_empty() {
609 return None;
610 }
611
612 let modulus_tlv = der_integer(modulus);
613 let exponent_tlv = der_integer(exponent);
614
615 let mut value = Vec::with_capacity(modulus_tlv.len() + exponent_tlv.len());
616 value.extend_from_slice(&modulus_tlv);
617 value.extend_from_slice(&exponent_tlv);
618 Some(der_tlv(0x30, &value))
619}
620
621fn der_integer(raw: &[u8]) -> Vec<u8> {
622 let mut v = raw.to_vec();
623 trim_leading_zeros(&mut v);
624 if v.is_empty() {
625 v.push(0);
626 }
627 if (v[0] & 0x80) != 0 {
628 v.insert(0, 0x00);
629 }
630 der_tlv(0x02, &v)
631}
632
633fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
634 let mut out = Vec::with_capacity(1 + value.len() + 5);
635 out.push(tag);
636 out.extend_from_slice(&der_len(value.len()));
637 out.extend_from_slice(value);
638 out
639}
640
641fn der_len(len: usize) -> Vec<u8> {
642 if len < 0x80 {
643 return vec![len as u8];
644 }
645 let mut bytes = Vec::new();
646 let mut n = len;
647 while n > 0 {
648 bytes.push((n & 0xff) as u8);
649 n >>= 8;
650 }
651 bytes.reverse();
652 let mut out = Vec::with_capacity(1 + bytes.len());
653 out.push(0x80 | (bytes.len() as u8));
654 out.extend_from_slice(&bytes);
655 out
656}
657
658fn pem_encode(label: &str, der: &[u8]) -> String {
659 let b64 = BASE64_STD.encode(der);
660 let mut out = String::new();
661 out.push_str("-----BEGIN ");
662 out.push_str(label);
663 out.push_str("-----\n");
664 for chunk in b64.as_bytes().chunks(64) {
665 out.push_str(String::from_utf8_lossy(chunk).as_ref());
666 out.push('\n');
667 }
668 out.push_str("-----END ");
669 out.push_str(label);
670 out.push_str("-----\n");
671 out
672}
673
674fn extract_subject_public_key_info_der_from_certificate(cert_der: &[u8]) -> Option<Vec<u8>> {
675 if let Some(spki) = extract_spki_from_x509_layout(cert_der) {
676 return Some(spki);
677 }
678 extract_spki_sequence_recursive(cert_der, 0)
679}
680
681fn extract_spki_from_x509_layout(cert_der: &[u8]) -> Option<Vec<u8>> {
682 let (cert_tag, cert_value_start, cert_value_end, _) = parse_der_tlv(cert_der, 0)?;
683 if cert_tag != 0x30 {
684 return None;
685 }
686 let cert_seq = &cert_der[cert_value_start..cert_value_end];
687
688 let (tbs_tag, tbs_value_start, tbs_value_end, _) = parse_der_tlv(cert_seq, 0)?;
689 if tbs_tag != 0x30 {
690 return None;
691 }
692 let tbs = &cert_seq[tbs_value_start..tbs_value_end];
693
694 let mut idx = 0usize;
695 let (first_tag, _, _, next_idx) = parse_der_tlv(tbs, idx)?;
696 if first_tag == 0xA0 {
697 idx = next_idx;
698 }
699
700 for _ in 0..5 {
702 let (_, _, _, next) = parse_der_tlv(tbs, idx)?;
703 idx = next;
704 }
705
706 let (spki_tag, _, _, spki_next) = parse_der_tlv(tbs, idx)?;
707 if spki_tag != 0x30 {
708 return None;
709 }
710
711 Some(tbs[idx..spki_next].to_vec())
712}
713
714fn extract_spki_sequence_recursive(input: &[u8], offset: usize) -> Option<Vec<u8>> {
715 if offset >= input.len() {
716 return None;
717 }
718
719 let (tag, value_start, value_end, next) = parse_der_tlv(input, offset)?;
720
721 if tag == 0x30 && looks_like_spki_sequence(&input[value_start..value_end]) {
722 return Some(input[offset..next].to_vec());
723 }
724
725 if (tag & 0x20) != 0 {
727 if let Some(found) = extract_spki_sequence_recursive(&input[value_start..value_end], 0) {
728 return Some(found);
729 }
730 }
731
732 if tag == 0x03 && value_start < value_end {
735 let bit_payload = &input[value_start..value_end];
736 if bit_payload.first() == Some(&0) && bit_payload.len() > 1 {
737 if let Some(found) = extract_spki_sequence_recursive(&bit_payload[1..], 0) {
738 return Some(found);
739 }
740 }
741 }
742
743 extract_spki_sequence_recursive(input, next)
744}
745
746fn looks_like_spki_sequence(seq_value: &[u8]) -> bool {
747 let Some((alg_tag, _, _, alg_next)) = parse_der_tlv(seq_value, 0) else {
748 return false;
749 };
750 if alg_tag != 0x30 {
751 return false;
752 }
753 let Some((key_tag, _, _, key_next)) = parse_der_tlv(seq_value, alg_next) else {
754 return false;
755 };
756 key_tag == 0x03 && key_next == seq_value.len()
757}
758
759fn parse_der_tlv(input: &[u8], offset: usize) -> Option<(u8, usize, usize, usize)> {
760 if offset + 2 > input.len() {
761 return None;
762 }
763
764 let tag = input[offset];
765 let len_first = input[offset + 1];
766 let mut len_idx = offset + 2;
767
768 let len = if (len_first & 0x80) == 0 {
769 len_first as usize
770 } else {
771 let nbytes = (len_first & 0x7f) as usize;
772 if nbytes == 0 || nbytes > 4 || len_idx + nbytes > input.len() {
773 return None;
774 }
775 let mut v = 0usize;
776 for b in &input[len_idx..len_idx + nbytes] {
777 v = (v << 8) | (*b as usize);
778 }
779 len_idx += nbytes;
780 v
781 };
782
783 let value_start = len_idx;
784 let value_end = value_start.checked_add(len)?;
785 if value_end > input.len() {
786 return None;
787 }
788
789 Some((tag, value_start, value_end, value_end))
790}