//! JWT configuration extractor. //! //! Detects patterns where JWT validation is misconfigured, //! violating RFC 7519 security requirements. use regex::Regex; use stemedb_core::types::ObjectValue; use super::Extractor; use crate::types::{ExtractedClaim, Language}; /// Extractor for JWT validation configuration. pub struct JwtConfigExtractor { /// Audience validation disabled aud_disabled: Regex, /// Algorithm none allowed alg_none: Regex, /// Signature verification skipped sig_skip: Regex, /// Expiry validation disabled exp_disabled: Regex, /// Go jwt.Parse without algorithm check (heuristic) go_parse_insecure: Regex, } impl Default for JwtConfigExtractor { fn default() -> Self { Self::new() } } impl JwtConfigExtractor { /// Create a new JWT config extractor with compiled regexes. /// /// # Panics /// Panics if any regex pattern is invalid (programmer error). #[allow(clippy::expect_used)] pub fn new() -> Self { Self { // Match JWT audience validation disabled patterns: // - set_audience([]) or set_audience(vec![]) - empty audience list // - validate_aud = false or validate_audience = false // - aud = None or audience = None (direct assignment) // - ValidateAudience = false (.NET style) // NOTE: \baud\b ensures we don't match "audit" in "audit_log_path" aud_disabled: Regex::new( r"(?i)(set_audience\s*\(\s*(?:vec!)?\s*\[\s*\]\s*\)|validate_aud\w*\s*[:=]\s*false|\baud(?:ience)?\s*[:=]\s*None|ValidateAudience\s*[:=]\s*false)", ) .expect("valid regex"), // Match JWT algorithm none patterns (signature bypass vulnerability): // - Algorithm::None (Rust enum variant) // - alg: none, alg = none, alg: "none" (config assignment) // - allow_none = true or allow_none_algorithm = true // - SigningMethodNone (Go jwt library) // - YAML list item: "- none" or '- "none"' in algorithms list // - WithValidMethods([]string{"none"...}) in Go // NOTE: Post-processing filters out documentation like "(no alg: none)" alg_none: Regex::new( r#"(?i)(Algorithm::None|\balg(?:orithm)?s?\b\s*[:=]\s*['"]?none|allow_none\w*\s*[:=]\s*true|SigningMethodNone|-\s*['"]?none['"]?\s*(?:#|$)|WithValidMethods.*none)"#, ) .expect("valid regex"), // Match signature verification disabled patterns: // - dangerous_insecure_* functions // - skip_signature settings // - verify_signature = false, signature_verify = false // - RequireSignedTokens = false // NOTE: Excludes patterns like "skip_verify = false" (that's secure!) // and UI state like "isVerifying = false" sig_skip: Regex::new( r"(?i)(dangerous_insecure|skip_signature|(?:verify_signature|signature_verify)\s*[:=]\s*false|RequireSignedTokens\s*[:=]\s*false)", ) .expect("valid regex"), exp_disabled: Regex::new( r"(?i)(validate_exp.*false|RequireExpirationTime.*false|IgnoreExpiration)", ) .expect("valid regex"), go_parse_insecure: Regex::new( r"jwt\.Parse\([^,]+,\s*func\s*\([^)]*\*jwt\.Token\)\s*\([^)]*,\s*error\)\s*\{[^}]*return\s+[^,]+,\s*nil", ) .expect("valid regex"), } } #[allow(clippy::too_many_arguments)] fn extract_claim( &self, path_segments: &[String], file: &str, line: usize, matched_text: &str, leaf: &str, predicate: &str, value: ObjectValue, description: &str, confidence: f32, ) -> ExtractedClaim { let mut concept_path = path_segments.to_vec(); concept_path.push("jwt".to_string()); concept_path.push(leaf.to_string()); ExtractedClaim { concept_path: format!("code://{}", concept_path.join("/")), predicate: predicate.to_string(), value, file: file.to_string(), line, matched_text: matched_text.to_string(), confidence, description: description.to_string(), } } } impl Extractor for JwtConfigExtractor { fn name(&self) -> &str { "jwt_config" } fn languages(&self) -> &[Language] { &[ Language::Rust, Language::Go, Language::Python, Language::TypeScript, Language::JavaScript, Language::Yaml, Language::Toml, Language::Json, ] } fn extract( &self, path_segments: &[String], content: &str, _language: Language, file: &str, ) -> Vec { let mut claims = Vec::new(); for (line_idx, line) in content.lines().enumerate() { let line_num = line_idx + 1; // Audience validation disabled if let Some(matched) = self.aud_disabled.find(line) { claims.push(self.extract_claim( path_segments, file, line_num, matched.as_str(), "audience_validation", "enabled", ObjectValue::Boolean(false), "JWT audience validation is disabled", 1.0, )); } // Algorithm none allowed if let Some(matched) = self.alg_none.find(line) { // Filter out documentation that describes PREVENTING alg:none // e.g., "(no alg: none)" or "don't allow alg none" let lower_line = line.to_lowercase(); let is_prevention_doc = lower_line.contains("no alg") || lower_line.contains("no `alg") || lower_line.contains("don't allow") || lower_line.contains("do not allow") || lower_line.contains("whitelist") || lower_line.contains("reject") || (lower_line.contains("algorithm") && lower_line.contains("none")) && (lower_line.contains("checksum") || lower_line.contains("crc")); if is_prevention_doc { continue; } claims.push(self.extract_claim( path_segments, file, line_num, matched.as_str(), "algorithm_restriction", "config_value", ObjectValue::Text("none_allowed".to_string()), "JWT allows 'none' algorithm (signature bypass)", 1.0, )); } // Signature verification skipped if let Some(matched) = self.sig_skip.find(line) { claims.push(self.extract_claim( path_segments, file, line_num, matched.as_str(), "signature_verification", "enabled", ObjectValue::Boolean(false), "JWT signature verification is disabled", 1.0, )); } // Expiry validation disabled if let Some(matched) = self.exp_disabled.find(line) { claims.push(self.extract_claim( path_segments, file, line_num, matched.as_str(), "expiry_validation", "enabled", ObjectValue::Boolean(false), "JWT expiry validation is disabled", 1.0, )); } } // Check for Go insecure parse pattern (multi-line, lower confidence) if let Some(matched) = self.go_parse_insecure.find(content) { // Find line number for start of match let line_num = content[..matched.start()].lines().count() + 1; claims.push(self.extract_claim( path_segments, file, line_num, &matched.as_str()[..matched.as_str().len().min(50)], "signature_verification", "enabled", ObjectValue::Boolean(false), "JWT parsed without algorithm verification (heuristic)", 0.7, // Lower confidence - heuristic match )); } claims } } #[cfg(test)] mod tests { use super::*; #[test] fn test_audience_disabled() { let extractor = JwtConfigExtractor::new(); let content = r#" let validation = Validation::new(Algorithm::HS256); validation.validate_aud = false; "#; let claims = extractor.extract(&["rust".to_string()], content, Language::Rust, "src/auth.rs"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("audience_validation")); assert_eq!(claims[0].value, ObjectValue::Boolean(false)); } #[test] fn test_algorithm_none() { let extractor = JwtConfigExtractor::new(); let content = r#" // Dangerous: allows unsigned tokens Algorithm::None "#; let claims = extractor.extract(&["rust".to_string()], content, Language::Rust, "src/auth.rs"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("algorithm_restriction")); } #[test] fn test_signature_skip() { let extractor = JwtConfigExtractor::new(); let content = r#" let claims = dangerous_insecure_decode(&token)?; "#; let claims = extractor.extract(&["rust".to_string()], content, Language::Rust, "src/auth.rs"); assert_eq!(claims.len(), 1); assert!(claims[0].concept_path.contains("signature_verification")); } #[test] fn test_multiple_issues() { let extractor = JwtConfigExtractor::new(); let content = r#" validation.validate_aud = false; validation.validate_exp = false; "#; let claims = extractor.extract(&["rust".to_string()], content, Language::Rust, "src/auth.rs"); assert_eq!(claims.len(), 2); } // Regression tests for false positives #[test] fn test_skip_verify_false_not_flagged() { // "insecure_skip_verify = false" means verification IS enabled (secure) let extractor = JwtConfigExtractor::new(); let content = r#" insecure_skip_verify = false "#; let claims = extractor.extract(&["config".to_string()], content, Language::Toml, "config.toml"); assert!(claims.is_empty(), "skip_verify = false should NOT be flagged (it's secure)"); } #[test] fn test_ui_state_not_flagged() { // React state "isVerifying = false" is UI state, not security let extractor = JwtConfigExtractor::new(); let content = r#" const [isVerifying, setIsVerifying] = useState(false); setIsVerifying(false); "#; let claims = extractor.extract( &["typescript".to_string()], content, Language::TypeScript, "page.tsx", ); assert!(claims.is_empty(), "UI state isVerifying should NOT be flagged"); } #[test] fn test_verify_release_not_flagged() { // verify_release(version, false) is a release verification function, not JWT let extractor = JwtConfigExtractor::new(); let content = r#" verify_release(version, false); "#; let claims = extractor.extract(&["rust".to_string()], content, Language::Rust, "release.rs"); assert!(claims.is_empty(), "verify_release function should NOT be flagged as JWT"); } #[test] fn test_actual_signature_verify_disabled() { // verify_signature = false should be flagged let extractor = JwtConfigExtractor::new(); let content = r#" verify_signature = false "#; let claims = extractor.extract(&["config".to_string()], content, Language::Toml, "jwt.toml"); assert_eq!(claims.len(), 1, "verify_signature = false should be flagged"); assert!(claims[0].concept_path.contains("signature_verification")); } #[test] fn test_yaml_algorithms_list_none() { let extractor = JwtConfigExtractor::new(); let content = r#" jwt: algorithms: - HS256 - none "#; let claims = extractor.extract( &["config".to_string()], content, Language::Yaml, "config/production.yaml", ); assert!(claims.iter().any(|c| c.concept_path.contains("algorithm_restriction"))); } #[test] fn test_go_with_valid_methods_none() { let extractor = JwtConfigExtractor::new(); let content = r#" parser := jwt.NewParser(jwt.WithValidMethods([]string{"none", "HS256"})) "#; let claims = extractor.extract(&["go".to_string()], content, Language::Go, "auth.go"); assert!(claims.iter().any(|c| c.concept_path.contains("algorithm_restriction"))); } }