Major additions: - Community Next.js app (port 18187) for browsing claims with API docs - stemedb-chaos crate: Fault injection, chaos testing, CRDT properties - Latent ingestion system: Reddit/FDA ingesters with ADK-Go agents - Disputed claims handling: Manual review workflows and validation - Aphoria security scanner: New extractors (SQL injection, command injection, weak crypto, TLS version), policy-based ignores, UAT reports - Docker infrastructure: Dockerfile, docker-compose.yml for full stack - VulnBank demo: Intentionally vulnerable multi-language test corpus SDK & API enhancements: - Source registry handlers for tracking data provenance - Metrics endpoint - Skeptic filtering improvements Code quality: - Split 14 large files (>500 lines) into focused modules - All files now under 500-line limit per project guidelines Documentation: - Chaos testing guide, circuit breakers, observability docs - Phase 7 UAT documentation updates - Martin Kleppmann technical writer agent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
401 lines
13 KiB
Rust
401 lines
13 KiB
Rust
//! 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<ExtractedClaim> {
|
|
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")));
|
|
}
|
|
}
|