#![deny(unsafe_code)]
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwtAnalysis {
pub alg: String,
pub typ: Option<String>,
pub kid: Option<String>,
pub iss: Option<String>,
pub sub: Option<String>,
pub aud: Option<String>,
pub exp: Option<i64>,
pub expired: Option<bool>,
pub anomalies: Vec<JwtAnomaly>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum JwtAnomaly {
AlgNone,
UnknownAlg(String),
NonStandardTyp(String),
Expired,
}
pub(crate) fn anomalies_to_metadata(analysis: &JwtAnalysis) -> Option<BTreeMap<String, String>> {
if analysis.anomalies.is_empty() {
return None;
}
let mut out = BTreeMap::new();
for anomaly in &analysis.anomalies {
match anomaly {
JwtAnomaly::AlgNone => {
out.insert(
"jwt.alg_none".to_string(),
"true (unsigned token: RFC 7519 §6 risk)".to_string(),
);
}
JwtAnomaly::UnknownAlg(alg) => {
out.insert("jwt.unknown_alg".to_string(), alg.clone());
}
JwtAnomaly::NonStandardTyp(typ) => {
out.insert("jwt.non_standard_typ".to_string(), typ.clone());
}
JwtAnomaly::Expired => {
out.insert("jwt.expired".to_string(), "true".to_string());
}
}
}
Some(out)
}
pub fn finding_metadata(credential: &str) -> Option<std::collections::HashMap<String, String>> {
finding_metadata_with_secrets(credential, false)
}
pub fn finding_metadata_with_secrets(
credential: &str,
show_secrets: bool,
) -> Option<std::collections::HashMap<String, String>> {
let analysis = analyze(credential)?;
let mut meta = std::collections::HashMap::with_capacity(8);
meta.insert("jwt.alg".to_string(), analysis.alg.clone());
if let Some(iss) = &analysis.iss {
meta.insert(
"jwt.iss".to_string(),
if show_secrets {
iss.clone()
} else {
redact_jwt_claim(iss)
},
);
}
if let Some(sub) = &analysis.sub {
meta.insert(
"jwt.sub".to_string(),
if show_secrets {
sub.clone()
} else {
redact_jwt_claim(sub)
},
);
}
if let Some(aud) = &analysis.aud {
meta.insert(
"jwt.aud".to_string(),
if show_secrets {
aud.clone()
} else {
redact_jwt_claim(aud)
},
);
}
if let Some(exp) = analysis.exp {
meta.insert("jwt.exp".to_string(), exp.to_string());
}
if let Some(anomalies) = anomalies_to_metadata(&analysis) {
for (k, v) in anomalies {
meta.insert(k, v);
}
}
Some(meta)
}
fn redact_jwt_claim(value: &str) -> String {
format!("<redacted {} chars>", value.chars().count())
}
pub(crate) fn looks_like_jwt(s: &str) -> bool {
jwt_segments(s).is_some()
}
pub(crate) const JWT_BASE64_HEADER_PREFIX: &str = "eyJ";
pub(crate) fn has_jwt_header_prefix(s: &str) -> bool {
s.starts_with(JWT_BASE64_HEADER_PREFIX)
}
fn jwt_segments(s: &str) -> Option<(&str, &str, &str)> {
let s = s.trim();
const MAX_JWT_SEGMENT_LEN: usize = 16 * 1024;
let mut parts = s.split('.');
let (Some(h), Some(p), Some(sig), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return None;
};
if h.len() > MAX_JWT_SEGMENT_LEN
|| p.len() > MAX_JWT_SEGMENT_LEN
|| sig.len() > MAX_JWT_SEGMENT_LEN
{
return None;
}
if !h.is_empty()
&& !p.is_empty()
&& !sig.is_empty()
&& h.bytes().all(is_base64url_byte)
&& p.bytes().all(is_base64url_byte)
&& sig.bytes().all(is_base64url_byte)
{
Some((h, p, sig))
} else {
None
}
}
pub(crate) fn analyze(s: &str) -> Option<JwtAnalysis> {
let (header_b64, payload_b64, _signature_b64) = jwt_segments(s)?;
let header_json = decode_b64url(header_b64)?;
let payload_json = decode_b64url(payload_b64)?;
if !check_nesting_depth(&header_json, 15) || !check_nesting_depth(&payload_json, 15) {
return None;
}
let header: JwtHeader = serde_json::from_slice(&header_json).ok()?; let mut payload: JwtPayload = serde_json::from_slice(&payload_json).ok()?; let aud = payload.take_aud();
let iss = payload.iss.take();
let sub = payload.sub.take();
let mut anomalies = Vec::new();
let alg = header.alg.unwrap_or_else(|| "<missing>".to_string()); if alg.eq_ignore_ascii_case("none") {
anomalies.push(JwtAnomaly::AlgNone);
} else if !is_known_alg(&alg) {
anomalies.push(JwtAnomaly::UnknownAlg(alg.clone()));
}
if let Some(typ) = header.typ.as_deref() {
if !is_standard_typ(typ) {
anomalies.push(JwtAnomaly::NonStandardTyp(typ.to_string()));
}
}
let exp = payload.exp.take().and_then(json_i64);
let expired = exp.map(|exp_val| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0); let is_expired = now >= exp_val;
if is_expired {
anomalies.push(JwtAnomaly::Expired);
}
is_expired
});
Some(JwtAnalysis {
alg,
typ: header.typ,
kid: header.kid,
iss,
sub,
aud,
exp,
expired,
anomalies,
})
}
fn json_i64(value: serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(number) => number.as_i64(),
_ => None,
}
}
#[inline]
fn is_base64url_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'='
}
fn decode_b64url(s: &str) -> Option<Vec<u8>> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let trimmed = s.trim_end_matches('=');
URL_SAFE_NO_PAD.decode(trimmed).ok() }
#[derive(serde::Deserialize)]
struct JwtStandardsFile {
jwt_standards: JwtStandards,
}
#[derive(serde::Deserialize)]
struct JwtStandards {
algorithms: Vec<String>,
media_types: Vec<String>,
}
fn parse_jwt_standards(raw: &str) -> Result<(Vec<String>, Vec<String>), String> {
let parsed: JwtStandardsFile =
toml::from_str(raw).map_err(|error| format!("invalid jwt-standards.toml: {error}"))?;
let algorithms = crate::tier_b_list::parse_token_list(
parsed.jwt_standards.algorithms,
&crate::tier_b_list::ListPolicy {
what: "JWT algorithm",
require_lowercase: false,
separators: b"",
},
)?;
let media_types = crate::tier_b_list::parse_token_list(
parsed.jwt_standards.media_types,
&crate::tier_b_list::ListPolicy {
what: "JWT media type",
require_lowercase: false,
separators: b"+",
},
)?;
Ok((algorithms, media_types))
}
static JWT_STANDARDS: std::sync::LazyLock<(Vec<String>, Vec<String>)> =
std::sync::LazyLock::new(|| {
match parse_jwt_standards(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/jwt-standards.toml"
))) {
Ok(standards) => standards,
Err(error) => panic!(
"rules/jwt-standards.toml is invalid: {error}. \
Fix the bundled Tier-B JWT standards vocabulary."
),
}
});
fn is_known_alg(alg: &str) -> bool {
JWT_STANDARDS.0.iter().any(|known| known == alg)
}
fn is_standard_typ(typ: &str) -> bool {
JWT_STANDARDS.1.iter().any(|standard| standard == typ)
}
#[derive(Deserialize)]
struct JwtHeader {
alg: Option<String>,
typ: Option<String>,
kid: Option<String>,
}
#[derive(Deserialize)]
struct JwtPayload {
iss: Option<String>,
sub: Option<String>,
#[serde(default)]
aud: serde_json::Value,
exp: Option<serde_json::Value>,
}
impl JwtPayload {
fn take_aud(&mut self) -> Option<String> {
match std::mem::take(&mut self.aud) {
serde_json::Value::String(s) if !s.is_empty() => Some(s),
serde_json::Value::Array(items) if !items.is_empty() => join_audience_strings(items),
_ => None,
}
}
}
fn join_audience_strings(items: Vec<serde_json::Value>) -> Option<String> {
let mut strings = items.into_iter().filter_map(|value| match value {
serde_json::Value::String(value) => Some(value),
_ => None,
});
let mut joined = strings.next()?;
for audience in strings {
joined.push(',');
joined.push_str(&audience);
}
Some(joined)
}
fn check_nesting_depth(json: &[u8], max_depth: usize) -> bool {
let mut depth = 0;
let mut in_string = false;
let mut escaped = false;
for &b in json {
if escaped {
escaped = false;
continue;
}
if b == b'\\' {
if in_string {
escaped = true;
}
continue;
}
if b == b'"' {
in_string = !in_string;
continue;
}
if !in_string {
if b == b'{' || b == b'[' {
depth += 1;
if depth > max_depth {
return false;
}
} else if b == b'}' || b == b']' {
depth = depth.saturating_sub(1);
}
}
}
true
}