From 89d441092b0e0f461ee3950806f6d0dab646989b Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:21:32 -0500 Subject: [PATCH 01/29] =?UTF-8?q?Fix=20#70:=20P2.1=20Detector=20=E2=80=94?= =?UTF-8?q?=20JWT=20verify-options=20key=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-detectors/src/jwt/mod.rs | 318 ++++++++++++++++++- 1 file changed, 313 insertions(+), 5 deletions(-) diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index e2a2c04..38883f3 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -118,6 +118,16 @@ enum JwtField { EmailVerified, AuthMethod, AuthClass, + OptionAlgorithms, + OptionAudience, + OptionIssuer, + OptionSubject, + OptionNonce, + OptionClockTolerance, + OptionClockTimestamp, + OptionComplete, + OptionIgnoreNotBefore, + OptionIgnoreExpiration, } impl JwtField { @@ -181,6 +191,16 @@ impl JwtField { Self::EmailVerified => "email_verified", Self::AuthMethod => "auth_method", Self::AuthClass => "auth_class", + Self::OptionAlgorithms => "algorithms", + Self::OptionAudience => "audience", + Self::OptionIssuer => "issuer", + Self::OptionSubject => "subject", + Self::OptionNonce => "nonce", + Self::OptionClockTolerance => "clock_tolerance", + Self::OptionClockTimestamp => "clock_timestamp", + Self::OptionComplete => "complete", + Self::OptionIgnoreNotBefore => "ignore_not_before", + Self::OptionIgnoreExpiration => "ignore_expiration", } } @@ -206,12 +226,48 @@ impl JwtField { Self::EmailVerified => "email verified claim", Self::AuthMethod => "auth method claim", Self::AuthClass => "auth class claim", + Self::OptionAlgorithms => "JWT algorithms option", + Self::OptionAudience => "JWT audience option", + Self::OptionIssuer => "JWT issuer option", + Self::OptionSubject => "JWT subject option", + Self::OptionNonce => "JWT nonce option", + Self::OptionClockTolerance => "JWT clock tolerance option", + Self::OptionClockTimestamp => "JWT clock timestamp option", + Self::OptionComplete => "JWT complete option", + Self::OptionIgnoreNotBefore => "JWT ignore-not-before option", + Self::OptionIgnoreExpiration => "JWT ignore-expiration option", } } + fn is_option(self) -> bool { + matches!( + self, + Self::OptionAlgorithms + | Self::OptionAudience + | Self::OptionIssuer + | Self::OptionSubject + | Self::OptionNonce + | Self::OptionClockTolerance + | Self::OptionClockTimestamp + | Self::OptionComplete + | Self::OptionIgnoreNotBefore + | Self::OptionIgnoreExpiration + ) + } + fn lifecycle_stage(self, operation: JwtOperation) -> LifecycleStage { match self { Self::Expiration => LifecycleStage::Expire, + Self::OptionAlgorithms + | Self::OptionAudience + | Self::OptionIssuer + | Self::OptionSubject + | Self::OptionNonce + | Self::OptionClockTolerance + | Self::OptionClockTimestamp + | Self::OptionComplete + | Self::OptionIgnoreNotBefore + | Self::OptionIgnoreExpiration => LifecycleStage::Validate, Self::ExpiryEnforcement | Self::SignatureVerification => LifecycleStage::Validate, _ => operation.lifecycle_stage(), } @@ -414,9 +470,14 @@ fn calls_to_output( let line_part = observation.line.to_string(); let column_part = observation.column.to_string(); let state_part = jwt_state_part(observation.state); + let evidence_kind = if field.is_option() { + "jwt_option" + } else { + "jwt_attribute" + }; let evidence_id = stable_evidence_id(&[ detector_id, - "jwt_attribute", + evidence_kind, field.wire_name(), state_part, input.path, @@ -438,7 +499,11 @@ fn calls_to_output( line: Some(observation.line), column: Some(observation.column), }, - detector_id: format!("jwt.attribute.{}", field.wire_name()), + detector_id: if field.is_option() { + format!("jwt.option.{}", field.wire_name()) + } else { + format!("jwt.attribute.{}", field.wire_name()) + }, confidence: observation.confidence, excerpt: Some(observation.excerpt.clone()), dynamic: observation.state == JwtAttributeState::Dynamic, @@ -1102,6 +1167,15 @@ fn add_js_verify_fields( line, column, ); + add_js_options_fields( + fields, + source, + options, + option_aliases, + js_verify_option_fields(jose), + line, + column, + ); add_js_expiry_enforcement(fields, source, options, option_aliases, line, column, jose); } else { add_framework_default( @@ -1129,6 +1203,36 @@ fn add_js_verify_fields( } } +fn js_verify_option_fields(jose: bool) -> &'static [(JwtField, &'static [&'static str])] { + if jose { + &[ + (JwtField::OptionAlgorithms, &["algorithms", "algorithm"]), + (JwtField::OptionIssuer, &["issuer"]), + (JwtField::OptionAudience, &["audience"]), + (JwtField::OptionSubject, &["subject"]), + (JwtField::OptionNonce, &["nonce"]), + (JwtField::OptionClockTolerance, &["clockTolerance"]), + ( + JwtField::OptionClockTimestamp, + &["currentDate", "clockTimestamp"], + ), + ] + } else { + &[ + (JwtField::OptionAlgorithms, &["algorithms", "algorithm"]), + (JwtField::OptionIssuer, &["issuer"]), + (JwtField::OptionAudience, &["audience"]), + (JwtField::OptionSubject, &["subject"]), + (JwtField::OptionNonce, &["nonce"]), + (JwtField::OptionClockTolerance, &["clockTolerance"]), + (JwtField::OptionClockTimestamp, &["clockTimestamp"]), + (JwtField::OptionComplete, &["complete"]), + (JwtField::OptionIgnoreNotBefore, &["ignoreNotBefore"]), + (JwtField::OptionIgnoreExpiration, &["ignoreExpiration"]), + ] + } +} + fn add_decode_without_verify_fields( fields: &mut BTreeMap, line: usize, @@ -1509,12 +1613,24 @@ fn add_python_decode_fields( } if let Some(value) = python_keyword_value(argument_nodes, source, "algorithms") { add_present_node(fields, JwtField::Algorithm, value, source); + add_present_node(fields, JwtField::OptionAlgorithms, value, source); } if let Some(value) = python_keyword_value(argument_nodes, source, "issuer") { add_present_node(fields, JwtField::Issuer, value, source); + add_present_node(fields, JwtField::OptionIssuer, value, source); } if let Some(value) = python_keyword_value(argument_nodes, source, "audience") { add_present_node(fields, JwtField::Audience, value, source); + add_present_node(fields, JwtField::OptionAudience, value, source); + } + if let Some(value) = python_keyword_value(argument_nodes, source, "subject") { + add_present_node(fields, JwtField::OptionSubject, value, source); + } + if let Some(value) = python_keyword_value(argument_nodes, source, "nonce") { + add_present_node(fields, JwtField::OptionNonce, value, source); + } + if let Some(value) = python_keyword_value(argument_nodes, source, "leeway") { + add_present_node(fields, JwtField::OptionClockTolerance, value, source); } if let Some(options) = python_keyword_value(argument_nodes, source, "options") { add_python_options_fields(fields, source, options, option_aliases, line, column); @@ -1574,9 +1690,44 @@ fn add_python_options_fields( line: usize, column: usize, ) { + let option_fields = [ + JwtField::Issuer, + JwtField::Audience, + JwtField::Algorithm, + JwtField::OptionIssuer, + JwtField::OptionAudience, + JwtField::OptionAlgorithms, + JwtField::OptionSubject, + JwtField::OptionNonce, + JwtField::OptionClockTolerance, + JwtField::OptionClockTimestamp, + JwtField::OptionComplete, + JwtField::OptionIgnoreNotBefore, + JwtField::OptionIgnoreExpiration, + ]; if is_dictionary(node) { + for (field, names) in [ + ( + JwtField::OptionAlgorithms, + &["algorithms", "algorithm"] as &[_], + ), + (JwtField::OptionIssuer, &["issuer", "iss"]), + (JwtField::OptionAudience, &["audience", "aud"]), + (JwtField::OptionSubject, &["subject", "sub"]), + (JwtField::OptionNonce, &["nonce"]), + ( + JwtField::OptionClockTolerance, + &["leeway", "clockTolerance"], + ), + (JwtField::OptionClockTimestamp, &["clockTimestamp"]), + (JwtField::OptionComplete, &["complete"]), + (JwtField::OptionIgnoreNotBefore, &["verify_nbf"]), + (JwtField::OptionIgnoreExpiration, &["verify_exp"]), + ] { + add_object_field(fields, field, node, source, names, line, column); + } if object_has_dynamic_spread(node, source) { - for field in [JwtField::Issuer, JwtField::Audience, JwtField::Algorithm] { + for field in option_fields { fields .entry(field) .or_insert_with(|| dynamic_field(field, line, column)); @@ -1585,7 +1736,7 @@ fn add_python_options_fields( } else if node.kind() == "identifier" { let alias_name = node_text(node, source); if let Some(alias) = lookup_alias(aliases, &alias_name, node) { - for field in [JwtField::Issuer, JwtField::Audience, JwtField::Algorithm] { + for field in option_fields { if let Some(value) = alias.fields.get(&field) { fields.entry(field).or_insert_with(|| value.clone()); } else if alias.dynamic { @@ -1595,7 +1746,7 @@ fn add_python_options_fields( } } } else { - for field in [JwtField::Issuer, JwtField::Audience, JwtField::Algorithm] { + for field in option_fields { fields .entry(field) .or_insert_with(|| dynamic_field(field, line, column)); @@ -1961,6 +2112,31 @@ fn field_set_from_object(node: Node<'_>, source: &str) -> FieldSet { (JwtField::Audience, &["audience", "aud"][..]), (JwtField::Expiration, &["expiresIn", "expires", "exp"][..]), (JwtField::ExpiryEnforcement, &["maxAge", "maxTokenAge"][..]), + ( + JwtField::OptionAlgorithms, + &["algorithm", "algorithms", "alg"][..], + ), + (JwtField::OptionIssuer, &["issuer", "iss"][..]), + (JwtField::OptionAudience, &["audience", "aud"][..]), + (JwtField::OptionSubject, &["subject", "sub"][..]), + (JwtField::OptionNonce, &["nonce"][..]), + ( + JwtField::OptionClockTolerance, + &["clockTolerance", "leeway"][..], + ), + ( + JwtField::OptionClockTimestamp, + &["clockTimestamp", "currentDate"][..], + ), + (JwtField::OptionComplete, &["complete"][..]), + ( + JwtField::OptionIgnoreNotBefore, + &["ignoreNotBefore", "verify_nbf"][..], + ), + ( + JwtField::OptionIgnoreExpiration, + &["ignoreExpiration", "verify_exp"][..], + ), ] { add_object_field(&mut fields, field, node, source, names, 1, 1); } @@ -2928,6 +3104,138 @@ export function verifyLegacyJwt(token: string) { ); } + #[test] + fn emits_jsonwebtoken_verify_option_evidence() { + let output = detect( + Language::TypeScript, + r#" +import jwt from "jsonwebtoken"; +export function verifyAccessJwt(token: string) { + return jwt.verify(token, publicKey, { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + subject: SUBJECT, + nonce: expectedNonce, + clockTolerance: 120, + clockTimestamp: now, + complete: true, + ignoreNotBefore: true, + ignoreExpiration: false, + }); +} +"#, + ); + + for detector_id in [ + "jwt.option.algorithms", + "jwt.option.issuer", + "jwt.option.audience", + "jwt.option.subject", + "jwt.option.nonce", + "jwt.option.clock_tolerance", + "jwt.option.clock_timestamp", + "jwt.option.complete", + "jwt.option.ignore_not_before", + "jwt.option.ignore_expiration", + ] { + assert!( + output + .evidence + .iter() + .any(|evidence| evidence.detector_id == detector_id), + "missing {detector_id} in {:?}", + output + .evidence + .iter() + .map(|evidence| evidence.detector_id.as_str()) + .collect::>() + ); + } + } + + #[test] + fn emits_jose_verify_option_evidence() { + let output = detect( + Language::TypeScript, + r#" +import { jwtVerify } from "jose"; +export async function verifyAccessJwt(token: string) { + return jwtVerify(token, publicKey, { + algorithms: ["RS256"], + issuer, + audience, + subject, + nonce, + clockTolerance: "30s", + currentDate: now, + }); +} +"#, + ); + + for detector_id in [ + "jwt.option.algorithms", + "jwt.option.issuer", + "jwt.option.audience", + "jwt.option.subject", + "jwt.option.nonce", + "jwt.option.clock_tolerance", + "jwt.option.clock_timestamp", + ] { + assert!( + output + .evidence + .iter() + .any(|evidence| evidence.detector_id == detector_id), + "missing {detector_id}" + ); + } + } + + #[test] + fn emits_pyjwt_verify_option_evidence() { + let output = detect( + Language::Python, + r#" +import jwt + +def verify_access_jwt(token): + return jwt.decode( + token, + key=PUBLIC_KEY, + algorithms=["RS256"], + issuer=ISSUER, + audience=AUDIENCE, + subject=SUBJECT, + nonce=NONCE, + leeway=120, + options={"verify_nbf": False, "verify_exp": True, "complete": True}, + ) +"#, + ); + + for detector_id in [ + "jwt.option.algorithms", + "jwt.option.issuer", + "jwt.option.audience", + "jwt.option.subject", + "jwt.option.nonce", + "jwt.option.clock_tolerance", + "jwt.option.complete", + "jwt.option.ignore_not_before", + "jwt.option.ignore_expiration", + ] { + assert!( + output + .evidence + .iter() + .any(|evidence| evidence.detector_id == detector_id), + "missing {detector_id}" + ); + } + } + #[test] fn detects_jsonwebtoken_identity_claims_from_payload_alias() { let output = detect( From 29e366c8f40f16e9a044e182643cef43bcf8d9a3 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:24:52 -0500 Subject: [PATCH 02/29] =?UTF-8?q?Fix=20#71:=20P2.2=20Classifier=20?= =?UTF-8?q?=E2=80=94=20JWT=20alg:none=20acceptance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-classifier/src/jwt.rs | 272 +++++++++++++++++++++- 1 file changed, 266 insertions(+), 6 deletions(-) diff --git a/crates/sessionscope-classifier/src/jwt.rs b/crates/sessionscope-classifier/src/jwt.rs index f967829..0997303 100644 --- a/crates/sessionscope-classifier/src/jwt.rs +++ b/crates/sessionscope-classifier/src/jwt.rs @@ -1,6 +1,6 @@ use sessionscope_model::{ - Artifact, EvidenceId, Finding, FindingCategory, JwtAttributeObservation, JwtAttributeState, - ScanReport, Severity, stable_finding_id, + Artifact, Evidence, EvidenceId, Finding, FindingCategory, JwtAttributeObservation, + JwtAttributeState, ScanReport, Severity, stable_finding_id, }; pub fn classify(report: &ScanReport) -> Vec { @@ -13,6 +13,10 @@ pub fn classify(report: &ScanReport) -> Vec { let name = artifact.display_name.as_deref().unwrap_or("unknown_jwt"); + if !artifact.lifecycle_evidence.validate.is_empty() { + findings.extend(classify_alg_none(report, artifact, name, attributes)); + } + if operation_contains(&attributes.operation, "decode_without_verify") || attributes.signature_verification.state == JwtAttributeState::Missing { @@ -181,6 +185,113 @@ pub fn classify(report: &ScanReport) -> Vec { findings } +fn classify_alg_none( + report: &ScanReport, + artifact: &Artifact, + name: &str, + attributes: &sessionscope_model::JwtAttributes, +) -> Option { + let algorithm_option = artifact_bound_evidence(report, artifact, "jwt.option.algorithms"); + let has_dynamic_algorithm_option = algorithm_option.iter().any(|evidence| evidence.dynamic); + let has_literal_none_option = algorithm_option.iter().any(evidence_mentions_none); + let has_literal_none_attribute = observation_contains_literal(&attributes.algorithm, "none"); + + if has_literal_none_option || has_literal_none_attribute { + let mut evidence_ids = algorithm_option + .iter() + .map(|evidence| evidence.id.clone()) + .collect::>(); + evidence_ids.extend(attributes.algorithm.evidence_ids.clone()); + evidence_ids.sort(); + evidence_ids.dedup(); + return Some(finding( + artifact, + FindingRequest { + rule_id: "jwt_alg_none_accepted".to_string(), + category: FindingCategory::HighConfidenceMisconfiguration, + severity: Severity::High, + evidence_ids: fallback_ids(&evidence_ids, &artifact.lifecycle_evidence.validate), + title: format!("JWT `{name}` validation accepts the `none` algorithm"), + description: + "JWT verification evidence includes an explicit `none` algorithm allow-list entry." + .to_string(), + suggested_fix: + "Remove `none` from accepted algorithms and pin the expected signing algorithm." + .to_string(), + reviewer_question: + "Is any validation path intentionally accepting unsigned JWTs?".to_string(), + }, + )); + } + + if has_dynamic_algorithm_option || attributes.algorithm.state == JwtAttributeState::Dynamic { + return None; + } + + if attributes.algorithm.state == JwtAttributeState::Unknown + && library_has_default_alg_none_risk(artifact) + { + return Some(finding( + artifact, + FindingRequest { + rule_id: "jwt_alg_none_accepted".to_string(), + category: FindingCategory::FrameworkDefaultAssumed, + severity: Severity::Medium, + evidence_ids: artifact.lifecycle_evidence.validate.clone(), + title: format!("JWT `{name}` validation relies on JWT algorithm defaults"), + description: + "JWT verification evidence does not pin accepted algorithms for a library path with historically configuration-dependent `none` handling." + .to_string(), + suggested_fix: + "Pass an explicit safe algorithms allow-list when verifying JWTs." + .to_string(), + reviewer_question: + "Which library version and configuration define the accepted JWT algorithms here?" + .to_string(), + }, + )); + } + + None +} + +fn artifact_bound_evidence<'a>( + report: &'a ScanReport, + artifact: &Artifact, + detector_id: &str, +) -> Vec<&'a Evidence> { + report + .evidence + .iter() + .filter(|evidence| evidence.detector_id == detector_id) + .filter(|evidence| artifact.lifecycle_evidence.validate.contains(&evidence.id)) + .collect() +} + +fn evidence_mentions_none(evidence: &&Evidence) -> bool { + evidence.excerpt.as_ref().is_some_and(|excerpt| { + text_mentions_literal(&excerpt.as_str().to_ascii_lowercase(), "none") + }) +} + +fn observation_contains_literal(observation: &JwtAttributeObservation, literal: &str) -> bool { + observation.value.as_ref().is_some_and(|value| { + text_mentions_literal(&value.to_ascii_lowercase(), &literal.to_ascii_lowercase()) + }) +} + +fn text_mentions_literal(text: &str, literal: &str) -> bool { + text.split(|character: char| !character.is_ascii_alphanumeric()) + .any(|part| part == literal) +} + +fn library_has_default_alg_none_risk(artifact: &Artifact) -> bool { + artifact + .framework_hints + .iter() + .any(|hint| matches!(hint.as_str(), "jsonwebtoken" | "pyjwt" | "PyJWT" | "jwt")) +} + fn classify_validation_field( artifact: &Artifact, name: &str, @@ -291,19 +402,23 @@ fn operation_contains(operation: &JwtAttributeObservation, needle: &str) -> bool #[cfg(test)] mod tests { use sessionscope_model::{ - ArtifactId, ArtifactType, Confidence, JwtAttributes, LifecycleEvidence, SCHEMA_VERSION, - ScanSummary, SourceLocation, + ArtifactId, ArtifactType, Confidence, JwtAttributes, LifecycleEvidence, LifecycleStage, + SCHEMA_VERSION, SanitizedExcerpt, ScanSummary, SourceLocation, }; use super::*; fn classify_artifact(artifact: Artifact) -> Vec { + classify_report(vec![artifact], Vec::new()) + } + + fn classify_report(artifacts: Vec, evidence: Vec) -> Vec { classify(&ScanReport { schema_version: SCHEMA_VERSION.to_string(), summary: ScanSummary::default(), files: Vec::new(), - artifacts: vec![artifact], - evidence: Vec::new(), + artifacts, + evidence, lifecycle_paths: Vec::new(), findings: Vec::new(), }) @@ -410,6 +525,27 @@ mod tests { } } + fn option_evidence(id: &str, detector_id: &str, excerpt: &str, dynamic: bool) -> Evidence { + Evidence { + id: EvidenceId(id.to_string()), + lifecycle_stage: LifecycleStage::Validate, + location: SourceLocation { + path: "auth.ts".to_string(), + line: Some(1), + column: Some(1), + }, + detector_id: detector_id.to_string(), + confidence: if dynamic { + Confidence::Medium + } else { + Confidence::High + }, + excerpt: Some(SanitizedExcerpt::from_sanitized(excerpt.to_string())), + dynamic, + framework_default: false, + } + } + #[test] fn missing_issuer_and_audience_on_verify_are_missing_validation_evidence() { let findings = classify_artifact(artifact( @@ -434,6 +570,130 @@ mod tests { ); } + #[test] + fn literal_none_algorithm_is_high_confidence() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Present, + Some("none"), + EvidenceId("evidence_algorithm".to_string()), + ); + let mut artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_option_algorithms".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + artifact.framework_hints = vec!["jsonwebtoken".to_string()]; + let findings = classify_report( + vec![artifact], + vec![option_evidence( + "evidence_option_algorithms", + "jwt.option.algorithms", + r#"algorithms: ["none"]"#, + false, + )], + ); + + let finding = findings + .iter() + .find(|finding| finding.title.contains("`none` algorithm")) + .expect("alg none finding"); + assert_eq!( + finding.category, + FindingCategory::HighConfidenceMisconfiguration + ); + assert_eq!(finding.severity, Severity::High); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_option_algorithms".to_string())) + ); + } + + #[test] + fn missing_algorithm_allowlist_on_default_sensitive_library_is_framework_default() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Unknown, + None, + EvidenceId("evidence_algorithm".to_string()), + ); + let mut artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + ); + artifact.framework_hints = vec!["pyjwt".to_string()]; + + let findings = classify_artifact(artifact); + + assert!(findings.iter().any(|finding| { + finding.title.contains("algorithm defaults") + && finding.category == FindingCategory::FrameworkDefaultAssumed + && finding.severity == Severity::Medium + })); + } + + #[test] + fn dynamic_algorithm_options_do_not_emit_alg_none_finding() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Dynamic, + None, + EvidenceId("evidence_algorithm".to_string()), + ); + let artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_option_algorithms".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![option_evidence( + "evidence_option_algorithms", + "jwt.option.algorithms", + "JWT algorithms option depends on unresolved JWT options", + true, + )], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("`none` algorithm")) + ); + } + #[test] fn dynamic_audience_requires_review_not_high_confidence() { let findings = classify_artifact(artifact( From bd0e318b13cf2781f22df18ac26e4bd99020c7f5 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:26:58 -0500 Subject: [PATCH 03/29] =?UTF-8?q?Fix=20#72:=20P2.3=20Classifier=20?= =?UTF-8?q?=E2=80=94=20JWT=20algorithm-confusion=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-classifier/src/jwt.rs | 324 ++++++++++++++++++++++ 1 file changed, 324 insertions(+) diff --git a/crates/sessionscope-classifier/src/jwt.rs b/crates/sessionscope-classifier/src/jwt.rs index 0997303..cda8090 100644 --- a/crates/sessionscope-classifier/src/jwt.rs +++ b/crates/sessionscope-classifier/src/jwt.rs @@ -15,6 +15,7 @@ pub fn classify(report: &ScanReport) -> Vec { if !artifact.lifecycle_evidence.validate.is_empty() { findings.extend(classify_alg_none(report, artifact, name, attributes)); + findings.extend(classify_alg_confusion(report, artifact, name, attributes)); } if operation_contains(&attributes.operation, "decode_without_verify") @@ -255,6 +256,148 @@ fn classify_alg_none( None } +fn classify_alg_confusion( + report: &ScanReport, + artifact: &Artifact, + name: &str, + attributes: &sessionscope_model::JwtAttributes, +) -> Option { + let algorithm_option = artifact_bound_evidence(report, artifact, "jwt.option.algorithms"); + let algorithms = accepted_algorithms(&algorithm_option, &attributes.algorithm); + if algorithms.is_empty() { + return None; + } + + let accepts_hmac = algorithms + .iter() + .any(|algorithm| is_hmac_algorithm(algorithm)); + let accepts_asymmetric = algorithms + .iter() + .any(|algorithm| is_asymmetric_algorithm(algorithm)); + let public_key_reference = public_key_like_reference(&attributes.key_reference); + + if accepts_hmac && accepts_asymmetric { + return Some(finding( + artifact, + FindingRequest { + rule_id: "jwt_alg_confusion_signal".to_string(), + category: FindingCategory::HighConfidenceMisconfiguration, + severity: Severity::High, + evidence_ids: algorithm_and_key_evidence_ids(&algorithm_option, attributes), + title: format!( + "JWT `{name}` validation accepts both HMAC and asymmetric algorithms" + ), + description: + "JWT verification evidence allows both symmetric HMAC and asymmetric algorithms on the same validation path." + .to_string(), + suggested_fix: + "Pin one expected algorithm family for this key type and separate symmetric and asymmetric validation paths." + .to_string(), + reviewer_question: + "Can an attacker influence the token algorithm header on this validation path?" + .to_string(), + }, + )); + } + + if accepts_hmac && public_key_reference { + return Some(finding( + artifact, + FindingRequest { + rule_id: "jwt_alg_confusion_signal".to_string(), + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids: algorithm_and_key_evidence_ids(&algorithm_option, attributes), + title: format!( + "JWT `{name}` validation pairs HMAC algorithms with public-key-like material" + ), + description: + "JWT verification evidence accepts an HMAC algorithm while the key reference appears public-key-like." + .to_string(), + suggested_fix: + "Confirm the key material type and pin algorithms that match the expected key family." + .to_string(), + reviewer_question: + "Is this key reference a symmetric secret or an asymmetric public key?".to_string(), + }, + )); + } + + None +} + +fn accepted_algorithms( + algorithm_option: &[&Evidence], + observation: &JwtAttributeObservation, +) -> Vec { + let mut algorithms = Vec::new(); + for evidence in algorithm_option { + if let Some(excerpt) = &evidence.excerpt { + algorithms.extend(algorithm_tokens(excerpt.as_str())); + } + } + if let Some(value) = &observation.value { + algorithms.extend(algorithm_tokens(value)); + } + algorithms.sort(); + algorithms.dedup(); + algorithms +} + +fn algorithm_tokens(text: &str) -> Vec { + text.split(|character: char| !character.is_ascii_alphanumeric()) + .filter_map(|part| { + let upper = part.to_ascii_uppercase(); + (is_hmac_algorithm(&upper) || is_asymmetric_algorithm(&upper) || upper == "NONE") + .then_some(upper) + }) + .collect() +} + +fn is_hmac_algorithm(algorithm: &str) -> bool { + matches!(algorithm, "HS256" | "HS384" | "HS512") +} + +fn is_asymmetric_algorithm(algorithm: &str) -> bool { + matches!( + algorithm, + "RS256" | "RS384" | "RS512" | "ES256" | "ES384" | "ES512" | "PS256" | "PS384" | "PS512" + ) +} + +fn public_key_like_reference(observation: &JwtAttributeObservation) -> bool { + let Some(value) = observation.value.as_ref() else { + return false; + }; + let normalized = value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '.') + .collect::() + .to_ascii_lowercase(); + if normalized.contains("secret") || normalized.contains("private") { + return false; + } + normalized.contains("publickey") + || normalized.contains("pubkey") + || normalized.contains("loadpublickey") + || normalized.ends_with(".pem") +} + +fn algorithm_and_key_evidence_ids( + algorithm_option: &[&Evidence], + attributes: &sessionscope_model::JwtAttributes, +) -> Vec { + let mut evidence_ids = algorithm_option + .iter() + .map(|evidence| evidence.id.clone()) + .collect::>(); + evidence_ids.extend(attributes.algorithm.evidence_ids.clone()); + evidence_ids.extend(attributes.key_reference.evidence_ids.clone()); + evidence_ids.sort(); + evidence_ids.dedup(); + evidence_ids +} + fn artifact_bound_evidence<'a>( report: &'a ScanReport, artifact: &Artifact, @@ -694,6 +837,187 @@ mod tests { ); } + #[test] + fn mixed_hmac_and_asymmetric_algorithms_signal_confusion() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Present, + Some("HS256, RS256"), + EvidenceId("evidence_algorithm".to_string()), + ); + attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("verificationKey"), + EvidenceId("evidence_key".to_string()), + ); + let artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_option_algorithms".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + + let findings = classify_report( + vec![artifact], + vec![option_evidence( + "evidence_option_algorithms", + "jwt.option.algorithms", + r#"algorithms: ["HS256", "RS256"]"#, + false, + )], + ); + + let finding = findings + .iter() + .find(|finding| finding.title.contains("HMAC and asymmetric")) + .expect("algorithm confusion finding"); + assert_eq!( + finding.category, + FindingCategory::HighConfidenceMisconfiguration + ); + assert_eq!(finding.severity, Severity::High); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_option_algorithms".to_string())) + ); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_key".to_string())) + ); + } + + #[test] + fn public_key_like_reference_with_hmac_requires_review() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Present, + Some("HS256"), + EvidenceId("evidence_algorithm".to_string()), + ); + attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("publicKey"), + EvidenceId("evidence_key".to_string()), + ); + let artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + ); + + let findings = classify_artifact(artifact); + + assert!(findings.iter().any(|finding| { + finding.title.contains("public-key-like") + && finding.category == FindingCategory::DynamicReviewRequired + && finding.severity == Severity::Medium + })); + } + + #[test] + fn matching_key_family_algorithms_do_not_signal_confusion() { + let hs_secret = classify_artifact(artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + )); + + let mut rs_attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + rs_attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Present, + Some("RS256"), + EvidenceId("evidence_algorithm".to_string()), + ); + rs_attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("publicKey"), + EvidenceId("evidence_key".to_string()), + ); + let rs_public = classify_artifact(artifact( + rs_attributes, + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + )); + + assert!(hs_secret.iter().chain(rs_public.iter()).all(|finding| { + !finding.title.contains("HMAC and asymmetric") + && !finding.title.contains("public-key-like") + })); + } + + #[test] + fn public_secret_name_does_not_signal_public_key_confusion() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.algorithm = observation( + "algorithm", + JwtAttributeState::Present, + Some("HS256"), + EvidenceId("evidence_algorithm".to_string()), + ); + attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("publicSecret"), + EvidenceId("evidence_key".to_string()), + ); + let findings = classify_artifact(artifact( + attributes, + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + )); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("public-key-like")) + ); + } + #[test] fn dynamic_audience_requires_review_not_high_confidence() { let findings = classify_artifact(artifact( From ddc731f884becaf5d603d969ec5ecc50a0387981 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:30:07 -0500 Subject: [PATCH 04/29] =?UTF-8?q?Fix=20#73:=20P2.4=20Classifier=20?= =?UTF-8?q?=E2=80=94=20JWT=20header-trust=20risks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-classifier/src/jwt.rs | 166 +++++++++++++++++++ crates/sessionscope-detectors/src/jwt/mod.rs | 105 +++++++++++- 2 files changed, 270 insertions(+), 1 deletion(-) diff --git a/crates/sessionscope-classifier/src/jwt.rs b/crates/sessionscope-classifier/src/jwt.rs index cda8090..1904c25 100644 --- a/crates/sessionscope-classifier/src/jwt.rs +++ b/crates/sessionscope-classifier/src/jwt.rs @@ -16,6 +16,7 @@ pub fn classify(report: &ScanReport) -> Vec { if !artifact.lifecycle_evidence.validate.is_empty() { findings.extend(classify_alg_none(report, artifact, name, attributes)); findings.extend(classify_alg_confusion(report, artifact, name, attributes)); + findings.extend(classify_header_trust(report, artifact, name)); } if operation_contains(&attributes.operation, "decode_without_verify") @@ -326,6 +327,57 @@ fn classify_alg_confusion( None } +fn classify_header_trust(report: &ScanReport, artifact: &Artifact, name: &str) -> Vec { + let complete_evidence = artifact_bound_evidence(report, artifact, "jwt.option.complete"); + if !complete_evidence.iter().any(evidence_mentions_true) { + return Vec::new(); + } + + [ + ("jku", "jwt_jku_header_trust", "jku URL"), + ("x5u", "jwt_x5u_header_trust", "x5u URL"), + ("jwk", "jwt_embedded_jwk_trust", "embedded JWK"), + ] + .into_iter() + .filter_map(|(header_name, rule_id, label)| { + let header_evidence = artifact_bound_evidence( + report, + artifact, + &format!("jwt.header.{header_name}"), + ); + if header_evidence.is_empty() { + return None; + } + let mut evidence_ids = complete_evidence + .iter() + .chain(header_evidence.iter()) + .map(|evidence| evidence.id.clone()) + .collect::>(); + evidence_ids.sort(); + evidence_ids.dedup(); + Some(finding( + artifact, + FindingRequest { + rule_id: rule_id.to_string(), + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids, + title: format!("JWT `{name}` validation trusts the {label} header"), + description: format!( + "JWT verification parses complete token headers and reads `{header_name}` for key resolution context." + ), + suggested_fix: + "Constrain header-driven key resolution to an explicit allow-list or pinned key source." + .to_string(), + reviewer_question: format!( + "Is the `{header_name}` header constrained to trusted key material before use?" + ), + }, + )) + }) + .collect() +} + fn accepted_algorithms( algorithm_option: &[&Evidence], observation: &JwtAttributeObservation, @@ -417,6 +469,12 @@ fn evidence_mentions_none(evidence: &&Evidence) -> bool { }) } +fn evidence_mentions_true(evidence: &&Evidence) -> bool { + evidence.excerpt.as_ref().is_some_and(|excerpt| { + text_mentions_literal(&excerpt.as_str().to_ascii_lowercase(), "true") + }) +} + fn observation_contains_literal(observation: &JwtAttributeObservation, literal: &str) -> bool { observation.value.as_ref().is_some_and(|value| { text_mentions_literal(&value.to_ascii_lowercase(), &literal.to_ascii_lowercase()) @@ -1018,6 +1076,114 @@ mod tests { ); } + #[test] + fn complete_token_header_trust_requires_review() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_complete".to_string()), + EvidenceId("evidence_jku".to_string()), + EvidenceId("evidence_x5u".to_string()), + EvidenceId("evidence_jwk".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_complete", + "jwt.option.complete", + "complete: true", + false, + ), + option_evidence( + "evidence_jku", + "jwt.header.jku", + "JWT header `jku` is read near verification logic", + false, + ), + option_evidence( + "evidence_x5u", + "jwt.header.x5u", + "JWT header `x5u` is read near verification logic", + false, + ), + option_evidence( + "evidence_jwk", + "jwt.header.jwk", + "JWT header `jwk` is read near verification logic", + false, + ), + ], + ); + + for title_fragment in ["jku URL", "x5u URL", "embedded JWK"] { + let finding = findings + .iter() + .find(|finding| finding.title.contains(title_fragment)) + .expect("header trust finding"); + assert_eq!(finding.category, FindingCategory::DynamicReviewRequired); + assert_eq!(finding.severity, Severity::Medium); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_complete".to_string())) + ); + } + } + + #[test] + fn header_reads_without_complete_true_do_not_fire_header_trust() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_complete".to_string()), + EvidenceId("evidence_jku".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_complete", + "jwt.option.complete", + "complete: false", + false, + ), + option_evidence( + "evidence_jku", + "jwt.header.jku", + "JWT header `jku` is read near verification logic", + false, + ), + ], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("jku URL")) + ); + } + #[test] fn dynamic_audience_requires_review_not_high_confidence() { let findings = classify_artifact(artifact( diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index 38883f3..97f0250 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -128,6 +128,10 @@ enum JwtField { OptionComplete, OptionIgnoreNotBefore, OptionIgnoreExpiration, + HeaderJku, + HeaderX5u, + HeaderJwk, + HeaderKid, } impl JwtField { @@ -201,6 +205,10 @@ impl JwtField { Self::OptionComplete => "complete", Self::OptionIgnoreNotBefore => "ignore_not_before", Self::OptionIgnoreExpiration => "ignore_expiration", + Self::HeaderJku => "jku", + Self::HeaderX5u => "x5u", + Self::HeaderJwk => "jwk", + Self::HeaderKid => "kid", } } @@ -236,6 +244,10 @@ impl JwtField { Self::OptionComplete => "JWT complete option", Self::OptionIgnoreNotBefore => "JWT ignore-not-before option", Self::OptionIgnoreExpiration => "JWT ignore-expiration option", + Self::HeaderJku => "JWT jku header", + Self::HeaderX5u => "JWT x5u header", + Self::HeaderJwk => "JWT embedded JWK header", + Self::HeaderKid => "JWT kid header", } } @@ -255,6 +267,13 @@ impl JwtField { ) } + fn is_header(self) -> bool { + matches!( + self, + Self::HeaderJku | Self::HeaderX5u | Self::HeaderJwk | Self::HeaderKid + ) + } + fn lifecycle_stage(self, operation: JwtOperation) -> LifecycleStage { match self { Self::Expiration => LifecycleStage::Expire, @@ -267,7 +286,11 @@ impl JwtField { | Self::OptionClockTimestamp | Self::OptionComplete | Self::OptionIgnoreNotBefore - | Self::OptionIgnoreExpiration => LifecycleStage::Validate, + | Self::OptionIgnoreExpiration + | Self::HeaderJku + | Self::HeaderX5u + | Self::HeaderJwk + | Self::HeaderKid => LifecycleStage::Validate, Self::ExpiryEnforcement | Self::SignatureVerification => LifecycleStage::Validate, _ => operation.lifecycle_stage(), } @@ -472,6 +495,8 @@ fn calls_to_output( let state_part = jwt_state_part(observation.state); let evidence_kind = if field.is_option() { "jwt_option" + } else if field.is_header() { + "jwt_header" } else { "jwt_attribute" }; @@ -501,6 +526,8 @@ fn calls_to_output( }, detector_id: if field.is_option() { format!("jwt.option.{}", field.wire_name()) + } else if field.is_header() { + format!("jwt.header.{}", field.wire_name()) } else { format!("jwt.attribute.{}", field.wire_name()) }, @@ -1201,6 +1228,39 @@ fn add_js_verify_fields( } else { add_missing_for_verify_fields(fields, line, column); } + add_header_trust_fields(fields, source, line, column); +} + +fn add_header_trust_fields( + fields: &mut BTreeMap, + source: &str, + line: usize, + column: usize, +) { + for (field, header_name) in [ + (JwtField::HeaderJku, "jku"), + (JwtField::HeaderX5u, "x5u"), + (JwtField::HeaderJwk, "jwk"), + (JwtField::HeaderKid, "kid"), + ] { + if header_name_is_read(source, header_name) { + add_present_value( + fields, + field, + header_name, + line, + column, + format!("JWT header `{header_name}` is read near verification logic"), + ); + } + } +} + +fn header_name_is_read(source: &str, header_name: &str) -> bool { + let dot = format!(".{header_name}"); + let single = format!("['{header_name}']"); + let double = format!("[\"{header_name}\"]"); + source.contains(&dot) || source.contains(&single) || source.contains(&double) } fn js_verify_option_fields(jose: bool) -> &'static [(JwtField, &'static [&'static str])] { @@ -1637,6 +1697,7 @@ fn add_python_decode_fields( } add_python_expiry_enforcement(fields, source, argument_nodes, option_aliases, line, column); add_missing_for_verify_fields(fields, line, column); + add_header_trust_fields(fields, source, line, column); } fn add_python_decode_without_verify_fields( @@ -3236,6 +3297,48 @@ def verify_access_jwt(token): } } + #[test] + fn emits_jwt_header_read_evidence_near_verification() { + let ts_output = detect( + Language::TypeScript, + r#" +import jwt from "jsonwebtoken"; +export function verifyAccessJwt(token: string) { + const decoded = jwt.verify(token, getKey, { complete: true, algorithms: ["RS256"] }); + return resolveKey(decoded.header.jku, decoded.header.x5u, decoded.header.jwk, decoded.header.kid); +} +"#, + ); + let py_output = detect( + Language::Python, + r#" +import jwt + +def verify_access_jwt(token): + decoded = jwt.decode(token, key=PUBLIC_KEY, algorithms=["RS256"], options={"complete": True}) + return resolve_key(decoded["header"]["jku"], decoded["header"]["x5u"], decoded["header"]["jwk"]) +"#, + ); + + let detector_ids = ts_output + .evidence + .iter() + .chain(py_output.evidence.iter()) + .map(|evidence| evidence.detector_id.as_str()) + .collect::>(); + for detector_id in [ + "jwt.header.jku", + "jwt.header.x5u", + "jwt.header.jwk", + "jwt.header.kid", + ] { + assert!( + detector_ids.contains(&detector_id), + "missing {detector_id} in {detector_ids:?}" + ); + } + } + #[test] fn detects_jsonwebtoken_identity_claims_from_payload_alias() { let output = detect( From 15707163670c9d8766692c1ccd86470d9172aa88 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:33:10 -0500 Subject: [PATCH 05/29] =?UTF-8?q?Fix=20#74:=20P2.5=20Classifier=20?= =?UTF-8?q?=E2=80=94=20nbf,=20clock-skew,=20kid=20validation=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-classifier/src/jwt.rs | 379 ++++++++++++++++++++++ 1 file changed, 379 insertions(+) diff --git a/crates/sessionscope-classifier/src/jwt.rs b/crates/sessionscope-classifier/src/jwt.rs index 1904c25..a1c7122 100644 --- a/crates/sessionscope-classifier/src/jwt.rs +++ b/crates/sessionscope-classifier/src/jwt.rs @@ -17,6 +17,7 @@ pub fn classify(report: &ScanReport) -> Vec { findings.extend(classify_alg_none(report, artifact, name, attributes)); findings.extend(classify_alg_confusion(report, artifact, name, attributes)); findings.extend(classify_header_trust(report, artifact, name)); + findings.extend(classify_nbf_clock_kid(report, artifact, name)); } if operation_contains(&attributes.operation, "decode_without_verify") @@ -378,6 +379,140 @@ fn classify_header_trust(report: &ScanReport, artifact: &Artifact, name: &str) - .collect() } +fn classify_nbf_clock_kid(report: &ScanReport, artifact: &Artifact, name: &str) -> Vec { + let mut findings = Vec::new(); + if report.evidence.is_empty() { + return findings; + } + + let nbf_evidence = artifact_bound_evidence(report, artifact, "jwt.option.ignore_not_before"); + if nbf_evidence.is_empty() || nbf_evidence.iter().any(nbf_is_ignored) { + let evidence_ids = if nbf_evidence.is_empty() { + artifact.lifecycle_evidence.validate.clone() + } else { + nbf_evidence + .iter() + .map(|evidence| evidence.id.clone()) + .collect() + }; + findings.push(finding( + artifact, + FindingRequest { + rule_id: "jwt_nbf_missing".to_string(), + category: FindingCategory::MissingValidationEvidence, + severity: Severity::Low, + evidence_ids, + title: format!("JWT `{name}` verification has no not-before validation evidence"), + description: + "JWT verification evidence does not show `nbf` / not-before claim enforcement." + .to_string(), + suggested_fix: + "Require or enforce `nbf` validation where issuer policy uses not-before claims." + .to_string(), + reviewer_question: + "Do issued tokens for this path use `nbf`, and is it enforced in production?" + .to_string(), + }, + )); + } + + let clock_evidence = artifact_bound_evidence(report, artifact, "jwt.option.clock_tolerance"); + if clock_evidence.iter().any(|evidence| { + evidence.dynamic || clock_tolerance_seconds(evidence).is_some_and(|v| v > 60) + }) { + findings.push(finding( + artifact, + FindingRequest { + rule_id: "jwt_clock_skew_review".to_string(), + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids: clock_evidence + .iter() + .map(|evidence| evidence.id.clone()) + .collect(), + title: format!("JWT `{name}` validation uses broad or dynamic clock skew"), + description: + "JWT verification evidence sets clock tolerance above 60 seconds or from unresolved runtime input." + .to_string(), + suggested_fix: + "Keep JWT clock tolerance minimal and explicit unless issuer skew requirements justify it." + .to_string(), + reviewer_question: + "What production clock-skew tolerance is required for this issuer?".to_string(), + }, + )); + } + + let kid_evidence = artifact_bound_evidence(report, artifact, "jwt.header.kid"); + if !kid_evidence.is_empty() && !has_visible_kid_validation(report, artifact) { + findings.push(finding( + artifact, + FindingRequest { + rule_id: "jwt_kid_unvalidated_review".to_string(), + category: FindingCategory::MissingValidationEvidence, + severity: Severity::Medium, + evidence_ids: kid_evidence + .iter() + .map(|evidence| evidence.id.clone()) + .collect(), + title: format!("JWT `{name}` reads `kid` without visible validation"), + description: + "JWT header `kid` is read, but no source-visible allow-list or pinned key lookup evidence was found." + .to_string(), + suggested_fix: + "Validate `kid` against a pinned allow-list or trusted key map before using it for key selection." + .to_string(), + reviewer_question: + "Where is the accepted `kid` set constrained for this validation path?" + .to_string(), + }, + )); + } + + findings +} + +fn nbf_is_ignored(evidence: &&Evidence) -> bool { + let Some(excerpt) = &evidence.excerpt else { + return false; + }; + let text = excerpt.as_str().to_ascii_lowercase(); + (text.contains("ignorenotbefore") && text_mentions_literal(&text, "true")) + || (text.contains("verify_nbf") && text_mentions_literal(&text, "false")) +} + +fn clock_tolerance_seconds(evidence: &Evidence) -> Option { + let text = evidence.excerpt.as_ref()?.as_str(); + text.split(|character: char| !character.is_ascii_digit()) + .find(|part| !part.is_empty()) + .and_then(|part| part.parse::().ok()) +} + +fn has_visible_kid_validation(report: &ScanReport, artifact: &Artifact) -> bool { + artifact.jwt_attributes.as_ref().is_some_and(|attributes| { + attributes + .key_reference + .value + .as_deref() + .is_some_and(kid_key_reference_is_validated) + }) || report.evidence.iter().any(|evidence| { + artifact.lifecycle_evidence.validate.contains(&evidence.id) + && evidence.excerpt.as_ref().is_some_and(|excerpt| { + let text = excerpt.as_str().to_ascii_lowercase(); + text.contains("allowlist") || text.contains("allow-list") || text.contains("jwks") + }) + }) +} + +fn kid_key_reference_is_validated(value: &str) -> bool { + let normalized = value.to_ascii_lowercase(); + normalized.contains("allow") + || normalized.contains("keymap") + || normalized.contains("key_map") + || normalized.contains("jwks") + || normalized.contains("pinned") +} + fn accepted_algorithms( algorithm_option: &[&Evidence], observation: &JwtAttributeObservation, @@ -1184,6 +1319,250 @@ mod tests { ); } + #[test] + fn missing_nbf_evidence_is_missing_validation() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![EvidenceId("evidence_verify".to_string())], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![option_evidence( + "evidence_verify", + "jwt.validate", + "jsonwebtoken.verify validate call detected", + false, + )], + ); + + assert!(findings.iter().any(|finding| { + finding.title.contains("not-before") + && finding.category == FindingCategory::MissingValidationEvidence + && finding.severity == Severity::Low + })); + } + + #[test] + fn explicit_nbf_enforcement_does_not_fire_nbf_missing() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_nbf".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![option_evidence( + "evidence_nbf", + "jwt.option.ignore_not_before", + "ignoreNotBefore: false", + false, + )], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("not-before")) + ); + } + + #[test] + fn broad_or_dynamic_clock_skew_requires_review() { + let static_artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_clock".to_string()), + EvidenceId("evidence_nbf".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![static_artifact], + vec![ + option_evidence( + "evidence_clock", + "jwt.option.clock_tolerance", + "clockTolerance: 3600", + false, + ), + option_evidence( + "evidence_nbf", + "jwt.option.ignore_not_before", + "ignoreNotBefore: false", + false, + ), + ], + ); + + assert!(findings.iter().any(|finding| { + finding.title.contains("clock skew") + && finding.category == FindingCategory::DynamicReviewRequired + && finding.severity == Severity::Medium + })); + } + + #[test] + fn small_clock_skew_does_not_require_review() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_clock".to_string()), + EvidenceId("evidence_nbf".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_clock", + "jwt.option.clock_tolerance", + "clockTolerance: 60", + false, + ), + option_evidence( + "evidence_nbf", + "jwt.option.ignore_not_before", + "ignoreNotBefore: false", + false, + ), + ], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("clock skew")) + ); + } + + #[test] + fn kid_read_without_validation_is_missing_validation() { + let artifact = artifact( + attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ), + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_kid".to_string()), + EvidenceId("evidence_nbf".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_kid", + "jwt.header.kid", + "JWT header `kid` is read near verification logic", + false, + ), + option_evidence( + "evidence_nbf", + "jwt.option.ignore_not_before", + "ignoreNotBefore: false", + false, + ), + ], + ); + + assert!(findings.iter().any(|finding| { + finding.title.contains("`kid`") + && finding.category == FindingCategory::MissingValidationEvidence + && finding.severity == Severity::Medium + })); + } + + #[test] + fn kid_allowlist_key_reference_suppresses_kid_review() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("trustedKeyMap"), + EvidenceId("evidence_key".to_string()), + ); + let artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_kid".to_string()), + EvidenceId("evidence_nbf".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_kid", + "jwt.header.kid", + "JWT header `kid` is read near verification logic", + false, + ), + option_evidence( + "evidence_nbf", + "jwt.option.ignore_not_before", + "ignoreNotBefore: false", + false, + ), + ], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("`kid`")) + ); + } + #[test] fn dynamic_audience_requires_review_not_high_confidence() { let findings = classify_artifact(artifact( From 68a1e05ea7f88881ef48a18fa69ee86ed6642d15 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:39:55 -0500 Subject: [PATCH 06/29] =?UTF-8?q?Fix=20#75:=20P2.6=20Fixtures=20=E2=80=94?= =?UTF-8?q?=20JWT=20crypto-trust=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/sessionscope-detectors/src/jwt/mod.rs | 1 + crates/sessionscope-testing/src/fixtures.rs | 88 +++++++++++++++++++ .../jwt-crypto-trust-alg-confusion/auth.js | 16 ++++ .../expected.json | 9 ++ .../jwt-crypto-trust-alg-none/auth.js | 16 ++++ .../jwt-crypto-trust-alg-none/expected.json | 9 ++ .../jwt-crypto-trust-clock-skew/auth.py | 21 +++++ .../jwt-crypto-trust-clock-skew/expected.json | 9 ++ .../jwt-crypto-trust-kid/auth.py | 21 +++++ .../jwt-crypto-trust-kid/expected.json | 9 ++ .../jwt-crypto-trust-nbf/auth.py | 19 ++++ .../jwt-crypto-trust-nbf/expected.json | 9 ++ .../jwt-crypto-trust-embedded-jwk/auth.ts | 20 +++++ .../expected.json | 9 ++ .../generic-ts/jwt-crypto-trust-jku/auth.ts | 20 +++++ .../jwt-crypto-trust-jku/expected.json | 9 ++ .../generic-ts/jwt-crypto-trust-x5u/auth.ts | 20 +++++ .../jwt-crypto-trust-x5u/expected.json | 9 ++ 18 files changed, 314 insertions(+) create mode 100644 fixtures/generic-js/jwt-crypto-trust-alg-confusion/auth.js create mode 100644 fixtures/generic-js/jwt-crypto-trust-alg-confusion/expected.json create mode 100644 fixtures/generic-js/jwt-crypto-trust-alg-none/auth.js create mode 100644 fixtures/generic-js/jwt-crypto-trust-alg-none/expected.json create mode 100644 fixtures/generic-python/jwt-crypto-trust-clock-skew/auth.py create mode 100644 fixtures/generic-python/jwt-crypto-trust-clock-skew/expected.json create mode 100644 fixtures/generic-python/jwt-crypto-trust-kid/auth.py create mode 100644 fixtures/generic-python/jwt-crypto-trust-kid/expected.json create mode 100644 fixtures/generic-python/jwt-crypto-trust-nbf/auth.py create mode 100644 fixtures/generic-python/jwt-crypto-trust-nbf/expected.json create mode 100644 fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/auth.ts create mode 100644 fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/expected.json create mode 100644 fixtures/generic-ts/jwt-crypto-trust-jku/auth.ts create mode 100644 fixtures/generic-ts/jwt-crypto-trust-jku/expected.json create mode 100644 fixtures/generic-ts/jwt-crypto-trust-x5u/auth.ts create mode 100644 fixtures/generic-ts/jwt-crypto-trust-x5u/expected.json diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index 97f0250..abcf55d 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -1276,6 +1276,7 @@ fn js_verify_option_fields(jose: bool) -> &'static [(JwtField, &'static [&'stati JwtField::OptionClockTimestamp, &["currentDate", "clockTimestamp"], ), + (JwtField::OptionComplete, &["complete"]), ] } else { &[ diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index cffc772..7ec0577 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -499,6 +499,94 @@ mod tests { })); } + #[test] + fn jwt_crypto_trust_fixtures_emit_expected_findings() { + let cases = [ + ( + fixture_root() + .join("generic-js") + .join("jwt-crypto-trust-alg-none"), + "`none` algorithm", + ), + ( + fixture_root() + .join("generic-js") + .join("jwt-crypto-trust-alg-confusion"), + "HMAC and asymmetric", + ), + ( + fixture_root() + .join("generic-ts") + .join("jwt-crypto-trust-jku"), + "jku URL", + ), + ( + fixture_root() + .join("generic-ts") + .join("jwt-crypto-trust-x5u"), + "x5u URL", + ), + ( + fixture_root() + .join("generic-ts") + .join("jwt-crypto-trust-embedded-jwk"), + "embedded JWK", + ), + ( + fixture_root() + .join("generic-python") + .join("jwt-crypto-trust-nbf"), + "not-before", + ), + ( + fixture_root() + .join("generic-python") + .join("jwt-crypto-trust-clock-skew"), + "clock skew", + ), + ( + fixture_root() + .join("generic-python") + .join("jwt-crypto-trust-kid"), + "`kid`", + ), + ]; + + for (root, title_fragment) in cases { + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .unwrap_or_else(|error| panic!("{} should scan: {error}", root.display())), + ); + + assert!( + report + .findings + .iter() + .any(|finding| finding.title.contains(title_fragment)), + "{} should emit finding containing {title_fragment:?}. Findings: {:?}", + root.display(), + report + .findings + .iter() + .map(|finding| finding.title.as_str()) + .collect::>() + ); + + for format in [ + ReportFormat::Json, + ReportFormat::Markdown, + ReportFormat::Sarif, + ] { + let rendered = render(&report, format); + assert!(!rendered.contains("PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE")); + assert!(!rendered.contains("placeholder-key-id")); + } + } + } + #[test] fn logout_fixtures_emit_revoke_evidence() { let cases = [ diff --git a/fixtures/generic-js/jwt-crypto-trust-alg-confusion/auth.js b/fixtures/generic-js/jwt-crypto-trust-alg-confusion/auth.js new file mode 100644 index 0000000..47b4f23 --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-alg-confusion/auth.js @@ -0,0 +1,16 @@ +const jwt = require("jsonwebtoken"); + +const PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +function verifyAccessJwt(token) { + return jwt.verify(token, PUBLIC_KEY, { + algorithms: ["HS256", "RS256"], + issuer: ISSUER, + audience: AUDIENCE, + ignoreNotBefore: false, + }); +} + +module.exports = { verifyAccessJwt }; diff --git a/fixtures/generic-js/jwt-crypto-trust-alg-confusion/expected.json b/fixtures/generic-js/jwt-crypto-trust-alg-confusion/expected.json new file mode 100644 index 0000000..d88047e --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-alg-confusion/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-js.jwt-crypto-trust-alg-confusion", + "framework": "generic-javascript", + "source_files": ["auth.js"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_alg_confusion_signal"], + "notes": "Covers jsonwebtoken verification that accepts both HMAC and asymmetric algorithms." +} diff --git a/fixtures/generic-js/jwt-crypto-trust-alg-none/auth.js b/fixtures/generic-js/jwt-crypto-trust-alg-none/auth.js new file mode 100644 index 0000000..c342caf --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-alg-none/auth.js @@ -0,0 +1,16 @@ +const jwt = require("jsonwebtoken"); + +const PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +function verifyAccessJwt(token) { + return jwt.verify(token, PUBLIC_KEY, { + algorithms: ["none"], + issuer: ISSUER, + audience: AUDIENCE, + ignoreNotBefore: false, + }); +} + +module.exports = { verifyAccessJwt }; diff --git a/fixtures/generic-js/jwt-crypto-trust-alg-none/expected.json b/fixtures/generic-js/jwt-crypto-trust-alg-none/expected.json new file mode 100644 index 0000000..6d163bb --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-alg-none/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-js.jwt-crypto-trust-alg-none", + "framework": "generic-javascript", + "source_files": ["auth.js"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_alg_none_accepted"], + "notes": "Covers jsonwebtoken verification that explicitly accepts the none algorithm." +} diff --git a/fixtures/generic-python/jwt-crypto-trust-clock-skew/auth.py b/fixtures/generic-python/jwt-crypto-trust-clock-skew/auth.py new file mode 100644 index 0000000..9e7b433 --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-clock-skew/auth.py @@ -0,0 +1,21 @@ +class jwt: + @staticmethod + def decode(*args, **kwargs): + return {"claims": "placeholder"} + + +PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE" +ISSUER = "https://placeholder.issuer.invalid" +AUDIENCE = "placeholder-service" + + +def verify_access_jwt(token): + return jwt.decode( + token, + key=PUBLIC_KEY, + algorithms=["RS256"], + issuer=ISSUER, + audience=AUDIENCE, + leeway=3600, + options={"verify_nbf": True}, + ) diff --git a/fixtures/generic-python/jwt-crypto-trust-clock-skew/expected.json b/fixtures/generic-python/jwt-crypto-trust-clock-skew/expected.json new file mode 100644 index 0000000..329f91c --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-clock-skew/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-python.jwt-crypto-trust-clock-skew", + "framework": "generic-python", + "source_files": ["auth.py"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_clock_skew_review"], + "notes": "Covers PyJWT-like verification with a broad 3600-second clock skew tolerance." +} diff --git a/fixtures/generic-python/jwt-crypto-trust-kid/auth.py b/fixtures/generic-python/jwt-crypto-trust-kid/auth.py new file mode 100644 index 0000000..5ec44c9 --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-kid/auth.py @@ -0,0 +1,21 @@ +class jwt: + @staticmethod + def decode(*args, **kwargs): + return {"header": {"kid": "placeholder-key-id"}} + + +PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE" +ISSUER = "https://placeholder.issuer.invalid" +AUDIENCE = "placeholder-service" + + +def verify_access_jwt(token): + decoded = jwt.decode( + token, + key=PUBLIC_KEY, + algorithms=["RS256"], + issuer=ISSUER, + audience=AUDIENCE, + options={"complete": True, "verify_nbf": True}, + ) + return decoded["header"]["kid"] diff --git a/fixtures/generic-python/jwt-crypto-trust-kid/expected.json b/fixtures/generic-python/jwt-crypto-trust-kid/expected.json new file mode 100644 index 0000000..54e6fd9 --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-kid/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-python.jwt-crypto-trust-kid", + "framework": "generic-python", + "source_files": ["auth.py"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_kid_unvalidated_review"], + "notes": "Covers PyJWT-like verification that reads kid without visible allow-list or trusted key-map validation." +} diff --git a/fixtures/generic-python/jwt-crypto-trust-nbf/auth.py b/fixtures/generic-python/jwt-crypto-trust-nbf/auth.py new file mode 100644 index 0000000..4015ee9 --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-nbf/auth.py @@ -0,0 +1,19 @@ +class jwt: + @staticmethod + def decode(*args, **kwargs): + return {"claims": "placeholder"} + + +PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE" +ISSUER = "https://placeholder.issuer.invalid" +AUDIENCE = "placeholder-service" + + +def verify_access_jwt(token): + return jwt.decode( + token, + key=PUBLIC_KEY, + algorithms=["RS256"], + issuer=ISSUER, + audience=AUDIENCE, + ) diff --git a/fixtures/generic-python/jwt-crypto-trust-nbf/expected.json b/fixtures/generic-python/jwt-crypto-trust-nbf/expected.json new file mode 100644 index 0000000..357bb43 --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-nbf/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-python.jwt-crypto-trust-nbf", + "framework": "generic-python", + "source_files": ["auth.py"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_nbf_missing"], + "notes": "Covers PyJWT-like verification that omits not-before validation evidence." +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/auth.ts b/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/auth.ts new file mode 100644 index 0000000..9081476 --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/auth.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import { jwtVerify } from "jose"; + +const PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +export async function verifyAccessJwt(token: string) { + const result = await jwtVerify(token, PUBLIC_KEY, { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + complete: true, + }); + return resolveTrustedKey(result.protectedHeader.jwk); +} + +function resolveTrustedKey(value: unknown) { + return value; +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/expected.json b/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/expected.json new file mode 100644 index 0000000..6037152 --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-embedded-jwk/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-crypto-trust-embedded-jwk", + "framework": "generic-typescript", + "source_files": ["auth.ts"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_embedded_jwk_trust"], + "notes": "Covers jose verification that reads an embedded jwk header after complete-header verification context." +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-jku/auth.ts b/fixtures/generic-ts/jwt-crypto-trust-jku/auth.ts new file mode 100644 index 0000000..89e20d9 --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-jku/auth.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import { jwtVerify } from "jose"; + +const PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +export async function verifyAccessJwt(token: string) { + const result = await jwtVerify(token, PUBLIC_KEY, { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + complete: true, + }); + return resolveTrustedKey(result.protectedHeader.jku); +} + +function resolveTrustedKey(value: unknown) { + return value; +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-jku/expected.json b/fixtures/generic-ts/jwt-crypto-trust-jku/expected.json new file mode 100644 index 0000000..5ab7a35 --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-jku/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-crypto-trust-jku", + "framework": "generic-typescript", + "source_files": ["auth.ts"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_jku_header_trust"], + "notes": "Covers jose verification that reads a jku header after complete-header verification context." +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-x5u/auth.ts b/fixtures/generic-ts/jwt-crypto-trust-x5u/auth.ts new file mode 100644 index 0000000..8ea493e --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-x5u/auth.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import { jwtVerify } from "jose"; + +const PUBLIC_KEY = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +export async function verifyAccessJwt(token: string) { + const result = await jwtVerify(token, PUBLIC_KEY, { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + complete: true, + }); + return resolveTrustedKey(result.protectedHeader.x5u); +} + +function resolveTrustedKey(value: unknown) { + return value; +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-x5u/expected.json b/fixtures/generic-ts/jwt-crypto-trust-x5u/expected.json new file mode 100644 index 0000000..90c24ac --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-x5u/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-crypto-trust-x5u", + "framework": "generic-typescript", + "source_files": ["auth.ts"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["jwt_x5u_header_trust"], + "notes": "Covers jose verification that reads an x5u header after complete-header verification context." +} From c82b4aa8d3eeacfec5d7f905cd2bba14b55783b7 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:41:51 -0500 Subject: [PATCH 07/29] =?UTF-8?q?Fix=20#76:=20P2.7=20Docs=20=E2=80=94=20P2?= =?UTF-8?q?=20check=20catalog=20and=20coverage=20matrix=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/COVERAGE_MATRIX.md | 8 ++++++++ docs/USAGE.md | 1 + 3 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e9284..5242fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and SessionScope uses semantic versioning as described in - Added `docs/COVERAGE_MATRIX.md` as the per-check source of truth for supported, review-required, and intentionally not-covered SessionScope evidence patterns across languages, frameworks, libraries, lifecycle stages, finding categories, and SARIF rule IDs. - Added v0.2 P1 cookie hardening coverage for `__Host-` / `__Secure-` prefix rules, `Partitioned` cookie review, broad non-session Domain review, and same-handler conflicting cookie writes across existing JS/TS/Python cookie detectors. +- Added v0.2 P2 JWT crypto-trust coverage for `alg:none`, HMAC/asymmetric algorithm-confusion signals, `jku`/`x5u`/embedded-JWK header trust review, missing `nbf` validation, broad clock-skew review, and unvalidated `kid` header review across existing `jsonwebtoken`, `jose`, and PyJWT detector surfaces. ### Pre-release remediation (v0.1.0 readiness) diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index cd15262..81c08ad 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -45,6 +45,14 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m | `jwt_expiry_enforcement_disabled` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | JWT verify APIs | **supported:** ignore/disable expiry checks | validate | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Verification disables token expiry enforcement. | | `jwt_dynamic_expiry_enforcement` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | JWT verify APIs | **review-required:** dynamic expiry enforcement | validate | `dynamic_review_required` | `dynamic_review_required` | Runtime verification options require review. | | `jwt_default_expiry_enforcement` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | JWT verify APIs | **review-required:** library/default expiry enforcement | validate | `framework_default_assumed` | `framework_default_assumed` | Local source relies on library defaults. | +| `jwt_alg_none_accepted` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `PyJWT`; `jose` explicit algorithm evidence | **supported:** literal `none` in `algorithms`/`algorithm`; **review-required:** missing allow-list on default-sensitive library paths; **not covered:** python-jose/authlib JWT validation paths | validate | `high_confidence_misconfiguration` or `framework_default_assumed` | matching finding category | JWT validation should not accept unsigned tokens and should pin allowed algorithms. | +| `jwt_alg_confusion_signal` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** HMAC and asymmetric algorithms in one allow-list; **review-required:** HMAC algorithms paired with public-key-like key references; **not covered:** python-jose/authlib JWT validation paths | validate | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | Accepted algorithms should match the expected key family. | +| `jwt_jku_header_trust` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **review-required:** `complete` verification context plus source-visible `header.jku` read; **not covered:** live URL allow-list verification or network JWKS state | validate | `dynamic_review_required` | `dynamic_review_required` | Header-driven key URLs require reviewer confirmation and allow-listing. | +| `jwt_x5u_header_trust` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **review-required:** `complete` verification context plus source-visible `header.x5u` read; **not covered:** live URL allow-list verification or network certificate state | validate | `dynamic_review_required` | `dynamic_review_required` | Header-driven certificate URLs require reviewer confirmation and allow-listing. | +| `jwt_embedded_jwk_trust` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **review-required:** `complete` verification context plus source-visible `header.jwk` read; **not covered:** live key trust verification | validate | `dynamic_review_required` | `dynamic_review_required` | Embedded JWK header use requires reviewer confirmation. | +| `jwt_nbf_missing` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** validation path without source-visible `nbf`/not-before enforcement or with not-before checks disabled; **not covered:** issuer policy that intentionally never emits `nbf` | validate | `missing_validation_evidence` | `missing_validation_evidence` | Not-before validation evidence is missing or disabled. | +| `jwt_clock_skew_review` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **review-required:** clock tolerance/leeway above 60 seconds or dynamic clock tolerance evidence | validate | `dynamic_review_required` | `dynamic_review_required` | Broad clock skew can extend token acceptance windows. | +| `jwt_kid_unvalidated_review` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** source-visible `kid` header read without visible allow-list, pinned key map, or JWKS-style validation evidence; **not covered:** live JWKS/OIDC discovery state | validate | `missing_validation_evidence` | `missing_validation_evidence` | Header key IDs should be constrained before key selection. | | `bearer_token_in_url_query` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Bearer/API-key handlers | **supported:** bearer/API-key token in query URL | transmit | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Tokens accepted from URLs can leak via logs/referrers. | | `bearer_token_browser_storage` | JS/TS | Generic JS/TS, Next.js client-adjacent code | Browser APIs | **supported:** token-like values in browser storage | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Browser storage token use is flagged. | | `bearer_static_secret_literal` | JS/TS, Python | Generic JS/TS/Python | Config/source literals | **supported:** obvious placeholder/static secret literal | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Static token-like literals are flagged. | diff --git a/docs/USAGE.md b/docs/USAGE.md index 9f8d5a6..b2dd6fd 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -124,6 +124,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - Unsafe or review-required cookie posture, including excessive lifetime, broad `Domain`/`Path` scope, `SameSite=None` handling, `__Host-` / `__Secure-` prefix-rule violations, `Partitioned` cookie review, broad non-session Domain leak review, and same-handler conflicting cookie writes - JWT verification without issuer validation - JWT verification without audience validation +- JWT crypto-trust hardening, including `alg:none` acceptance, HMAC/asymmetric algorithm-confusion signals, `jku`/`x5u`/embedded-JWK header trust review, missing `nbf` validation, broad clock-skew review, and unvalidated `kid` header review - Tokens issued without explicit expiry - Refresh tokens without rotation evidence - Logout without revocation evidence From 96cd4214e7d80262756ca456379a25a11cdc3e25 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:51:50 -0500 Subject: [PATCH 08/29] Fix #73: Address JWT crypto-trust review findings --- crates/sessionscope-classifier/src/jwt.rs | 73 +++++++++ crates/sessionscope-detectors/src/jwt/mod.rs | 148 +++++++++++++++++- crates/sessionscope-testing/src/fixtures.rs | 52 ++++++ docs/USAGE.md | 2 +- .../jwt-crypto-trust-safe-algorithms/auth.js | 16 ++ .../expected.json | 9 ++ .../auth.py | 22 +++ .../expected.json | 9 ++ .../auth.ts | 17 ++ .../expected.json | 9 ++ 10 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 fixtures/generic-js/jwt-crypto-trust-safe-algorithms/auth.js create mode 100644 fixtures/generic-js/jwt-crypto-trust-safe-algorithms/expected.json create mode 100644 fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/auth.py create mode 100644 fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/expected.json create mode 100644 fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/auth.ts create mode 100644 fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/expected.json diff --git a/crates/sessionscope-classifier/src/jwt.rs b/crates/sessionscope-classifier/src/jwt.rs index a1c7122..b04eb1f 100644 --- a/crates/sessionscope-classifier/src/jwt.rs +++ b/crates/sessionscope-classifier/src/jwt.rs @@ -349,6 +349,9 @@ fn classify_header_trust(report: &ScanReport, artifact: &Artifact, name: &str) - if header_evidence.is_empty() { return None; } + if has_visible_key_trust_validation(report, artifact) { + return None; + } let mut evidence_ids = complete_evidence .iter() .chain(header_evidence.iter()) @@ -504,6 +507,26 @@ fn has_visible_kid_validation(report: &ScanReport, artifact: &Artifact) -> bool }) } +fn has_visible_key_trust_validation(report: &ScanReport, artifact: &Artifact) -> bool { + artifact.jwt_attributes.as_ref().is_some_and(|attributes| { + attributes + .key_reference + .value + .as_deref() + .is_some_and(kid_key_reference_is_validated) + }) || report.evidence.iter().any(|evidence| { + artifact.lifecycle_evidence.validate.contains(&evidence.id) + && evidence.excerpt.as_ref().is_some_and(|excerpt| { + let text = excerpt.as_str().to_ascii_lowercase(); + text.contains("allowlist") + || text.contains("allow-list") + || text.contains("trusted key") + || text.contains("pinned") + || text.contains("jwks") + }) + }) +} + fn kid_key_reference_is_validated(value: &str) -> bool { let normalized = value.to_ascii_lowercase(); normalized.contains("allow") @@ -1319,6 +1342,56 @@ mod tests { ); } + #[test] + fn header_trust_suppressed_by_visible_trusted_key_lookup() { + let mut attributes = attributes( + JwtAttributeState::Present, + JwtAttributeState::Present, + JwtAttributeState::Present, + "validate", + ); + attributes.key_reference = observation( + "key", + JwtAttributeState::Present, + Some("trustedJwksKeyMap"), + EvidenceId("evidence_key".to_string()), + ); + let artifact = artifact( + attributes, + LifecycleEvidence { + validate: vec![ + EvidenceId("evidence_verify".to_string()), + EvidenceId("evidence_complete".to_string()), + EvidenceId("evidence_jku".to_string()), + ], + ..LifecycleEvidence::default() + }, + ); + let findings = classify_report( + vec![artifact], + vec![ + option_evidence( + "evidence_complete", + "jwt.option.complete", + "complete: true", + false, + ), + option_evidence( + "evidence_jku", + "jwt.header.jku", + "JWT header `jku` is read near verification logic", + false, + ), + ], + ); + + assert!( + findings + .iter() + .all(|finding| !finding.title.contains("jku URL")) + ); + } + #[test] fn missing_nbf_evidence_is_missing_validation() { let artifact = artifact( diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index abcf55d..b791ae7 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -995,6 +995,7 @@ fn js_jwt_call<'tree>( "jsonwebtoken.verify" | "jose.jwtVerify" => add_js_verify_fields( &mut fields, source, + &scope_text(node, source), &argument_nodes, option_aliases, line, @@ -1150,6 +1151,7 @@ fn add_js_sign_fields( fn add_js_verify_fields( fields: &mut BTreeMap, source: &str, + scope_source: &str, argument_nodes: &[Node<'_>], option_aliases: &AliasMap, line: usize, @@ -1228,7 +1230,7 @@ fn add_js_verify_fields( } else { add_missing_for_verify_fields(fields, line, column); } - add_header_trust_fields(fields, source, line, column); + add_header_trust_fields(fields, scope_source, line, column); } fn add_header_trust_fields( @@ -1277,6 +1279,7 @@ fn js_verify_option_fields(jose: bool) -> &'static [(JwtField, &'static [&'stati &["currentDate", "clockTimestamp"], ), (JwtField::OptionComplete, &["complete"]), + (JwtField::OptionIgnoreNotBefore, &["ignoreNotBefore"]), ] } else { &[ @@ -1538,6 +1541,7 @@ fn python_jwt_call<'tree>( add_python_decode_without_verify_fields( &mut fields, source, + &scope_text(node, source), &argument_nodes, option_aliases, line, @@ -1548,6 +1552,7 @@ fn python_jwt_call<'tree>( add_python_decode_fields( &mut fields, source, + &scope_text(node, source), &argument_nodes, option_aliases, line, @@ -1642,6 +1647,7 @@ fn add_python_encode_fields( fn add_python_decode_fields( fields: &mut BTreeMap, source: &str, + scope_source: &str, argument_nodes: &[Node<'_>], option_aliases: &AliasMap, line: usize, @@ -1698,12 +1704,13 @@ fn add_python_decode_fields( } add_python_expiry_enforcement(fields, source, argument_nodes, option_aliases, line, column); add_missing_for_verify_fields(fields, line, column); - add_header_trust_fields(fields, source, line, column); + add_header_trust_fields(fields, scope_source, line, column); } fn add_python_decode_without_verify_fields( fields: &mut BTreeMap, source: &str, + scope_source: &str, argument_nodes: &[Node<'_>], option_aliases: &AliasMap, line: usize, @@ -1725,7 +1732,15 @@ fn add_python_decode_without_verify_fields( column, "PyJWT decode without signature verification should not be treated as expiration enforcement", ); - add_python_decode_fields(fields, source, argument_nodes, option_aliases, line, column); + add_python_decode_fields( + fields, + source, + scope_source, + argument_nodes, + option_aliases, + line, + column, + ); add_missing_value( fields, JwtField::SignatureVerification, @@ -2422,19 +2437,53 @@ fn add_present_node( source: &str, ) { let (line, column) = node_line_column(node); + let (value, excerpt) = if field.is_option() { + option_value_and_excerpt(field, node, source) + } else { + ( + Some(safe_node_value(node, source)), + excerpt_for_node(source, node), + ) + }; fields.insert( field, JwtFieldEvidence { state: JwtAttributeState::Present, - value: Some(safe_node_value(node, source)), + value, confidence: Confidence::High, line, column, - excerpt: excerpt_for_node(source, node), + excerpt, }, ); } +fn option_value_and_excerpt( + field: JwtField, + node: Node<'_>, + source: &str, +) -> (Option, SanitizedExcerpt) { + let value = match field { + JwtField::OptionAlgorithms + | JwtField::OptionClockTolerance + | JwtField::OptionClockTimestamp + | JwtField::OptionComplete + | JwtField::OptionIgnoreNotBefore + | JwtField::OptionIgnoreExpiration => Some(safe_node_value(node, source)), + _ => Some("[option]".to_string()), + }; + let excerpt = match field { + JwtField::OptionAlgorithms + | JwtField::OptionClockTolerance + | JwtField::OptionClockTimestamp + | JwtField::OptionComplete + | JwtField::OptionIgnoreNotBefore + | JwtField::OptionIgnoreExpiration => excerpt_for_node(source, node), + _ => jwt_excerpt(format!("{} is present", field.display_name())), + }; + (value, excerpt) +} + fn add_present_synthetic( fields: &mut BTreeMap, field: JwtField, @@ -2447,7 +2496,11 @@ fn add_present_synthetic( field, JwtFieldEvidence { state: JwtAttributeState::Present, - value: Some(safe_node_value(node, source)), + value: Some(if field == JwtField::KeyReference { + safe_key_reference_value(node, source) + } else { + safe_node_value(node, source) + }), confidence: Confidence::High, line, column, @@ -2867,6 +2920,26 @@ fn safe_node_value(node: Node<'_>, source: &str) -> String { safe_text_value(&node_text(node, source)) } +fn safe_key_reference_value(node: Node<'_>, source: &str) -> String { + let text = node_text(node, source); + let trimmed = text.trim(); + if trimmed.contains(['"', '\'', '`']) { + return "[key_reference]".to_string(); + } + match node.kind() { + "identifier" | "property_identifier" => safe_text_value(trimmed), + "member_expression" | "attribute" => safe_text_value(trimmed), + _ if trimmed.to_ascii_lowercase().contains("publickey") + || trimmed.to_ascii_lowercase().contains("pubkey") + || trimmed.to_ascii_lowercase().contains("loadpublickey") + || trimmed.ends_with(".pem") => + { + safe_text_value(trimmed) + } + _ => "[key_reference]".to_string(), + } +} + fn safe_identity_value(field: JwtField, text: &str) -> String { let trimmed = text.trim(); if field == JwtField::EmailVerified @@ -3025,6 +3098,17 @@ fn node_text(node: Node<'_>, source: &str) -> String { .to_string() } +fn scope_text(node: Node<'_>, source: &str) -> String { + let mut current = Some(node); + while let Some(candidate) = current { + if is_scope_node(candidate) && candidate.kind() != "program" { + return node_text(candidate, source); + } + current = candidate.parent(); + } + node_text(node, source) +} + #[cfg(test)] mod tests { use sessionscope_model::{ArtifactType, Language}; @@ -3340,6 +3424,58 @@ def verify_access_jwt(token): } } + #[test] + fn does_not_attach_unrelated_header_reads_to_verify_call() { + let output = detect( + Language::TypeScript, + r#" +import jwt from "jsonwebtoken"; +export function verifyAccessJwt(token: string) { + return jwt.verify(token, publicKey, { algorithms: ["RS256"], issuer, audience, complete: true }); +} +export function unrelated(decoded: any) { + return decoded.header.jku; +} +"#, + ); + + assert!( + output + .evidence + .iter() + .all(|evidence| evidence.detector_id != "jwt.header.jku") + ); + } + + #[test] + fn option_and_key_reference_values_do_not_leak_sensitive_literals() { + let output = detect( + Language::TypeScript, + r#" +import jwt from "jsonwebtoken"; +export function verifyAccessJwt(token: string) { + return jwt.verify(token, createSecretKey("raw-secret-value"), { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + subject: "sensitive-subject-value", + nonce: "abcDEF12345678901234", + ignoreNotBefore: false, + }); +} +"#, + ); + + let detected = detected_text(&output); + for leaked in [ + "raw-secret-value", + "sensitive-subject-value", + "abcDEF12345678901234", + ] { + assert!(!detected.contains(leaked), "{leaked} leaked in {detected}"); + } + } + #[test] fn detects_jsonwebtoken_identity_claims_from_payload_alias() { let output = detect( diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 7ec0577..e214660 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -587,6 +587,58 @@ mod tests { } } + #[test] + fn jwt_crypto_trust_false_positive_fixtures_stay_clean() { + let cases = [ + fixture_root() + .join("generic-js") + .join("jwt-crypto-trust-safe-algorithms"), + fixture_root() + .join("generic-ts") + .join("jwt-crypto-trust-safe-header-allowlist"), + fixture_root() + .join("generic-python") + .join("jwt-crypto-trust-safe-nbf-clock-kid"), + ]; + let p2_title_fragments = [ + "`none` algorithm", + "HMAC and asymmetric", + "public-key-like", + "jku URL", + "x5u URL", + "embedded JWK", + "not-before", + "clock skew", + "`kid`", + ]; + + for root in cases { + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .unwrap_or_else(|error| panic!("{} should scan: {error}", root.display())), + ); + + for fragment in p2_title_fragments { + assert!( + report + .findings + .iter() + .all(|finding| !finding.title.contains(fragment)), + "{} should not emit P2 finding containing {fragment:?}. Findings: {:?}", + root.display(), + report + .findings + .iter() + .map(|finding| finding.title.as_str()) + .collect::>() + ); + } + } + } + #[test] fn logout_fixtures_emit_revoke_evidence() { let cases = [ diff --git a/docs/USAGE.md b/docs/USAGE.md index b2dd6fd..0833aec 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -124,7 +124,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - Unsafe or review-required cookie posture, including excessive lifetime, broad `Domain`/`Path` scope, `SameSite=None` handling, `__Host-` / `__Secure-` prefix-rule violations, `Partitioned` cookie review, broad non-session Domain leak review, and same-handler conflicting cookie writes - JWT verification without issuer validation - JWT verification without audience validation -- JWT crypto-trust hardening, including `alg:none` acceptance, HMAC/asymmetric algorithm-confusion signals, `jku`/`x5u`/embedded-JWK header trust review, missing `nbf` validation, broad clock-skew review, and unvalidated `kid` header review +- JWT crypto-trust hardening, including `jwt_alg_none_accepted`, `jwt_alg_confusion_signal`, `jwt_jku_header_trust`, `jwt_x5u_header_trust`, `jwt_embedded_jwk_trust`, `jwt_nbf_missing`, `jwt_clock_skew_review`, and `jwt_kid_unvalidated_review` - Tokens issued without explicit expiry - Refresh tokens without rotation evidence - Logout without revocation evidence diff --git a/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/auth.js b/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/auth.js new file mode 100644 index 0000000..e508ffb --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/auth.js @@ -0,0 +1,16 @@ +const jwt = require("jsonwebtoken"); + +const JWT_SECRET = "PLACEHOLDER_SECRET_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +function verifyAccessJwt(token) { + return jwt.verify(token, JWT_SECRET, { + algorithms: ["HS256"], + issuer: ISSUER, + audience: AUDIENCE, + ignoreNotBefore: false, + }); +} + +module.exports = { verifyAccessJwt }; diff --git a/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/expected.json b/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/expected.json new file mode 100644 index 0000000..30c25e4 --- /dev/null +++ b/fixtures/generic-js/jwt-crypto-trust-safe-algorithms/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-js.jwt-crypto-trust-safe-algorithms", + "framework": "generic-javascript", + "source_files": ["auth.js"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["no_p2_crypto_trust_findings"], + "notes": "False-positive fixture: explicit safe HMAC algorithm, matching symmetric key, issuer/audience, and not-before enforcement evidence." +} diff --git a/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/auth.py b/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/auth.py new file mode 100644 index 0000000..921b4ac --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/auth.py @@ -0,0 +1,22 @@ +class jwt: + @staticmethod + def decode(*args, **kwargs): + return {"header": {"kid": "placeholder-key-id"}} + + +trusted_key_map = {"placeholder-key-id": "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"} +ISSUER = "https://placeholder.issuer.invalid" +AUDIENCE = "placeholder-service" + + +def verify_access_jwt(token): + decoded = jwt.decode( + token, + key=trusted_key_map, + algorithms=["RS256"], + issuer=ISSUER, + audience=AUDIENCE, + leeway=60, + options={"complete": True, "verify_nbf": True}, + ) + return trusted_key_map.get(decoded["header"]["kid"]) diff --git a/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/expected.json b/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/expected.json new file mode 100644 index 0000000..8704dcd --- /dev/null +++ b/fixtures/generic-python/jwt-crypto-trust-safe-nbf-clock-kid/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-python.jwt-crypto-trust-safe-nbf-clock-kid", + "framework": "generic-python", + "source_files": ["auth.py"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["no_nbf_clock_or_kid_findings"], + "notes": "False-positive fixture: nbf enforcement, 60-second leeway, and trusted key-map kid lookup should avoid P2 review findings." +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/auth.ts b/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/auth.ts new file mode 100644 index 0000000..e2291bb --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/auth.ts @@ -0,0 +1,17 @@ +// @ts-nocheck +import { jwtVerify } from "jose"; + +const trustedJwksKeyMap = "PLACEHOLDER_PUBLIC_KEY_DO_NOT_USE"; +const ISSUER = "https://placeholder.issuer.invalid"; +const AUDIENCE = "placeholder-service"; + +export async function verifyAccessJwt(token: string) { + const result = await jwtVerify(token, trustedJwksKeyMap, { + algorithms: ["RS256"], + issuer: ISSUER, + audience: AUDIENCE, + complete: true, + ignoreNotBefore: false, + }); + return trustedJwksKeyMap && result.protectedHeader.jku; +} diff --git a/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/expected.json b/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/expected.json new file mode 100644 index 0000000..8afe135 --- /dev/null +++ b/fixtures/generic-ts/jwt-crypto-trust-safe-header-allowlist/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-crypto-trust-safe-header-allowlist", + "framework": "generic-typescript", + "source_files": ["auth.ts"], + "expected_artifacts": ["access_jwt"], + "expected_lifecycle_stages": ["validate"], + "expected_findings": ["no_header_trust_finding"], + "notes": "False-positive fixture: header read is paired with trusted JWKS-style key-map evidence." +} From bf9b3da04b7104777feace9cc7962943b0ed1a32 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:53:42 -0500 Subject: [PATCH 09/29] Fix #73: Keep JWT verify helper clippy-clean --- crates/sessionscope-detectors/src/jwt/mod.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index b791ae7..fba4bc5 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -339,6 +339,12 @@ struct FieldSet { dynamic: bool, } +#[derive(Debug, Clone, Copy)] +struct JwtSourceContext<'a> { + source: &'a str, + scope_source: &'a str, +} + #[derive(Debug, Clone)] struct ScopedFieldSet { fields: BTreeMap, @@ -994,8 +1000,10 @@ fn js_jwt_call<'tree>( ), "jsonwebtoken.verify" | "jose.jwtVerify" => add_js_verify_fields( &mut fields, - source, - &scope_text(node, source), + JwtSourceContext { + source, + scope_source: &scope_text(node, source), + }, &argument_nodes, option_aliases, line, @@ -1150,14 +1158,14 @@ fn add_js_sign_fields( fn add_js_verify_fields( fields: &mut BTreeMap, - source: &str, - scope_source: &str, + context: JwtSourceContext<'_>, argument_nodes: &[Node<'_>], option_aliases: &AliasMap, line: usize, column: usize, jose: bool, ) { + let source = context.source; add_key_reference(fields, source, argument_nodes.get(1).copied(), line, column); add_present_value( fields, @@ -1230,7 +1238,7 @@ fn add_js_verify_fields( } else { add_missing_for_verify_fields(fields, line, column); } - add_header_trust_fields(fields, scope_source, line, column); + add_header_trust_fields(fields, context.scope_source, line, column); } fn add_header_trust_fields( From 8d16ee3b42f5a89ef2260ed2f25c7bc4184d7eae Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:33:23 -0500 Subject: [PATCH 10/29] Fix #77: P3.1 oauth_flow scaffold and registration --- .../sessionscope-classifier/src/lifecycle.rs | 1 + .../sessionscope-detectors/src/bearer/mod.rs | 1 + .../sessionscope-detectors/src/cookies/mod.rs | 1 + crates/sessionscope-detectors/src/jwt/mod.rs | 1 + crates/sessionscope-detectors/src/lib.rs | 1 + .../src/oauth_flow/mod.rs | 448 ++++++++++++++++++ .../src/query_params/mod.rs | 1 + crates/sessionscope-detectors/src/registry.rs | 2 + .../src/sessions/mod.rs | 1 + crates/sessionscope-model/src/artifact.rs | 6 + crates/sessionscope-reporters/src/markdown.rs | 1 + docs/SCHEMA.md | 6 +- docs/USAGE.md | 2 + 13 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 crates/sessionscope-detectors/src/oauth_flow/mod.rs diff --git a/crates/sessionscope-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index 25aede0..e92e98d 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -1185,6 +1185,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-detectors/src/bearer/mod.rs b/crates/sessionscope-detectors/src/bearer/mod.rs index 0b2fbb8..c75b55b 100644 --- a/crates/sessionscope-detectors/src/bearer/mod.rs +++ b/crates/sessionscope-detectors/src/bearer/mod.rs @@ -1640,6 +1640,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-detectors/src/cookies/mod.rs b/crates/sessionscope-detectors/src/cookies/mod.rs index 75d6eab..3cc4bd2 100644 --- a/crates/sessionscope-detectors/src/cookies/mod.rs +++ b/crates/sessionscope-detectors/src/cookies/mod.rs @@ -714,6 +714,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-detectors/src/jwt/mod.rs b/crates/sessionscope-detectors/src/jwt/mod.rs index fba4bc5..6fb26e2 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -2760,6 +2760,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-detectors/src/lib.rs b/crates/sessionscope-detectors/src/lib.rs index 46ae639..000b416 100644 --- a/crates/sessionscope-detectors/src/lib.rs +++ b/crates/sessionscope-detectors/src/lib.rs @@ -2,6 +2,7 @@ pub mod bearer; pub mod cookies; pub mod frameworks; pub mod jwt; +pub mod oauth_flow; pub mod providers; pub mod query_params; pub mod registry; diff --git a/crates/sessionscope-detectors/src/oauth_flow/mod.rs b/crates/sessionscope-detectors/src/oauth_flow/mod.rs new file mode 100644 index 0000000..20da12f --- /dev/null +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -0,0 +1,448 @@ +use std::sync::LazyLock; + +use regex::Regex; +use sessionscope_model::{ + Artifact, ArtifactType, Confidence, Evidence, EvidenceId, Language, LifecycleEvidence, + LifecycleStage, SanitizedExcerpt, SourceLocation, stable_artifact_id, stable_evidence_id, +}; + +use crate::{DetectionOutput, Detector, DetectorInput}; + +const DETECTOR_ID: &str = "oauth.flow"; +const REDACTION: &str = "[REDACTED]"; + +static OAUTH_FLOW_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)(passport-oauth2|OAuth2Strategy|authorizationUrl|authorization_url|OAuthProvider|OIDCProvider|NextAuth|OAuth2Client|OAuth2Session|register\(|authorize_redirect|response_type\s*[:=]\s*['\"]code)"#) + .expect("oauth flow regex should compile") +}); +static PKCE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(code_challenge|codeChallenge|code_verifier|codeVerifier|code_challenge_method|S256|checks\s*[:=][^\n]*(pkce))") + .expect("pkce regex should compile") +}); +static STATE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bstate\b").expect("state regex should compile")); +static STATIC_STATE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)\bstate\b\s*[:=]\s*(?:"[A-Za-z0-9._~+/-]{4,}"|'[A-Za-z0-9._~+/-]{4,}')"#) + .expect("static state regex should compile") +}); +static STATE_VERIFY_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(state[^\n]{0,80}(===|==|!=|!==)|compare_digest\([^\n]*state|session[^\n]{0,80}state|cookies?[^\n]{0,80}state|csrf[^\n]{0,80}state)") + .expect("state verification regex should compile") +}); +static OPENID_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(scope\s*[:=][^\n]*openid|\bopenid\b|OIDCProvider)") + .expect("openid regex should compile") +}); +static NONCE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bnonce\b").expect("nonce regex should compile")); +static NONCE_VERIFY_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)(verify[^\n]{0,80}nonce|nonce[^\n]{0,80}(===|==|!=|!==)|id_token[^\n]{0,80}nonce)", + ) + .expect("nonce verification regex should compile") +}); +static REDIRECT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)\bredirect_?uris?\b\s*[:=]\s*(\[[^\n\]]+\]|["'][^"']+["'])"#) + .expect("redirect uri regex should compile") +}); +static QUOTED_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"["']([^"']+)["']"#).expect("quoted regex should compile")); +static OAUTH_VALUE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)(\b(?:state|nonce|code_verifier|codeVerifier|code_challenge|codeChallenge)\b\s*[:=]\s*)(["'])([^"']{8,})(["'])"#) + .expect("oauth value regex should compile") +}); +static OAUTH_URL_VALUE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)([?&#](?:state|nonce|code_verifier|code_challenge)=)([^&#\s"']+)"#) + .expect("oauth url value regex should compile") +}); + +#[derive(Debug, Clone, Copy, Default)] +pub struct OAuthFlowDetector; + +impl Detector for OAuthFlowDetector { + fn id(&self) -> &'static str { + DETECTOR_ID + } + + fn detect(&self, input: &DetectorInput<'_>) -> DetectionOutput { + match input.language { + Language::JavaScript | Language::TypeScript | Language::Python => detect(input), + _ => DetectionOutput::default(), + } + } +} + +#[derive(Debug, Clone)] +struct Signal { + detector_id: &'static str, + stage: LifecycleStage, + line: usize, + column: usize, + confidence: Confidence, + dynamic: bool, + framework_default: bool, + excerpt: SanitizedExcerpt, +} + +fn detect(input: &DetectorInput<'_>) -> DetectionOutput { + let mut signals = Vec::new(); + let mut saw_flow = false; + + for (index, line) in input.source.lines().enumerate() { + let line_number = index + 1; + if OAUTH_FLOW_RE.is_match(line) { + saw_flow = true; + signals.push(signal( + "oauth.flow.auth_code", + LifecycleStage::Issue, + line, + line_number, + false, + false, + )); + if line.to_ascii_lowercase().contains("nextauth") + || line.contains("OAuthProvider") + || line.contains("OIDCProvider") + { + signals.push(signal( + "oauth.flow.framework_default", + LifecycleStage::Issue, + line, + line_number, + true, + true, + )); + } + } + if PKCE_RE.is_match(line) { + signals.push(signal( + "oauth.pkce.present", + LifecycleStage::Issue, + line, + line_number, + false, + false, + )); + } + if STATE_RE.is_match(line) { + let detector_id = if STATIC_STATE_RE.is_match(line) { + "oauth.state.static" + } else if line.to_ascii_lowercase().contains("req.query") + || line.to_ascii_lowercase().contains("request.query") + || line.to_ascii_lowercase().contains("searchparams") + { + "oauth.state.callback_read" + } else { + "oauth.state.present" + }; + signals.push(signal( + detector_id, + LifecycleStage::Validate, + line, + line_number, + !STATIC_STATE_RE.is_match(line), + false, + )); + } + if STATE_VERIFY_RE.is_match(line) { + signals.push(signal( + "oauth.state.verified", + LifecycleStage::Validate, + line, + line_number, + false, + false, + )); + } + if OPENID_RE.is_match(line) { + signals.push(signal( + "oauth.oidc.openid_scope", + LifecycleStage::Issue, + line, + line_number, + false, + false, + )); + } + if NONCE_RE.is_match(line) { + signals.push(signal( + "oauth.nonce.present", + LifecycleStage::Issue, + line, + line_number, + true, + false, + )); + } + if NONCE_VERIFY_RE.is_match(line) { + signals.push(signal( + "oauth.nonce.verified", + LifecycleStage::Validate, + line, + line_number, + false, + false, + )); + } + if REDIRECT_RE.is_match(line) { + signals.push(signal( + "oauth.redirect_uri.literal", + LifecycleStage::Issue, + line, + line_number, + false, + false, + )); + if redirect_line_is_broad(line) { + signals.push(signal( + "oauth.redirect_uri.broad", + LifecycleStage::Issue, + line, + line_number, + false, + false, + )); + } + } + } + + if !saw_flow + && !signals + .iter() + .any(|signal| signal.detector_id == "oauth.flow.auth_code") + { + return DetectionOutput::default(); + } + + signals_to_output(input, signals) +} + +fn signal( + detector_id: &'static str, + stage: LifecycleStage, + line: &str, + line_number: usize, + dynamic: bool, + framework_default: bool, +) -> Signal { + Signal { + detector_id, + stage, + line: line_number, + column: 1, + confidence: if dynamic { + Confidence::Medium + } else { + Confidence::High + }, + dynamic, + framework_default, + excerpt: SanitizedExcerpt::from_sanitized(sanitize_oauth_excerpt(line)), + } +} + +fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> DetectionOutput { + let mut output = DetectionOutput::default(); + let artifact_id = stable_artifact_id(&[DETECTOR_ID, "oauth_auth_code_flow", input.path]); + let mut lifecycle_evidence = LifecycleEvidence::default(); + let mut evidence = Vec::new(); + + for signal in signals { + let line = signal.line.to_string(); + let column = signal.column.to_string(); + let evidence_id = stable_evidence_id(&[ + DETECTOR_ID, + signal.detector_id, + input.path, + line.as_str(), + column.as_str(), + ]); + push_lifecycle_id(&mut lifecycle_evidence, signal.stage, evidence_id.clone()); + evidence.push(Evidence { + id: evidence_id, + lifecycle_stage: signal.stage, + location: SourceLocation { + path: input.path.to_string(), + line: Some(signal.line), + column: Some(signal.column), + }, + detector_id: signal.detector_id.to_string(), + confidence: signal.confidence, + excerpt: Some(signal.excerpt), + dynamic: signal.dynamic, + framework_default: signal.framework_default, + }); + } + + if evidence.is_empty() { + return output; + } + + output.artifacts.push(Artifact { + id: artifact_id, + artifact_type: ArtifactType::OAuthAuthCodeFlow, + display_name: Some("oauth_auth_code_flow".to_string()), + locations: vec![SourceLocation { + path: input.path.to_string(), + line: evidence.first().and_then(|item| item.location.line), + column: Some(1), + }], + lifecycle_evidence, + confidence: Confidence::High, + framework_hints: framework_hints(input.path, input.source), + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, + }); + output.evidence = evidence; + output +} + +fn push_lifecycle_id(lifecycle: &mut LifecycleEvidence, stage: LifecycleStage, id: EvidenceId) { + let bucket = match stage { + LifecycleStage::Issue => &mut lifecycle.issue, + LifecycleStage::Store => &mut lifecycle.store, + LifecycleStage::Transmit => &mut lifecycle.transmit, + LifecycleStage::Validate => &mut lifecycle.validate, + LifecycleStage::Refresh => &mut lifecycle.refresh, + LifecycleStage::Revoke => &mut lifecycle.revoke, + LifecycleStage::Expire => &mut lifecycle.expire, + LifecycleStage::Introspect => &mut lifecycle.introspect, + }; + if !bucket.contains(&id) { + bucket.push(id); + } +} + +fn framework_hints(path: &str, source: &str) -> Vec { + let haystack = format!( + "{}\n{}", + path.to_ascii_lowercase(), + source.to_ascii_lowercase() + ); + let mut hints = Vec::new(); + for (needle, hint) in [ + ("passport", "passport-oauth2"), + ("openid-client", "openid-client"), + ("nextauth", "next-auth"), + ("authjs", "next-auth"), + ("authlib", "authlib"), + ("oauth2session", "authlib"), + ("oauth2client", "authlib"), + ] { + if haystack.contains(needle) && !hints.iter().any(|existing| existing == hint) { + hints.push(hint.to_string()); + } + } + if hints.is_empty() { + hints.push("oauth-generic".to_string()); + } + hints +} + +fn redirect_line_is_broad(line: &str) -> bool { + QUOTED_RE.captures_iter(line).any(|capture| { + let value = capture.get(1).map_or("", |capture| capture.as_str()); + if is_loopback_redirect(value) { + return false; + } + value.contains('*') || bare_host(value) || top_level_wildcard(value) + }) +} + +fn is_loopback_redirect(value: &str) -> bool { + value.contains("localhost") || value.contains("127.0.0.1") || value.contains("[::1]") +} + +fn bare_host(value: &str) -> bool { + let Some(rest) = value + .strip_prefix("https://") + .or_else(|| value.strip_prefix("http://")) + else { + return false; + }; + !rest.contains('/') || rest.ends_with('/') +} + +fn top_level_wildcard(value: &str) -> bool { + value.starts_with("*.") || value.contains("://*.") +} + +fn sanitize_oauth_excerpt(line: &str) -> String { + let mut output = OAUTH_VALUE_RE + .replace_all(line, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string(); + output = OAUTH_URL_VALUE_RE + .replace_all(&output, format!("${{1}}{REDACTION}")) + .to_string(); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(source: &str) -> DetectorInput<'_> { + DetectorInput { + path: "src/auth.ts", + source, + language: Language::TypeScript, + } + } + + #[test] + fn detects_auth_code_flow_with_pkce_state_and_redirect_evidence() { + let output = OAuthFlowDetector.detect(&input( + r#" +const url = client.authorizationUrl({ response_type: 'code', scope: 'openid profile', state: crypto.randomUUID(), code_challenge: challenge, redirect_uris: ['https://*.example.com'] }); +if (req.query.state === session.oauthState) {} +verifyIdToken(token, { nonce }) +"#, + )); + + assert!( + output + .artifacts + .iter() + .any(|artifact| artifact.artifact_type == ArtifactType::OAuthAuthCodeFlow) + ); + for detector_id in [ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.oidc.openid_scope", + "oauth.nonce.present", + "oauth.nonce.verified", + "oauth.redirect_uri.broad", + ] { + assert!( + output + .evidence + .iter() + .any(|evidence| evidence.detector_id == detector_id), + "missing {detector_id}" + ); + } + } + + #[test] + fn redacts_oauth_high_entropy_values_in_excerpts() { + let output = OAuthFlowDetector.detect(&input( + "client.authorizationUrl({ response_type: 'code', state: 'abcdefghijklmnopqrstuvwxyz123456', nonce: 'ZYXWVUTSRQPONMLKJIHGFEDCBA987654' })", + )); + let rendered = format!("{:?}", output.evidence); + assert!(!rendered.contains("abcdefghijklmnopqrstuvwxyz123456")); + assert!(!rendered.contains("ZYXWVUTSRQPONMLKJIHGFEDCBA987654")); + } + + #[test] + fn suppresses_loopback_bare_redirect_review() { + assert!(!redirect_line_is_broad( + "redirect_uris: ['http://localhost:3000']" + )); + assert!(redirect_line_is_broad( + "redirect_uris: ['https://example.com']" + )); + } +} diff --git a/crates/sessionscope-detectors/src/query_params/mod.rs b/crates/sessionscope-detectors/src/query_params/mod.rs index 45207f3..13d526c 100644 --- a/crates/sessionscope-detectors/src/query_params/mod.rs +++ b/crates/sessionscope-detectors/src/query_params/mod.rs @@ -912,6 +912,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-detectors/src/registry.rs b/crates/sessionscope-detectors/src/registry.rs index 3dc9fcb..8cbea25 100644 --- a/crates/sessionscope-detectors/src/registry.rs +++ b/crates/sessionscope-detectors/src/registry.rs @@ -3,6 +3,7 @@ use std::time::Instant; use crate::bearer::BearerTokenDetector; use crate::cookies::CookieSetDetector; use crate::jwt::JwtDetector; +use crate::oauth_flow::OAuthFlowDetector; use crate::query_params::QueryParameterTokenDetector; use crate::reset_tokens::ResetTokenDetector; use crate::sessions::{RefreshTokenLifecycleDetector, SessionLifecycleDetector}; @@ -22,6 +23,7 @@ impl DetectorRegistry { Self::empty() .with_detector(Box::new(CookieSetDetector)) .with_detector(Box::new(JwtDetector)) + .with_detector(Box::new(OAuthFlowDetector)) .with_detector(Box::new(BearerTokenDetector)) .with_detector(Box::new(QueryParameterTokenDetector)) .with_detector(Box::new(ResetTokenDetector)) diff --git a/crates/sessionscope-detectors/src/sessions/mod.rs b/crates/sessionscope-detectors/src/sessions/mod.rs index 840a112..ed776d2 100644 --- a/crates/sessionscope-detectors/src/sessions/mod.rs +++ b/crates/sessionscope-detectors/src/sessions/mod.rs @@ -2154,6 +2154,7 @@ fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/crates/sessionscope-model/src/artifact.rs b/crates/sessionscope-model/src/artifact.rs index 31766f4..11bbb09 100644 --- a/crates/sessionscope-model/src/artifact.rs +++ b/crates/sessionscope-model/src/artifact.rs @@ -18,6 +18,12 @@ pub enum ArtifactType { UnknownToken, PasswordResetToken, EmailVerificationToken, + /// Source-visible OAuth/OIDC authorization-code flow construction. + /// + /// Added by the P3.1 artifact audit because existing session, bearer, + /// cookie, and JWT artifacts do not accurately model an authorization + /// request plus callback verification lifecycle. + OAuthAuthCodeFlow, SessionRecord, Unknown, } diff --git a/crates/sessionscope-reporters/src/markdown.rs b/crates/sessionscope-reporters/src/markdown.rs index 2c6f102..5d68f5e 100644 --- a/crates/sessionscope-reporters/src/markdown.rs +++ b/crates/sessionscope-reporters/src/markdown.rs @@ -498,6 +498,7 @@ fn format_artifact_type(artifact_type: ArtifactType) -> &'static str { ArtifactType::PasswordResetToken => "password_reset_token", ArtifactType::EmailVerificationToken => "email_verification_token", ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", ArtifactType::Unknown => "unknown", } } diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 58cfb60..37b1473 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -84,6 +84,7 @@ types are: - `unknown_token` - `password_reset_token` - `email_verification_token` +- `oauth_auth_code_flow` - `session_record` - `unknown` @@ -91,7 +92,10 @@ Artifacts include a stable ID, type, optional safe display name, source locations, confidence, framework hints, lifecycle evidence references, optional cookie attributes for cookie artifacts, optional JWT attributes for JWT artifacts, and optional token boundary attributes for JWT, bearer, API-key, and -service-token artifacts. +service-token artifacts. `oauth_auth_code_flow` artifacts represent source-visible +OAuth/OIDC authorization-code flow construction and callback/verification +evidence; they intentionally do not store authorization codes, `state`, `nonce`, +PKCE verifier/challenge values, tokens, or client secrets. Lifecycle evidence is grouped by stage: diff --git a/docs/USAGE.md b/docs/USAGE.md index 0833aec..669cce5 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -112,6 +112,7 @@ SessionScope classifies the artifacts it identifies into the following categorie - unknown token flows - password-reset tokens - email-verification tokens +- OAuth/OIDC authorization-code flow construction - device or session records - token scope and trust-boundary evidence @@ -125,6 +126,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - JWT verification without issuer validation - JWT verification without audience validation - JWT crypto-trust hardening, including `jwt_alg_none_accepted`, `jwt_alg_confusion_signal`, `jwt_jku_header_trust`, `jwt_x5u_header_trust`, `jwt_embedded_jwk_trust`, `jwt_nbf_missing`, `jwt_clock_skew_review`, and `jwt_kid_unvalidated_review` +- OAuth/OIDC flow evidence for authorization-code construction across Passport OAuth2, openid-client, NextAuth/Auth.js provider blocks, Authlib, and generic OAuth/OIDC code. P3 classifiers evaluate PKCE, `state`, OIDC `nonce`, and redirect-URI risks from this source-bound evidence. - Tokens issued without explicit expiry - Refresh tokens without rotation evidence - Logout without revocation evidence From 311128db781213e018942a3ccb74874cf47fa9c5 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:36:04 -0500 Subject: [PATCH 11/29] Fix #85: P3.9 OAuth state nonce and PKCE redaction --- CHANGELOG.md | 1 + crates/sessionscope-core/src/redaction.rs | 39 ++++++++++++- crates/sessionscope-reporters/src/lib.rs | 70 +++++++++++++++++++++++ docs/DATA_HANDLING.md | 3 +- 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5242fb7..16e695e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and SessionScope uses semantic versioning as described in - Added `docs/COVERAGE_MATRIX.md` as the per-check source of truth for supported, review-required, and intentionally not-covered SessionScope evidence patterns across languages, frameworks, libraries, lifecycle stages, finding categories, and SARIF rule IDs. - Added v0.2 P1 cookie hardening coverage for `__Host-` / `__Secure-` prefix rules, `Partitioned` cookie review, broad non-session Domain review, and same-handler conflicting cookie writes across existing JS/TS/Python cookie detectors. - Added v0.2 P2 JWT crypto-trust coverage for `alg:none`, HMAC/asymmetric algorithm-confusion signals, `jku`/`x5u`/embedded-JWK header trust review, missing `nbf` validation, broad clock-skew review, and unvalidated `kid` header review across existing `jsonwebtoken`, `jose`, and PyJWT detector surfaces. +- Extended report redaction for OAuth/OIDC `state`, `nonce`, `code_verifier`, and `code_challenge` values in assignments, object keys, and URL parameters. ### Pre-release remediation (v0.1.0 readiness) diff --git a/crates/sessionscope-core/src/redaction.rs b/crates/sessionscope-core/src/redaction.rs index 47361fd..0c00e26 100644 --- a/crates/sessionscope-core/src/redaction.rs +++ b/crates/sessionscope-core/src/redaction.rs @@ -26,7 +26,7 @@ static API_KEY_HEADER_RE: LazyLock = LazyLock::new(|| { }); static URL_PARAM_RE: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?i)([?&](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|apikey|secret|session|jwt|code)=)([^&#\s"']+)"#, + r#"(?i)([?&#](?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|api[_-]?key|apikey|secret|session|jwt|code|state|nonce|code[_-]?verifier|code[_-]?challenge)=)([^&#\s"']+)"#, ) .expect("URL param regex should compile") }); @@ -52,13 +52,13 @@ static QUOTED_LITERAL_RE: LazyLock = LazyLock::new(|| { }); static SENSITIVE_QUOTED_ASSIGNMENT_RE: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?ix)(["']?\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|reset[_-]?token|session[_-]?token|csrf[_-]?token|api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|jwt|sessionid|private[_-]?key|signing[_-]?key)\b["']?\s*[:=]\s*)(["'])([^"']*)(["'])"#, + r#"(?ix)(["']?\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|reset[_-]?token|session[_-]?token|csrf[_-]?token|api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|jwt|sessionid|private[_-]?key|signing[_-]?key|state|nonce|code[_-]?verifier|code[_-]?challenge|codeVerifier|codeChallenge)\b["']?\s*[:=]\s*)(["'])([^"']*)(["'])"#, ) .expect("sensitive quoted assignment regex should compile") }); static SENSITIVE_UNQUOTED_ASSIGNMENT_RE: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?ix)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|reset[_-]?token|session[_-]?token|bearer[_-]?token|service[_-]?token|authorization|csrf[_-]?token|api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|jwt|sessionid|private[_-]?key|signing[_-]?key)\b\s*[:=]\s*)([^\s,;)\]\[}'"]+)"#, + r#"(?ix)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|reset[_-]?token|session[_-]?token|bearer[_-]?token|service[_-]?token|authorization|csrf[_-]?token|api[_-]?key|apikey|secret|client[_-]?secret|password|passwd|jwt|sessionid|private[_-]?key|signing[_-]?key|state|nonce|code[_-]?verifier|code[_-]?challenge|codeVerifier|codeChallenge)\b\s*[:=]\s*)([^\s,;)\]\[}'"]+)"#, ) .expect("sensitive unquoted assignment regex should compile") }); @@ -103,6 +103,7 @@ pub enum RedactionContext { Jwt, Bearer, ApiKey, + OAuth, Generic, } @@ -685,6 +686,37 @@ mod tests { assert!(!output.contains("abcdefghijklmnopqrstuvwxyzABCDEF0123456789")); } + #[test] + fn redacts_oauth_state_nonce_and_pkce_material() { + let source = concat!( + "const state = \"abcdefghijklmnopqrstuvwxyzABCDEF0123456789\";\n", + "const nonce = 'ZYXWVUTSRQPONMLKJIHGFEDCBA987654';\n", + "const code_verifier = \"verifierabcdefghijklmnopqrstuvwxyz123456\";\n", + "const codeChallenge = 'challengeabcdefghijklmnopqrstuvwxyz123456';\n", + "const callback = \"/cb?state=stateabcdefghijklmnopqrstuvwxyz123456&nonce=nonceabcdefghijklmnopqrstuvwxyz123456#code_challenge=challengeabcdefghijklmnopqrstuvwxyz123456\";" + ); + + let output = redact_sensitive_values(source); + + for secret in [ + "abcdefghijklmnopqrstuvwxyzABCDEF0123456789", + "ZYXWVUTSRQPONMLKJIHGFEDCBA987654", + "verifierabcdefghijklmnopqrstuvwxyz123456", + "challengeabcdefghijklmnopqrstuvwxyz123456", + "stateabcdefghijklmnopqrstuvwxyz123456", + "nonceabcdefghijklmnopqrstuvwxyz123456", + ] { + assert!(!output.contains(secret), "OAuth value leaked: {output}"); + } + assert!(output.contains("state = \"[REDACTED]\"")); + assert!(output.contains("nonce = '[REDACTED]'")); + assert!(output.contains("code_verifier = \"[REDACTED]\"")); + assert!(output.contains("codeChallenge = '[REDACTED]'")); + assert!(output.contains("state=[REDACTED]")); + assert!(output.contains("nonce=[REDACTED]")); + assert!(output.contains("code_challenge=[REDACTED]")); + } + #[test] fn redacts_placeholder_secret_values() { let output = redact_sensitive_values( @@ -709,6 +741,7 @@ mod tests { RedactionContext::Jwt, RedactionContext::Bearer, RedactionContext::ApiKey, + RedactionContext::OAuth, ] { let excerpt = safe_excerpt_with_context(source, 200, context); assert!( diff --git a/crates/sessionscope-reporters/src/lib.rs b/crates/sessionscope-reporters/src/lib.rs index 97e8d7d..1a8f30d 100644 --- a/crates/sessionscope-reporters/src/lib.rs +++ b/crates/sessionscope-reporters/src/lib.rs @@ -260,6 +260,7 @@ mod tests { use super::{ReportFormat, render}; const SECRET: &str = "abcdefghijklmnopqrstuvwxyzABCDEF0123456789"; + const OAUTH_STATE: &str = "stateabcdefghijklmnopqrstuvwxyzABCDEF0123456789"; fn unsafe_report() -> ScanReport { let evidence_id = EvidenceId("evidence_report_secret".to_string()); @@ -370,4 +371,73 @@ mod tests { assert!(output.contains("\\[REDACTED\\]")); assert!(!output.contains(SECRET)); } + + #[test] + fn render_sanitizes_oauth_values_in_json_and_markdown() { + let evidence_id = EvidenceId("evidence_oauth_state".to_string()); + let report = ScanReport { + schema_version: SCHEMA_VERSION.to_string(), + summary: ScanSummary { + files_discovered: 1, + files_scanned: 1, + files_skipped: 0, + diagnostics: Vec::new(), + worker_panic_count: 0, + skipped_by_reason: std::collections::BTreeMap::new(), + }, + files: Vec::new(), + artifacts: vec![Artifact { + id: ArtifactId("artifact_oauth_flow".to_string()), + artifact_type: ArtifactType::OAuthAuthCodeFlow, + display_name: Some("oauth_auth_code_flow".to_string()), + locations: Vec::new(), + lifecycle_evidence: LifecycleEvidence::default(), + confidence: Confidence::High, + framework_hints: vec!["oauth-generic".to_string()], + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, + }], + evidence: vec![Evidence { + id: evidence_id.clone(), + lifecycle_stage: LifecycleStage::Issue, + location: SourceLocation { + path: "src/oauth.ts".to_string(), + line: Some(12), + column: Some(1), + }, + detector_id: "oauth.state.present".to_string(), + confidence: Confidence::High, + excerpt: Some(SanitizedExcerpt::from_sanitized(format!( + "authorizationUrl({{ state: '{OAUTH_STATE}', nonce: 'nonceabcdefghijklmnopqrstuvwxyzABCDEF0123456789', code_verifier: 'verifierabcdefghijklmnopqrstuvwxyzABCDEF0123456789' }})" + ))), + dynamic: false, + framework_default: false, + }], + lifecycle_paths: Vec::new(), + findings: vec![Finding { + id: FindingId("finding_oauth_state".to_string()), + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + artifact_ids: vec![ArtifactId("artifact_oauth_flow".to_string())], + evidence_ids: vec![evidence_id], + title: format!("OAuth state {OAUTH_STATE} is static"), + description: format!("OAuth state value {OAUTH_STATE} must not leak"), + suggested_fix: None, + reviewer_question: None, + }], + }; + + for format in [ReportFormat::Json, ReportFormat::Markdown] { + let output = render(&report, format); + assert!(output.contains("REDACTED"), "{format:?} did not redact"); + for secret in [ + OAUTH_STATE, + "nonceabcdefghijklmnopqrstuvwxyzABCDEF0123456789", + "verifierabcdefghijklmnopqrstuvwxyzABCDEF0123456789", + ] { + assert!(!output.contains(secret), "{format:?} leaked {secret}"); + } + } + } } diff --git a/docs/DATA_HANDLING.md b/docs/DATA_HANDLING.md index c2e75ff..3cc4d2c 100644 --- a/docs/DATA_HANDLING.md +++ b/docs/DATA_HANDLING.md @@ -4,7 +4,7 @@ SessionScope analyzes source you point it at and produces reviewable reports. Th ## Redaction trust boundary -SessionScope treats source text and detector output as untrusted until it has passed through `sessionscope-core::redaction`. Evidence excerpts and rendered reports keep source locations, finding IDs, lifecycle stages, claim names, and attribute names — but **token values, cookie values, bearer strings, private keys, and high-entropy secret-like literals are replaced with `[REDACTED]`** before they reach any reporter. +SessionScope treats source text and detector output as untrusted until it has passed through `sessionscope-core::redaction`. Evidence excerpts and rendered reports keep source locations, finding IDs, lifecycle stages, claim names, and attribute names — but **token values, cookie values, bearer strings, private keys, OAuth `state`, `nonce`, `code_verifier`, `code_challenge` values, and high-entropy secret-like literals are replaced with `[REDACTED]`** before they reach any reporter. Stable IDs and source locations are preserved for reviewability. They must never be generated from runtime token values, private keys, bearer strings, cookie values, or other secrets. @@ -26,6 +26,7 @@ Reports retain the information a reviewer needs: Before rendering: - Token, cookie, and bearer **values**. +- OAuth/OIDC correlation and PKCE values named `state`, `nonce`, `code_verifier`, `code_challenge`, `codeVerifier`, or `codeChallenge`, including URL parameter values. - Private-key material. - High-entropy string literals that match secret-like patterns. - Source excerpts have these spans replaced with `[REDACTED]`. From 299507371c9e7698f5137392ddbf4250df91c92d Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:37:21 -0500 Subject: [PATCH 12/29] Fix #78: P3.2 PKCE missing classifier --- crates/sessionscope-classifier/src/lib.rs | 2 + .../sessionscope-classifier/src/oauth_flow.rs | 220 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 crates/sessionscope-classifier/src/oauth_flow.rs diff --git a/crates/sessionscope-classifier/src/lib.rs b/crates/sessionscope-classifier/src/lib.rs index 4046d34..a414bbd 100644 --- a/crates/sessionscope-classifier/src/lib.rs +++ b/crates/sessionscope-classifier/src/lib.rs @@ -2,6 +2,7 @@ pub mod bearer; pub mod cookies; pub mod jwt; pub mod lifecycle; +pub mod oauth_flow; pub mod query_params; pub mod session_fixation; pub mod trust_boundary; @@ -12,6 +13,7 @@ pub fn classify(mut report: ScanReport) -> ScanReport { report.lifecycle_paths = lifecycle::link(&report); report.findings = cookies::classify(&report); report.findings.extend(jwt::classify(&report)); + report.findings.extend(oauth_flow::classify(&report)); report.findings.extend(bearer::classify(&report)); report.findings.extend(trust_boundary::classify(&report)); report.findings.extend(query_params::classify(&report)); diff --git a/crates/sessionscope-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs new file mode 100644 index 0000000..897e6f7 --- /dev/null +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -0,0 +1,220 @@ +use std::collections::BTreeSet; + +use sessionscope_model::{ + Artifact, ArtifactType, Evidence, EvidenceId, Finding, FindingCategory, ScanReport, Severity, + stable_finding_id, +}; + +pub fn classify(report: &ScanReport) -> Vec { + let mut findings = Vec::new(); + + for artifact in &report.artifacts { + if artifact.artifact_type != ArtifactType::OAuthAuthCodeFlow { + continue; + } + + let evidence = artifact_evidence(report, artifact); + if evidence + .iter() + .any(|item| item.detector_id == "oauth.flow.auth_code") + { + findings.extend(classify_pkce(report, artifact, &evidence)); + } + } + + dedupe_findings(findings) +} + +fn classify_pkce( + report: &ScanReport, + artifact: &Artifact, + evidence: &[&Evidence], +) -> Option { + if evidence + .iter() + .any(|item| item.detector_id == "oauth.pkce.present") + { + return None; + } + + let mut evidence_ids = detector_ids(evidence, "oauth.flow.auth_code"); + evidence_ids.extend(detector_ids(evidence, "oauth.flow.framework_default")); + if evidence_ids.is_empty() { + evidence_ids = artifact.lifecycle_evidence.issue.clone(); + } + + Some(finding( + artifact, + FindingSpec { + rule_id: "oauth_pkce_missing_review", + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids, + title: "OAuth authorization-code flow has no source-visible PKCE evidence".to_string(), + description: "The authorization-code flow construction was detected without a source-visible `code_challenge`, `code_challenge_method`, `code_verifier`, or library PKCE option in the same flow evidence. Some providers and client libraries enable PKCE by default, so this requires review rather than a deterministic misconfiguration conclusion.".to_string(), + suggested_fix: "Configure PKCE explicitly with an S256 code challenge when the OAuth/OIDC client library allows it, or document the provider/library default that enforces PKCE.".to_string(), + reviewer_question: "Does this OAuth/OIDC provider or client library enforce PKCE for this authorization-code flow in production?".to_string(), + }, + report, + )) +} + +struct FindingSpec { + rule_id: &'static str, + category: FindingCategory, + severity: Severity, + evidence_ids: Vec, + title: String, + description: String, + suggested_fix: String, + reviewer_question: String, +} + +fn finding(artifact: &Artifact, spec: FindingSpec, report: &ScanReport) -> Finding { + let evidence_part = spec + .evidence_ids + .first() + .map(|id| id.0.as_str()) + .unwrap_or("no_evidence"); + let path_part = first_evidence(report, &spec.evidence_ids) + .map(|evidence| evidence.location.path.as_str()) + .unwrap_or("unknown_path"); + let id = stable_finding_id(&[ + spec.rule_id, + artifact.id.0.as_str(), + evidence_part, + path_part, + ]); + + Finding { + id, + category: spec.category, + severity: spec.severity, + artifact_ids: vec![artifact.id.clone()], + evidence_ids: spec.evidence_ids, + title: spec.title, + description: spec.description, + suggested_fix: Some(spec.suggested_fix), + reviewer_question: Some(spec.reviewer_question), + } +} + +fn artifact_evidence<'a>(report: &'a ScanReport, artifact: &Artifact) -> Vec<&'a Evidence> { + let ids = artifact_evidence_ids(artifact); + report + .evidence + .iter() + .filter(|evidence| ids.contains(&evidence.id)) + .collect() +} + +fn artifact_evidence_ids(artifact: &Artifact) -> BTreeSet { + artifact + .lifecycle_evidence + .issue + .iter() + .chain(&artifact.lifecycle_evidence.store) + .chain(&artifact.lifecycle_evidence.transmit) + .chain(&artifact.lifecycle_evidence.validate) + .chain(&artifact.lifecycle_evidence.refresh) + .chain(&artifact.lifecycle_evidence.revoke) + .chain(&artifact.lifecycle_evidence.expire) + .chain(&artifact.lifecycle_evidence.introspect) + .cloned() + .collect() +} + +fn detector_ids(evidence: &[&Evidence], detector_id: &str) -> Vec { + evidence + .iter() + .filter(|item| item.detector_id == detector_id) + .map(|item| item.id.clone()) + .collect() +} + +fn first_evidence<'a>(report: &'a ScanReport, ids: &[EvidenceId]) -> Option<&'a Evidence> { + ids.iter() + .find_map(|id| report.evidence.iter().find(|item| &item.id == id)) +} + +fn dedupe_findings(findings: Vec) -> Vec { + let mut seen = BTreeSet::new(); + findings + .into_iter() + .filter(|finding| seen.insert(finding.id.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use sessionscope_model::{ + ArtifactId, Confidence, LifecycleEvidence, LifecycleStage, SCHEMA_VERSION, ScanSummary, + SourceLocation, + }; + + use super::*; + + fn report_with(detector_ids: &[&str]) -> ScanReport { + let evidence = detector_ids + .iter() + .enumerate() + .map(|(index, detector_id)| Evidence { + id: EvidenceId(format!("evidence_{index}")), + lifecycle_stage: LifecycleStage::Issue, + location: SourceLocation { + path: "src/auth.ts".to_string(), + line: Some(index + 1), + column: Some(1), + }, + detector_id: detector_id.to_string(), + confidence: Confidence::High, + excerpt: None, + dynamic: false, + framework_default: false, + }) + .collect::>(); + let mut lifecycle_evidence = LifecycleEvidence::default(); + lifecycle_evidence.issue = evidence.iter().map(|item| item.id.clone()).collect(); + + ScanReport { + schema_version: SCHEMA_VERSION.to_string(), + summary: ScanSummary::default(), + files: Vec::new(), + artifacts: vec![Artifact { + id: ArtifactId("artifact_oauth".to_string()), + artifact_type: ArtifactType::OAuthAuthCodeFlow, + display_name: Some("oauth_auth_code_flow".to_string()), + locations: Vec::new(), + lifecycle_evidence, + confidence: Confidence::High, + framework_hints: Vec::new(), + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, + }], + evidence, + lifecycle_paths: Vec::new(), + findings: Vec::new(), + } + } + + #[test] + fn flags_auth_code_flow_without_pkce() { + let report = report_with(&["oauth.flow.auth_code"]); + let findings = classify(&report); + + assert!(findings.iter().any(|finding| { + finding.title.contains("PKCE") + && finding.category == FindingCategory::DynamicReviewRequired + && finding.severity == Severity::Medium + && finding.evidence_ids == vec![EvidenceId("evidence_0".to_string())] + })); + } + + #[test] + fn suppresses_auth_code_flow_with_pkce() { + let report = report_with(&["oauth.flow.auth_code", "oauth.pkce.present"]); + + assert!(classify(&report).is_empty()); + } +} From 52b8c6ea175b249a1a692990c260b2722db2b724 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:38:40 -0500 Subject: [PATCH 13/29] Fix #79: P3.3 OAuth state integrity classifier --- .../sessionscope-classifier/src/oauth_flow.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/crates/sessionscope-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs index 897e6f7..11859f4 100644 --- a/crates/sessionscope-classifier/src/oauth_flow.rs +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -19,12 +19,96 @@ pub fn classify(report: &ScanReport) -> Vec { .any(|item| item.detector_id == "oauth.flow.auth_code") { findings.extend(classify_pkce(report, artifact, &evidence)); + findings.extend(classify_state(report, artifact, &evidence)); } } dedupe_findings(findings) } +fn classify_state( + report: &ScanReport, + artifact: &Artifact, + evidence: &[&Evidence], +) -> Vec { + let mut findings = Vec::new(); + let has_state = evidence.iter().any(|item| { + matches!( + item.detector_id.as_str(), + "oauth.state.present" + | "oauth.state.static" + | "oauth.state.callback_read" + | "oauth.state.verified" + ) + }); + let has_verified = evidence + .iter() + .any(|item| item.detector_id == "oauth.state.verified"); + + if !has_state { + findings.push(finding( + artifact, + FindingSpec { + rule_id: "oauth_state_missing", + category: FindingCategory::MissingValidationEvidence, + severity: Severity::Medium, + evidence_ids: detector_ids(evidence, "oauth.flow.auth_code"), + title: "OAuth authorization-code flow has no source-visible state parameter" + .to_string(), + description: "The authorization-code flow construction lacks source-visible `state` evidence. State is the caller-visible CSRF/correlation value that should be generated per authorization request and verified on callback.".to_string(), + suggested_fix: "Generate a cryptographically random state value, bind it to server-side or signed client-side state, include it in the authorization request, and verify it on callback.".to_string(), + reviewer_question: "Does another framework/provider layer add and validate OAuth state for this flow?".to_string(), + }, + report, + )); + } + + if evidence + .iter() + .any(|item| item.detector_id == "oauth.state.static") + { + findings.push(finding( + artifact, + FindingSpec { + rule_id: "oauth_state_static_review", + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids: detector_ids(evidence, "oauth.state.static"), + title: "OAuth state appears to be assigned from a static literal".to_string(), + description: "State evidence is source-visible but appears to come from a literal value rather than per-request cryptographic randomness. The literal value is redacted from evidence.".to_string(), + suggested_fix: "Generate state with a cryptographically secure random source for each authorization request and avoid committing static state values.".to_string(), + reviewer_question: "Is this literal only test code, or can production requests reuse the same OAuth state value?".to_string(), + }, + report, + )); + } + + if evidence + .iter() + .any(|item| item.detector_id == "oauth.state.callback_read") + && !has_verified + { + let mut ids = detector_ids(evidence, "oauth.state.callback_read"); + ids.extend(detector_ids(evidence, "oauth.state.present")); + findings.push(finding( + artifact, + FindingSpec { + rule_id: "oauth_state_unverified_review", + category: FindingCategory::MissingValidationEvidence, + severity: Severity::High, + evidence_ids: ids, + title: "OAuth callback reads state without visible verification".to_string(), + description: "Callback evidence reads an OAuth `state` value, but SessionScope did not find a source-visible comparison against server-side session/cache state or a signed cookie/helper value in the same flow evidence.".to_string(), + suggested_fix: "Compare the callback state with the value stored when the authorization request was issued, then reject mismatches before exchanging the code.".to_string(), + reviewer_question: "Where is this callback state compared with the originally issued state value?".to_string(), + }, + report, + )); + } + + findings +} + fn classify_pkce( report: &ScanReport, artifact: &Artifact, @@ -215,6 +299,56 @@ mod tests { fn suppresses_auth_code_flow_with_pkce() { let report = report_with(&["oauth.flow.auth_code", "oauth.pkce.present"]); + assert!( + classify(&report) + .iter() + .all(|finding| !finding.title.contains("PKCE")) + ); + } + + #[test] + fn flags_missing_static_and_unverified_state() { + let missing = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + ])); + assert!( + missing + .iter() + .any(|finding| finding.title.contains("no source-visible state")) + ); + + let static_state = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.static", + ])); + assert!( + static_state + .iter() + .any(|finding| finding.title.contains("static literal")) + ); + + let unverified = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.callback_read", + ])); + assert!(unverified.iter().any(|finding| { + finding.title.contains("without visible verification") + && finding.severity == Severity::High + })); + } + + #[test] + fn suppresses_state_findings_when_state_is_verified() { + let report = report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + ]); + assert!(classify(&report).is_empty()); } } From 7a149cb4f682b603046332c97f0608d018615735 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:39:21 -0500 Subject: [PATCH 14/29] Fix #80: P3.4 OIDC nonce classifier --- .../sessionscope-classifier/src/oauth_flow.rs | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/sessionscope-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs index 11859f4..8d987d1 100644 --- a/crates/sessionscope-classifier/src/oauth_flow.rs +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -20,12 +20,71 @@ pub fn classify(report: &ScanReport) -> Vec { { findings.extend(classify_pkce(report, artifact, &evidence)); findings.extend(classify_state(report, artifact, &evidence)); + findings.extend(classify_nonce(report, artifact, &evidence)); } } dedupe_findings(findings) } +fn classify_nonce( + report: &ScanReport, + artifact: &Artifact, + evidence: &[&Evidence], +) -> Vec { + if !evidence + .iter() + .any(|item| item.detector_id == "oauth.oidc.openid_scope") + { + return Vec::new(); + } + + let has_nonce = evidence + .iter() + .any(|item| item.detector_id == "oauth.nonce.present"); + let has_verified = evidence + .iter() + .any(|item| item.detector_id == "oauth.nonce.verified"); + let mut findings = Vec::new(); + + if !has_nonce { + let mut ids = detector_ids(evidence, "oauth.oidc.openid_scope"); + ids.extend(detector_ids(evidence, "oauth.flow.auth_code")); + findings.push(finding( + artifact, + FindingSpec { + rule_id: "oidc_nonce_missing", + category: FindingCategory::MissingValidationEvidence, + severity: Severity::Medium, + evidence_ids: ids, + title: "OIDC authorization flow has no source-visible nonce parameter" + .to_string(), + description: "The flow requests an OIDC `openid` scope, but SessionScope did not find source-visible nonce evidence in the authorization request construction.".to_string(), + suggested_fix: "Generate a per-request nonce, include it in the OIDC authorization request, and verify the same nonce during ID-token validation.".to_string(), + reviewer_question: "Does a framework/provider layer add an OIDC nonce for this authorization request?".to_string(), + }, + report, + )); + } else if !has_verified { + findings.push(finding( + artifact, + FindingSpec { + rule_id: "oidc_nonce_unverified_review", + category: FindingCategory::MissingValidationEvidence, + severity: Severity::Medium, + evidence_ids: detector_ids(evidence, "oauth.nonce.present"), + title: "OIDC nonce is set without visible ID-token nonce verification".to_string(), + description: "OIDC flow evidence includes a nonce, but SessionScope did not find same-flow ID-token verification options or comparison evidence for that nonce.".to_string(), + suggested_fix: "Pass the expected nonce to the ID-token verification API or compare the validated ID-token nonce claim with the issued nonce.".to_string(), + reviewer_question: "Where is the OIDC nonce checked during ID-token verification?".to_string(), + }, + report, + )); + } + + findings +} + fn classify_state( report: &ScanReport, artifact: &Artifact, @@ -351,4 +410,56 @@ mod tests { assert!(classify(&report).is_empty()); } + + #[test] + fn flags_oidc_nonce_missing_and_unverified() { + let missing = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.oidc.openid_scope", + ])); + assert!( + missing + .iter() + .any(|finding| finding.title.contains("no source-visible nonce")) + ); + + let unverified = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.oidc.openid_scope", + "oauth.nonce.present", + ])); + assert!(unverified.iter().any(|finding| { + finding + .title + .contains("without visible ID-token nonce verification") + })); + } + + #[test] + fn suppresses_nonce_checks_for_oauth_only_and_verified_oidc() { + let oauth_only = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + ])); + assert!(oauth_only.is_empty()); + + let verified = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.oidc.openid_scope", + "oauth.nonce.present", + "oauth.nonce.verified", + ])); + assert!(verified.is_empty()); + } } From 853ce6e10f3b39a976fa8e3816e5a11a9128638b Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:39:54 -0500 Subject: [PATCH 15/29] Fix #81: P3.5 redirect URI wildcard classifier --- .../sessionscope-classifier/src/oauth_flow.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/sessionscope-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs index 8d987d1..0b5adbd 100644 --- a/crates/sessionscope-classifier/src/oauth_flow.rs +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -21,12 +21,41 @@ pub fn classify(report: &ScanReport) -> Vec { findings.extend(classify_pkce(report, artifact, &evidence)); findings.extend(classify_state(report, artifact, &evidence)); findings.extend(classify_nonce(report, artifact, &evidence)); + findings.extend(classify_redirect_uri(report, artifact, &evidence)); } } dedupe_findings(findings) } +fn classify_redirect_uri( + report: &ScanReport, + artifact: &Artifact, + evidence: &[&Evidence], +) -> Option { + if !evidence + .iter() + .any(|item| item.detector_id == "oauth.redirect_uri.broad") + { + return None; + } + + Some(finding( + artifact, + FindingSpec { + rule_id: "oauth_redirect_uri_wildcard_review", + category: FindingCategory::DynamicReviewRequired, + severity: Severity::Medium, + evidence_ids: detector_ids(evidence, "oauth.redirect_uri.broad"), + title: "OAuth redirect URI literal appears broad or wildcarded".to_string(), + description: "A source-visible `redirect_uri` / `redirect_uris` literal contains a wildcard or broad host-only shape. Final matching is enforced by the authorization server, so this is review-required evidence rather than proof of provider-side configuration.".to_string(), + suggested_fix: "Register exact redirect URIs with concrete hosts and callback paths; avoid wildcard or bare-host redirect URI entries unless the provider constrains them elsewhere.".to_string(), + reviewer_question: "Does the authorization server restrict this client to exact redirect URIs despite the broad source literal?".to_string(), + }, + report, + )) +} + fn classify_nonce( report: &ScanReport, artifact: &Artifact, @@ -462,4 +491,35 @@ mod tests { ])); assert!(verified.is_empty()); } + + #[test] + fn flags_broad_redirect_uri_literals() { + let findings = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.redirect_uri.literal", + "oauth.redirect_uri.broad", + ])); + + assert!(findings.iter().any(|finding| { + finding.title.contains("redirect URI") + && finding.category == FindingCategory::DynamicReviewRequired + && finding.severity == Severity::Medium + })); + } + + #[test] + fn suppresses_exact_redirect_uri_literals() { + let findings = classify(&report_with(&[ + "oauth.flow.auth_code", + "oauth.pkce.present", + "oauth.state.present", + "oauth.state.verified", + "oauth.redirect_uri.literal", + ])); + + assert!(findings.is_empty()); + } } From 79eb8dbc13715b7c42a5966c4e1eb7cbf7ac3846 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:41:40 -0500 Subject: [PATCH 16/29] Fix #82: P3.6 client_storage detector registration --- .../src/client_storage/mod.rs | 385 ++++++++++++++++++ crates/sessionscope-detectors/src/lib.rs | 1 + crates/sessionscope-detectors/src/registry.rs | 2 + 3 files changed, 388 insertions(+) create mode 100644 crates/sessionscope-detectors/src/client_storage/mod.rs diff --git a/crates/sessionscope-detectors/src/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs new file mode 100644 index 0000000..dd06027 --- /dev/null +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -0,0 +1,385 @@ +use std::sync::LazyLock; + +use regex::Regex; +use sessionscope_model::{ + Artifact, ArtifactType, Confidence, Evidence, EvidenceId, Language, LifecycleEvidence, + LifecycleStage, SanitizedExcerpt, SourceLocation, stable_artifact_id, stable_evidence_id, +}; + +use crate::{DetectionOutput, Detector, DetectorInput}; + +const DETECTOR_ID: &str = "client.storage"; +const REDACTION: &str = "[REDACTED]"; + +static STORAGE_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?i)\b(localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?:"([^"]+)"|'([^']+)')"#, + ) + .expect("storage regex should compile") +}); +static DOCUMENT_COOKIE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)document\s*\.\s*cookie\s*="#).expect("document cookie regex should compile") +}); +static COOKIE_KEY_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(["'])([^="';]+)="#).expect("cookie key regex should compile")); +static URL_PATH_FRAGMENT_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)([#/](?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)(?:[=/]|\b))"#) + .expect("url path/fragment regex should compile") +}); +static CLIENT_SECRET_RE: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(?i)\b(client_secret|clientSecret)\b\s*[:=]\s*(["'][^"']+["']|[A-Za-z0-9._~+/-]{12,})"#, + ) + .expect("client secret regex should compile") +}); +static SENSITIVE_VALUE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)(\b(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session|client[_-]?secret|clientSecret)\b\s*[:=,]\s*)(["'])([^"']*)(["'])"#) + .expect("sensitive value regex should compile") +}); + +#[derive(Debug, Clone, Copy, Default)] +pub struct ClientStorageDetector; + +impl Detector for ClientStorageDetector { + fn id(&self) -> &'static str { + DETECTOR_ID + } + + fn detect(&self, input: &DetectorInput<'_>) -> DetectionOutput { + match input.language { + Language::JavaScript | Language::TypeScript => detect(input), + _ => DetectionOutput::default(), + } + } +} + +#[derive(Debug, Clone)] +struct Signal { + detector_id: &'static str, + stage: LifecycleStage, + artifact_type: ArtifactType, + display_name: String, + line: usize, + column: usize, + confidence: Confidence, + dynamic: bool, + excerpt: SanitizedExcerpt, +} + +fn detect(input: &DetectorInput<'_>) -> DetectionOutput { + let mut signals = Vec::new(); + + for (index, line) in input.source.lines().enumerate() { + let line_number = index + 1; + if let Some(captures) = STORAGE_RE.captures(line) { + let storage = captures.get(1).map_or("", |capture| capture.as_str()); + let key = captures + .get(2) + .or_else(|| captures.get(3)) + .map_or("", |capture| capture.as_str()); + if is_token_shaped_name(key) && !is_allowlisted_key(key) { + let detector_id = if storage.eq_ignore_ascii_case("localStorage") { + "client_storage.local_storage.set_item" + } else { + "client_storage.session_storage.set_item" + }; + signals.push(signal( + detector_id, + LifecycleStage::Store, + key, + line, + line_number, + false, + )); + } + } + + if DOCUMENT_COOKIE_RE.is_match(line) { + let key = COOKIE_KEY_RE + .captures(line) + .and_then(|captures| captures.get(2)) + .map_or("document_cookie", |capture| capture.as_str()); + if is_token_shaped_name(key) && !is_allowlisted_key(key) { + signals.push(signal( + "client_storage.document_cookie.write", + LifecycleStage::Store, + key, + line, + line_number, + false, + )); + } + } + + if URL_PATH_FRAGMENT_RE.is_match(line) { + signals.push(signal( + "client_storage.url_path_or_fragment.token", + LifecycleStage::Transmit, + "url_token", + line, + line_number, + false, + )); + } + + if is_browser_client_path(input.path) && CLIENT_SECRET_RE.is_match(line) { + signals.push(signal( + "client_storage.browser.client_secret", + LifecycleStage::Store, + "client_secret", + line, + line_number, + true, + )); + } + } + + signals_to_output(input, signals) +} + +fn signal( + detector_id: &'static str, + stage: LifecycleStage, + display_name: &str, + line: &str, + line_number: usize, + dynamic: bool, +) -> Signal { + Signal { + detector_id, + stage, + artifact_type: artifact_type_for_name(display_name), + display_name: normalize_display_name(display_name), + line: line_number, + column: 1, + confidence: if dynamic { + Confidence::Medium + } else { + Confidence::High + }, + dynamic, + excerpt: SanitizedExcerpt::from_sanitized(sanitize_storage_excerpt(line)), + } +} + +fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> DetectionOutput { + let mut output = DetectionOutput::default(); + for signal in signals { + let line = signal.line.to_string(); + let column = signal.column.to_string(); + let evidence_id = stable_evidence_id(&[ + DETECTOR_ID, + signal.detector_id, + input.path, + line.as_str(), + column.as_str(), + signal.display_name.as_str(), + ]); + let artifact_id = stable_artifact_id(&[ + DETECTOR_ID, + artifact_type_part(signal.artifact_type), + input.path, + signal.display_name.as_str(), + ]); + let mut lifecycle_evidence = LifecycleEvidence::default(); + push_lifecycle_id(&mut lifecycle_evidence, signal.stage, evidence_id.clone()); + let location = SourceLocation { + path: input.path.to_string(), + line: Some(signal.line), + column: Some(signal.column), + }; + output.artifacts.push(Artifact { + id: artifact_id, + artifact_type: signal.artifact_type, + display_name: Some(signal.display_name), + locations: vec![location.clone()], + lifecycle_evidence, + confidence: signal.confidence, + framework_hints: vec!["browser-client".to_string()], + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, + }); + output.evidence.push(Evidence { + id: evidence_id, + lifecycle_stage: signal.stage, + location, + detector_id: signal.detector_id.to_string(), + confidence: signal.confidence, + excerpt: Some(signal.excerpt), + dynamic: signal.dynamic, + framework_default: false, + }); + } + output +} + +fn is_token_shaped_name(name: &str) -> bool { + let normalized = normalize_display_name(name); + [ + "access_token", + "id_token", + "refresh_token", + "jwt", + "bearer", + "auth", + "session", + ] + .iter() + .any(|needle| normalized.contains(needle)) +} + +fn is_allowlisted_key(name: &str) -> bool { + matches!( + normalize_display_name(name).as_str(), + "theme" | "dark" | "authorship" | "session_replay_consent" | "session_storage_test" + ) +} + +fn normalize_display_name(name: &str) -> String { + name.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-') + .replace('-', "_") + .to_ascii_lowercase() +} + +fn artifact_type_for_name(name: &str) -> ArtifactType { + let normalized = normalize_display_name(name); + if normalized.contains("access_token") + || normalized.contains("id_token") + || normalized.contains("jwt") + { + ArtifactType::AccessJwt + } else if normalized.contains("refresh_token") { + ArtifactType::RefreshJwt + } else if normalized.contains("client_secret") { + ArtifactType::ServiceToken + } else if normalized.contains("session") { + ArtifactType::OpaqueBearerToken + } else { + ArtifactType::UnknownToken + } +} + +fn is_browser_client_path(path: &str) -> bool { + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + normalized.contains("/pages/") + || normalized.contains("/app/") + || normalized.contains("/src/components/") + || normalized.contains("/components/") + || normalized.contains("/public/") + || normalized.starts_with("pages/") + || normalized.starts_with("app/") + || normalized.starts_with("src/components/") + || normalized.starts_with("public/") +} + +fn sanitize_storage_excerpt(line: &str) -> String { + SENSITIVE_VALUE_RE + .replace_all(line, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string() +} + +fn push_lifecycle_id(lifecycle: &mut LifecycleEvidence, stage: LifecycleStage, id: EvidenceId) { + let bucket = match stage { + LifecycleStage::Issue => &mut lifecycle.issue, + LifecycleStage::Store => &mut lifecycle.store, + LifecycleStage::Transmit => &mut lifecycle.transmit, + LifecycleStage::Validate => &mut lifecycle.validate, + LifecycleStage::Refresh => &mut lifecycle.refresh, + LifecycleStage::Revoke => &mut lifecycle.revoke, + LifecycleStage::Expire => &mut lifecycle.expire, + LifecycleStage::Introspect => &mut lifecycle.introspect, + }; + bucket.push(id); +} + +fn artifact_type_part(artifact_type: ArtifactType) -> &'static str { + match artifact_type { + ArtifactType::SessionCookie => "session_cookie", + ArtifactType::SignedCookie => "signed_cookie", + ArtifactType::AccessJwt => "access_jwt", + ArtifactType::RefreshJwt => "refresh_jwt", + ArtifactType::OpaqueBearerToken => "opaque_bearer_token", + ArtifactType::ApiKey => "api_key", + ArtifactType::ServiceToken => "service_token", + ArtifactType::UnknownToken => "unknown_token", + ArtifactType::PasswordResetToken => "password_reset_token", + ArtifactType::EmailVerificationToken => "email_verification_token", + ArtifactType::SessionRecord => "session_record", + ArtifactType::OAuthAuthCodeFlow => "oauth_auth_code_flow", + ArtifactType::Unknown => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input<'a>(path: &'a str, source: &'a str) -> DetectorInput<'a> { + DetectorInput { + path, + source, + language: Language::TypeScript, + } + } + + #[test] + fn detects_browser_storage_and_url_token_evidence() { + let output = ClientStorageDetector.detect(&input( + "src/components/Auth.tsx", + r#" +localStorage.setItem('access_token', token) +sessionStorage.setItem('refresh_token', refresh) +document.cookie = "session=" + sessionId +const url = `/callback#id_token=${idToken}` +const clientSecret = 'PLACEHOLDER_SECRET_DO_NOT_USE' +"#, + )); + + for detector_id in [ + "client_storage.local_storage.set_item", + "client_storage.session_storage.set_item", + "client_storage.document_cookie.write", + "client_storage.url_path_or_fragment.token", + "client_storage.browser.client_secret", + ] { + assert!( + output + .evidence + .iter() + .any(|evidence| evidence.detector_id == detector_id), + "missing {detector_id}" + ); + } + } + + #[test] + fn ignores_benign_local_storage_key() { + let output = ClientStorageDetector.detect(&input( + "src/components/Theme.tsx", + "localStorage.setItem('theme', 'dark')", + )); + + assert!(output.evidence.is_empty()); + } + + #[test] + fn only_flags_client_secret_on_browser_paths() { + let server = ClientStorageDetector.detect(&input( + "src/server/oauth.ts", + "const clientSecret = 'PLACEHOLDER_SECRET_DO_NOT_USE'", + )); + let browser = ClientStorageDetector.detect(&input( + "src/components/oauth.tsx", + "const clientSecret = 'PLACEHOLDER_SECRET_DO_NOT_USE'", + )); + + assert!(server.evidence.is_empty()); + assert!( + browser + .evidence + .iter() + .any(|evidence| evidence.detector_id == "client_storage.browser.client_secret") + ); + } +} diff --git a/crates/sessionscope-detectors/src/lib.rs b/crates/sessionscope-detectors/src/lib.rs index 000b416..e74656c 100644 --- a/crates/sessionscope-detectors/src/lib.rs +++ b/crates/sessionscope-detectors/src/lib.rs @@ -1,4 +1,5 @@ pub mod bearer; +pub mod client_storage; pub mod cookies; pub mod frameworks; pub mod jwt; diff --git a/crates/sessionscope-detectors/src/registry.rs b/crates/sessionscope-detectors/src/registry.rs index 8cbea25..dd37549 100644 --- a/crates/sessionscope-detectors/src/registry.rs +++ b/crates/sessionscope-detectors/src/registry.rs @@ -1,6 +1,7 @@ use std::time::Instant; use crate::bearer::BearerTokenDetector; +use crate::client_storage::ClientStorageDetector; use crate::cookies::CookieSetDetector; use crate::jwt::JwtDetector; use crate::oauth_flow::OAuthFlowDetector; @@ -24,6 +25,7 @@ impl DetectorRegistry { .with_detector(Box::new(CookieSetDetector)) .with_detector(Box::new(JwtDetector)) .with_detector(Box::new(OAuthFlowDetector)) + .with_detector(Box::new(ClientStorageDetector)) .with_detector(Box::new(BearerTokenDetector)) .with_detector(Box::new(QueryParameterTokenDetector)) .with_detector(Box::new(ResetTokenDetector)) From 98ffe39f5c1ed88834d34236e5b1d5feb2d884b5 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:43:02 -0500 Subject: [PATCH 17/29] Fix #83: P3.7 client storage hygiene classifier --- crates/sessionscope-classifier/src/bearer.rs | 17 +- .../src/client_storage.rs | 185 ++++++++++++++++++ crates/sessionscope-classifier/src/lib.rs | 2 + 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 crates/sessionscope-classifier/src/client_storage.rs diff --git a/crates/sessionscope-classifier/src/bearer.rs b/crates/sessionscope-classifier/src/bearer.rs index eba4b43..d51ff27 100644 --- a/crates/sessionscope-classifier/src/bearer.rs +++ b/crates/sessionscope-classifier/src/bearer.rs @@ -15,7 +15,7 @@ pub fn classify(report: &ScanReport) -> Vec { for context in token_contexts(report, &evidence_by_id) { let high_evidence = high_confidence_evidence_ids(&context); - findings.extend(classify_deterministic_risks(&context)); + findings.extend(classify_deterministic_risks(&context, report)); findings.extend(classify_missing_validation(&context, report)); findings.extend(classify_issue_without_expiry(&context, report)); findings.extend(classify_missing_rotation_or_revocation(&context, report)); @@ -63,7 +63,17 @@ fn token_contexts<'a>( contexts.into_values().collect() } -fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec { +fn has_client_storage_same_location(report: &ScanReport, bearer_evidence: &Evidence) -> bool { + report.evidence.iter().any(|evidence| { + matches!( + evidence.detector_id.as_str(), + "client_storage.local_storage.set_item" | "client_storage.session_storage.set_item" + ) && evidence.location.path == bearer_evidence.location.path + && evidence.location.line == bearer_evidence.location.line + }) +} + +fn classify_deterministic_risks(context: &TokenContext<'_>, report: &ScanReport) -> Vec { let mut findings = Vec::new(); for evidence in &context.evidence { @@ -85,7 +95,7 @@ fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec { "Can this token be moved out of query parameters on every request path?" .to_string(), )), - "bearer.store.browser" => findings.push(finding( + "bearer.store.browser" if !has_client_storage_same_location(report, evidence) => findings.push(finding( "bearer_token_browser_storage", FindingCategory::HighConfidenceMisconfiguration, Severity::High, @@ -101,6 +111,7 @@ fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec { .to_string(), "Is this token intended to be readable by browser JavaScript?".to_string(), )), + "bearer.store.browser" => {} "bearer.literal.static" => findings.push(finding( "bearer_static_secret_literal", FindingCategory::HighConfidenceMisconfiguration, diff --git a/crates/sessionscope-classifier/src/client_storage.rs b/crates/sessionscope-classifier/src/client_storage.rs new file mode 100644 index 0000000..2a52af0 --- /dev/null +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -0,0 +1,185 @@ +use std::collections::BTreeSet; + +use sessionscope_model::{ + Artifact, Evidence, EvidenceId, Finding, FindingCategory, ScanReport, Severity, + stable_finding_id, +}; + +pub fn classify(report: &ScanReport) -> Vec { + let mut findings = Vec::new(); + + for evidence in &report.evidence { + let spec = match evidence.detector_id.as_str() { + "client_storage.local_storage.set_item" => Some(( + "token_in_local_storage", + FindingCategory::HighConfidenceMisconfiguration, + Severity::High, + "Token is stored in localStorage", + "Token-shaped evidence is written to localStorage, where browser JavaScript can read it and where it can persist beyond the intended authentication lifecycle.", + "Use an HttpOnly, Secure cookie or another storage pattern that prevents direct script access when possible.", + "Can this token be moved out of localStorage on every production path?", + )), + "client_storage.session_storage.set_item" => Some(( + "token_in_session_storage", + FindingCategory::HighConfidenceMisconfiguration, + Severity::High, + "Token is stored in sessionStorage", + "Token-shaped evidence is written to sessionStorage, where browser JavaScript can read it for the lifetime of the tab/session.", + "Use an HttpOnly, Secure cookie or another storage pattern that prevents direct script access when possible.", + "Can this token be moved out of sessionStorage on every production path?", + )), + "client_storage.url_path_or_fragment.token" => Some(( + "token_in_url_path_or_fragment", + FindingCategory::HighConfidenceMisconfiguration, + Severity::High, + "Token-shaped value is embedded in a URL path or fragment", + "Token-shaped evidence appears in a URL path segment or fragment identifier. URL material can be logged, cached, copied, or exposed to browser history and referrers depending on use.", + "Keep tokens out of URLs; transmit them through safer authorization channels or server-managed session state.", + "Can this URL construction avoid embedding token-shaped material in the path or fragment?", + )), + "client_storage.browser.client_secret" => Some(( + "client_secret_in_browser_code", + FindingCategory::DynamicReviewRequired, + Severity::High, + "Client secret-like evidence appears in browser-shipped code", + "A `client_secret` / `clientSecret` literal or identifier appears in a path that matches browser-client heuristics. SessionScope cannot prove bundling/runtime reachability, so this requires review.", + "Keep OAuth client secrets server-side only; browser clients should use public-client flows such as authorization code with PKCE and no client secret.", + "Is this file ever bundled or served to a browser in production?", + )), + _ => None, + }; + + if let Some(( + rule_id, + category, + severity, + title, + description, + suggested_fix, + reviewer_question, + )) = spec + { + let artifact_ids = artifacts_for_evidence(report, &evidence.id); + findings.push(Finding { + id: stable_finding_id(&[ + rule_id, + evidence.id.0.as_str(), + evidence.location.path.as_str(), + ]), + category, + severity, + artifact_ids: artifact_ids + .iter() + .map(|artifact| artifact.id.clone()) + .collect(), + evidence_ids: vec![evidence.id.clone()], + title: title.to_string(), + description: description.to_string(), + suggested_fix: Some(suggested_fix.to_string()), + reviewer_question: Some(reviewer_question.to_string()), + }); + } + } + + dedupe_findings(findings) +} + +fn artifacts_for_evidence<'a>( + report: &'a ScanReport, + evidence_id: &EvidenceId, +) -> Vec<&'a Artifact> { + report + .artifacts + .iter() + .filter(|artifact| artifact_has_evidence(artifact, evidence_id)) + .collect() +} + +fn artifact_has_evidence(artifact: &Artifact, evidence_id: &EvidenceId) -> bool { + artifact.lifecycle_evidence.issue.contains(evidence_id) + || artifact.lifecycle_evidence.store.contains(evidence_id) + || artifact.lifecycle_evidence.transmit.contains(evidence_id) + || artifact.lifecycle_evidence.validate.contains(evidence_id) + || artifact.lifecycle_evidence.refresh.contains(evidence_id) + || artifact.lifecycle_evidence.revoke.contains(evidence_id) + || artifact.lifecycle_evidence.expire.contains(evidence_id) + || artifact.lifecycle_evidence.introspect.contains(evidence_id) +} + +fn dedupe_findings(findings: Vec) -> Vec { + let mut seen = BTreeSet::new(); + findings + .into_iter() + .filter(|finding| seen.insert(finding.id.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use sessionscope_model::{ + ArtifactId, ArtifactType, Confidence, LifecycleEvidence, LifecycleStage, SCHEMA_VERSION, + ScanSummary, SourceLocation, + }; + + use super::*; + + fn report_with(detector_id: &str) -> ScanReport { + let evidence_id = EvidenceId("evidence_storage".to_string()); + let mut lifecycle_evidence = LifecycleEvidence::default(); + lifecycle_evidence.store.push(evidence_id.clone()); + ScanReport { + schema_version: SCHEMA_VERSION.to_string(), + summary: ScanSummary::default(), + files: Vec::new(), + artifacts: vec![Artifact { + id: ArtifactId("artifact_storage".to_string()), + artifact_type: ArtifactType::AccessJwt, + display_name: Some("access_token".to_string()), + locations: Vec::new(), + lifecycle_evidence, + confidence: Confidence::High, + framework_hints: Vec::new(), + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, + }], + evidence: vec![Evidence { + id: evidence_id, + lifecycle_stage: LifecycleStage::Store, + location: SourceLocation { + path: "src/components/auth.tsx".to_string(), + line: Some(12), + column: Some(1), + }, + detector_id: detector_id.to_string(), + confidence: Confidence::High, + excerpt: None, + dynamic: false, + framework_default: false, + }], + lifecycle_paths: Vec::new(), + findings: Vec::new(), + } + } + + #[test] + fn classifies_client_storage_findings() { + for (detector_id, rule_title) in [ + ("client_storage.local_storage.set_item", "localStorage"), + ("client_storage.session_storage.set_item", "sessionStorage"), + ( + "client_storage.url_path_or_fragment.token", + "URL path or fragment", + ), + ("client_storage.browser.client_secret", "Client secret-like"), + ] { + let findings = classify(&report_with(detector_id)); + assert!( + findings + .iter() + .any(|finding| finding.title.contains(rule_title)), + "missing {rule_title}" + ); + } + } +} diff --git a/crates/sessionscope-classifier/src/lib.rs b/crates/sessionscope-classifier/src/lib.rs index a414bbd..4da1566 100644 --- a/crates/sessionscope-classifier/src/lib.rs +++ b/crates/sessionscope-classifier/src/lib.rs @@ -1,4 +1,5 @@ pub mod bearer; +pub mod client_storage; pub mod cookies; pub mod jwt; pub mod lifecycle; @@ -14,6 +15,7 @@ pub fn classify(mut report: ScanReport) -> ScanReport { report.findings = cookies::classify(&report); report.findings.extend(jwt::classify(&report)); report.findings.extend(oauth_flow::classify(&report)); + report.findings.extend(client_storage::classify(&report)); report.findings.extend(bearer::classify(&report)); report.findings.extend(trust_boundary::classify(&report)); report.findings.extend(query_params::classify(&report)); From 1540792e6e1e1bdc7c42d0c69798bdb2cd0a290c Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:44:47 -0500 Subject: [PATCH 18/29] Fix #84: P3.8 OAuth and client storage fixtures --- .../src/client_storage.rs | 3 +- crates/sessionscope-testing/src/fixtures.rs | 123 ++++++++++++++++++ .../passport-oauth2-pkce/expected.json | 18 +++ .../express/passport-oauth2-pkce/src/app.ts | 2 + .../passport-oauth2-state/expected.json | 15 +++ .../express/passport-oauth2-state/src/app.ts | 4 + .../client-storage-localstorage/app.js | 2 + .../client-storage-localstorage/expected.json | 17 +++ .../client-storage-url-fragment/app.js | 2 + .../client-storage-url-fragment/expected.json | 17 +++ fixtures/generic-python/authlib-nonce/app.py | 6 + .../authlib-nonce/expected.json | 15 +++ fixtures/generic-python/authlib-pkce/app.py | 4 + .../generic-python/authlib-pkce/expected.json | 15 +++ fixtures/generic-python/authlib-state/app.py | 4 + .../authlib-state/expected.json | 17 +++ .../client-storage-sessionstorage/app.ts | 3 + .../expected.json | 17 +++ .../generic-ts/oauth-flow-nonce/expected.json | 17 +++ .../generic-ts/oauth-flow-nonce/src/app.ts | 6 + .../generic-ts/oauth-flow-pkce/expected.json | 17 +++ .../generic-ts/oauth-flow-pkce/src/app.ts | 6 + .../oauth-flow-redirect-uri/expected.json | 17 +++ .../oauth-flow-redirect-uri/src/app.ts | 6 + .../generic-ts/oauth-flow-state/expected.json | 18 +++ .../generic-ts/oauth-flow-state/src/app.ts | 6 + .../app/api/auth/[...nextauth]/route.ts | 3 + .../authjs-nonce-evidence/expected.json | 17 +++ .../app/api/auth/[...nextauth]/route.ts | 3 + .../nextjs/authjs-pkce-evidence/expected.json | 15 +++ .../app/api/auth/[...nextauth]/route.ts | 3 + .../authjs-state-evidence/expected.json | 17 +++ 32 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 fixtures/express/passport-oauth2-pkce/expected.json create mode 100644 fixtures/express/passport-oauth2-pkce/src/app.ts create mode 100644 fixtures/express/passport-oauth2-state/expected.json create mode 100644 fixtures/express/passport-oauth2-state/src/app.ts create mode 100644 fixtures/generic-js/client-storage-localstorage/app.js create mode 100644 fixtures/generic-js/client-storage-localstorage/expected.json create mode 100644 fixtures/generic-js/client-storage-url-fragment/app.js create mode 100644 fixtures/generic-js/client-storage-url-fragment/expected.json create mode 100644 fixtures/generic-python/authlib-nonce/app.py create mode 100644 fixtures/generic-python/authlib-nonce/expected.json create mode 100644 fixtures/generic-python/authlib-pkce/app.py create mode 100644 fixtures/generic-python/authlib-pkce/expected.json create mode 100644 fixtures/generic-python/authlib-state/app.py create mode 100644 fixtures/generic-python/authlib-state/expected.json create mode 100644 fixtures/generic-ts/client-storage-sessionstorage/app.ts create mode 100644 fixtures/generic-ts/client-storage-sessionstorage/expected.json create mode 100644 fixtures/generic-ts/oauth-flow-nonce/expected.json create mode 100644 fixtures/generic-ts/oauth-flow-nonce/src/app.ts create mode 100644 fixtures/generic-ts/oauth-flow-pkce/expected.json create mode 100644 fixtures/generic-ts/oauth-flow-pkce/src/app.ts create mode 100644 fixtures/generic-ts/oauth-flow-redirect-uri/expected.json create mode 100644 fixtures/generic-ts/oauth-flow-redirect-uri/src/app.ts create mode 100644 fixtures/generic-ts/oauth-flow-state/expected.json create mode 100644 fixtures/generic-ts/oauth-flow-state/src/app.ts create mode 100644 fixtures/nextjs/authjs-nonce-evidence/app/api/auth/[...nextauth]/route.ts create mode 100644 fixtures/nextjs/authjs-nonce-evidence/expected.json create mode 100644 fixtures/nextjs/authjs-pkce-evidence/app/api/auth/[...nextauth]/route.ts create mode 100644 fixtures/nextjs/authjs-pkce-evidence/expected.json create mode 100644 fixtures/nextjs/authjs-state-evidence/app/api/auth/[...nextauth]/route.ts create mode 100644 fixtures/nextjs/authjs-state-evidence/expected.json diff --git a/crates/sessionscope-classifier/src/client_storage.rs b/crates/sessionscope-classifier/src/client_storage.rs index 2a52af0..d90725e 100644 --- a/crates/sessionscope-classifier/src/client_storage.rs +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -1,8 +1,7 @@ use std::collections::BTreeSet; use sessionscope_model::{ - Artifact, Evidence, EvidenceId, Finding, FindingCategory, ScanReport, Severity, - stable_finding_id, + Artifact, EvidenceId, Finding, FindingCategory, ScanReport, Severity, stable_finding_id, }; pub fn classify(report: &ScanReport) -> Vec { diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index e214660..846c377 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -639,6 +639,129 @@ mod tests { } } + #[test] + fn p3_oauth_and_client_storage_fixtures_emit_expected_findings() { + let cases = [ + ( + fixture_root().join("generic-ts").join("oauth-flow-pkce"), + ["PKCE"].as_slice(), + ), + ( + fixture_root().join("generic-ts").join("oauth-flow-state"), + ["static literal", "without visible verification"].as_slice(), + ), + ( + fixture_root().join("generic-ts").join("oauth-flow-nonce"), + ["no source-visible nonce"].as_slice(), + ), + ( + fixture_root() + .join("generic-ts") + .join("oauth-flow-redirect-uri"), + ["redirect URI"].as_slice(), + ), + ( + fixture_root() + .join("generic-js") + .join("client-storage-localstorage"), + ["localStorage"].as_slice(), + ), + ( + fixture_root() + .join("generic-ts") + .join("client-storage-sessionstorage"), + ["sessionStorage"].as_slice(), + ), + ( + fixture_root() + .join("generic-js") + .join("client-storage-url-fragment"), + ["URL path or fragment"].as_slice(), + ), + ]; + + for (root, fragments) in cases { + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .unwrap_or_else(|error| panic!("{} should scan: {error}", root.display())), + ); + + for fragment in fragments { + assert!( + report + .findings + .iter() + .any(|finding| finding.title.contains(fragment)), + "{} should include finding containing {fragment:?}. Findings: {:?}", + root.display(), + report + .findings + .iter() + .map(|finding| finding.title.as_str()) + .collect::>() + ); + } + + for format in [ReportFormat::Json, ReportFormat::Markdown] { + let rendered = render(&report, format); + assert!(!rendered.contains("STATIC_STATE_PLACEHOLDER")); + assert!(!rendered.contains("PLACEHOLDER_CODE_CHALLENGE")); + } + } + } + + #[test] + fn p3_false_positive_fixtures_stay_clean() { + let cases = [ + fixture_root().join("nextjs").join("authjs-pkce-evidence"), + fixture_root().join("express").join("passport-oauth2-state"), + fixture_root().join("generic-python").join("authlib-pkce"), + fixture_root().join("generic-python").join("authlib-nonce"), + ]; + let p3_fragments = [ + "PKCE", + "source-visible state", + "static literal", + "without visible verification", + "source-visible nonce", + "ID-token nonce verification", + "redirect URI", + "localStorage", + "sessionStorage", + "URL path or fragment", + "Client secret-like", + ]; + + for root in cases { + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .unwrap_or_else(|error| panic!("{} should scan: {error}", root.display())), + ); + + for fragment in p3_fragments { + assert!( + report + .findings + .iter() + .all(|finding| !finding.title.contains(fragment)), + "{} should not include P3 finding containing {fragment:?}. Findings: {:?}", + root.display(), + report + .findings + .iter() + .map(|finding| finding.title.as_str()) + .collect::>() + ); + } + } + } + #[test] fn logout_fixtures_emit_revoke_evidence() { let cases = [ diff --git a/fixtures/express/passport-oauth2-pkce/expected.json b/fixtures/express/passport-oauth2-pkce/expected.json new file mode 100644 index 0000000..28ac070 --- /dev/null +++ b/fixtures/express/passport-oauth2-pkce/expected.json @@ -0,0 +1,18 @@ +{ + "fixture_id": "passport-oauth2-pkce", + "framework": "express", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_pkce_missing_review", + "oauth_state_missing" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/express/passport-oauth2-pkce/src/app.ts b/fixtures/express/passport-oauth2-pkce/src/app.ts new file mode 100644 index 0000000..fff9fc6 --- /dev/null +++ b/fixtures/express/passport-oauth2-pkce/src/app.ts @@ -0,0 +1,2 @@ +import { Strategy as OAuth2Strategy } from 'passport-oauth2' +new OAuth2Strategy({ authorizationURL: 'https://issuer.example/authorize', tokenURL: 'https://issuer.example/token', clientID: 'placeholder', callbackURL: 'https://app.example.com/callback' }, () => {}) diff --git a/fixtures/express/passport-oauth2-state/expected.json b/fixtures/express/passport-oauth2-state/expected.json new file mode 100644 index 0000000..58f375e --- /dev/null +++ b/fixtures/express/passport-oauth2-state/expected.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "passport-oauth2-state", + "framework": "express", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/express/passport-oauth2-state/src/app.ts b/fixtures/express/passport-oauth2-state/src/app.ts new file mode 100644 index 0000000..9f13682 --- /dev/null +++ b/fixtures/express/passport-oauth2-state/src/app.ts @@ -0,0 +1,4 @@ +import { Strategy as OAuth2Strategy } from 'passport-oauth2' +const state = crypto.randomUUID() +new OAuth2Strategy({ authorizationURL: 'https://issuer.example/authorize', tokenURL: 'https://issuer.example/token', clientID: 'placeholder', callbackURL: 'https://app.example.com/callback', state, code_challenge: challenge }, () => {}) +app.get('/callback', (req, res) => { if (req.query.state === req.session.oauthState) res.end('ok') }) diff --git a/fixtures/generic-js/client-storage-localstorage/app.js b/fixtures/generic-js/client-storage-localstorage/app.js new file mode 100644 index 0000000..6c9df8a --- /dev/null +++ b/fixtures/generic-js/client-storage-localstorage/app.js @@ -0,0 +1,2 @@ +localStorage.setItem('access_token', accessToken) +localStorage.setItem('theme', 'dark') diff --git a/fixtures/generic-js/client-storage-localstorage/expected.json b/fixtures/generic-js/client-storage-localstorage/expected.json new file mode 100644 index 0000000..2311b87 --- /dev/null +++ b/fixtures/generic-js/client-storage-localstorage/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "client-storage-localstorage", + "framework": "generic-js", + "source_files": [ + "app.js" + ], + "expected_artifacts": [ + "access_token" + ], + "expected_lifecycle_stages": [ + "store" + ], + "expected_findings": [ + "token_in_local_storage" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-js/client-storage-url-fragment/app.js b/fixtures/generic-js/client-storage-url-fragment/app.js new file mode 100644 index 0000000..f3a0632 --- /dev/null +++ b/fixtures/generic-js/client-storage-url-fragment/app.js @@ -0,0 +1,2 @@ +const url = `/callback#access_token=${accessToken}` +const path = `/session/${sessionId}` diff --git a/fixtures/generic-js/client-storage-url-fragment/expected.json b/fixtures/generic-js/client-storage-url-fragment/expected.json new file mode 100644 index 0000000..cd922fc --- /dev/null +++ b/fixtures/generic-js/client-storage-url-fragment/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "client-storage-url-fragment", + "framework": "generic-js", + "source_files": [ + "app.js" + ], + "expected_artifacts": [ + "access_token" + ], + "expected_lifecycle_stages": [ + "store" + ], + "expected_findings": [ + "token_in_url_path_or_fragment" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-python/authlib-nonce/app.py b/fixtures/generic-python/authlib-nonce/app.py new file mode 100644 index 0000000..adf1084 --- /dev/null +++ b/fixtures/generic-python/authlib-nonce/app.py @@ -0,0 +1,6 @@ +from authlib.integrations.requests_client import OAuth2Session +def login(): + client = OAuth2Session('client-id') + return client.create_authorization_url('https://issuer.example/authorize', response_type='code', scope='openid profile', state=secrets.token_urlsafe(32), nonce=secrets.token_urlsafe(32), code_challenge='PLACEHOLDER_CODE_CHALLENGE') +def verify(id_token): + return client.parse_id_token(id_token, nonce=expected_nonce) diff --git a/fixtures/generic-python/authlib-nonce/expected.json b/fixtures/generic-python/authlib-nonce/expected.json new file mode 100644 index 0000000..9fd2fde --- /dev/null +++ b/fixtures/generic-python/authlib-nonce/expected.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "authlib-nonce", + "framework": "generic-python", + "source_files": [ + "app.py" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-python/authlib-pkce/app.py b/fixtures/generic-python/authlib-pkce/app.py new file mode 100644 index 0000000..4dde7cf --- /dev/null +++ b/fixtures/generic-python/authlib-pkce/app.py @@ -0,0 +1,4 @@ +from authlib.integrations.requests_client import OAuth2Session +def login(): + client = OAuth2Session('client-id') + return client.create_authorization_url('https://issuer.example/authorize', response_type='code', state=secrets.token_urlsafe(32), code_challenge='PLACEHOLDER_CODE_CHALLENGE') diff --git a/fixtures/generic-python/authlib-pkce/expected.json b/fixtures/generic-python/authlib-pkce/expected.json new file mode 100644 index 0000000..ea388f5 --- /dev/null +++ b/fixtures/generic-python/authlib-pkce/expected.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "authlib-pkce", + "framework": "generic-python", + "source_files": [ + "app.py" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-python/authlib-state/app.py b/fixtures/generic-python/authlib-state/app.py new file mode 100644 index 0000000..b89e961 --- /dev/null +++ b/fixtures/generic-python/authlib-state/app.py @@ -0,0 +1,4 @@ +from authlib.integrations.requests_client import OAuth2Session +def login(): + client = OAuth2Session('client-id') + return client.create_authorization_url('https://issuer.example/authorize', response_type='code', code_challenge='PLACEHOLDER_CODE_CHALLENGE') diff --git a/fixtures/generic-python/authlib-state/expected.json b/fixtures/generic-python/authlib-state/expected.json new file mode 100644 index 0000000..52a4ff2 --- /dev/null +++ b/fixtures/generic-python/authlib-state/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "authlib-state", + "framework": "generic-python", + "source_files": [ + "app.py" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_state_missing" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/client-storage-sessionstorage/app.ts b/fixtures/generic-ts/client-storage-sessionstorage/app.ts new file mode 100644 index 0000000..ec80d89 --- /dev/null +++ b/fixtures/generic-ts/client-storage-sessionstorage/app.ts @@ -0,0 +1,3 @@ +sessionStorage.setItem('refresh_token', refreshToken) +const safe = new Map() +safe.set('access_token', accessToken) diff --git a/fixtures/generic-ts/client-storage-sessionstorage/expected.json b/fixtures/generic-ts/client-storage-sessionstorage/expected.json new file mode 100644 index 0000000..184b8ee --- /dev/null +++ b/fixtures/generic-ts/client-storage-sessionstorage/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "client-storage-sessionstorage", + "framework": "generic-ts", + "source_files": [ + "app.ts" + ], + "expected_artifacts": [ + "access_token" + ], + "expected_lifecycle_stages": [ + "store" + ], + "expected_findings": [ + "token_in_session_storage" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/oauth-flow-nonce/expected.json b/fixtures/generic-ts/oauth-flow-nonce/expected.json new file mode 100644 index 0000000..3b1df35 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-nonce/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "oauth-flow-nonce", + "framework": "generic-ts", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oidc_nonce_missing" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/oauth-flow-nonce/src/app.ts b/fixtures/generic-ts/oauth-flow-nonce/src/app.ts new file mode 100644 index 0000000..2560650 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-nonce/src/app.ts @@ -0,0 +1,6 @@ +export function login(client: any) { + return client.authorizationUrl({ response_type: 'code', scope: 'openid profile', code_challenge: challenge, state: crypto.randomUUID(), redirect_uris: ['https://app.example.com/auth/callback'] }); +} +export function callback(req: any, session: any) { + if (req.query.state === session.oauthState) return true; +} diff --git a/fixtures/generic-ts/oauth-flow-pkce/expected.json b/fixtures/generic-ts/oauth-flow-pkce/expected.json new file mode 100644 index 0000000..55327f8 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-pkce/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "oauth-flow-pkce", + "framework": "generic-ts", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_pkce_missing_review" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/oauth-flow-pkce/src/app.ts b/fixtures/generic-ts/oauth-flow-pkce/src/app.ts new file mode 100644 index 0000000..ba3d88c --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-pkce/src/app.ts @@ -0,0 +1,6 @@ +export function login(client: any) { + return client.authorizationUrl({ response_type: 'code', scope: 'profile', state: crypto.randomUUID(), redirect_uris: ['https://app.example.com/auth/callback'] }); +} +export function callback(req: any, session: any) { + if (req.query.state === session.oauthState) return true; +} diff --git a/fixtures/generic-ts/oauth-flow-redirect-uri/expected.json b/fixtures/generic-ts/oauth-flow-redirect-uri/expected.json new file mode 100644 index 0000000..704b822 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-redirect-uri/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "oauth-flow-redirect-uri", + "framework": "generic-ts", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_redirect_uri_wildcard_review" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/oauth-flow-redirect-uri/src/app.ts b/fixtures/generic-ts/oauth-flow-redirect-uri/src/app.ts new file mode 100644 index 0000000..3091349 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-redirect-uri/src/app.ts @@ -0,0 +1,6 @@ +export function login(client: any) { + return client.authorizationUrl({ response_type: 'code', code_challenge: challenge, state: crypto.randomUUID(), redirect_uris: ['https://example.com'] }); +} +export function callback(req: any, session: any) { + if (req.query.state === session.oauthState) return true; +} diff --git a/fixtures/generic-ts/oauth-flow-state/expected.json b/fixtures/generic-ts/oauth-flow-state/expected.json new file mode 100644 index 0000000..00dbdfb --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-state/expected.json @@ -0,0 +1,18 @@ +{ + "fixture_id": "oauth-flow-state", + "framework": "generic-ts", + "source_files": [ + "src/app.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_state_static_review", + "oauth_state_unverified_review" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/generic-ts/oauth-flow-state/src/app.ts b/fixtures/generic-ts/oauth-flow-state/src/app.ts new file mode 100644 index 0000000..dc19675 --- /dev/null +++ b/fixtures/generic-ts/oauth-flow-state/src/app.ts @@ -0,0 +1,6 @@ +export function login(client: any) { + return client.authorizationUrl({ response_type: 'code', code_challenge: challenge, state: 'STATIC_STATE_PLACEHOLDER', redirect_uris: ['https://app.example.com/auth/callback'] }); +} +export function callback(req: any) { + return req.query.state; +} diff --git a/fixtures/nextjs/authjs-nonce-evidence/app/api/auth/[...nextauth]/route.ts b/fixtures/nextjs/authjs-nonce-evidence/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8e9b844 --- /dev/null +++ b/fixtures/nextjs/authjs-nonce-evidence/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import NextAuth from 'next-auth' +export const authOptions = { providers: [OIDCProvider({ checks: ['pkce', 'state'] })] } +export default NextAuth(authOptions) diff --git a/fixtures/nextjs/authjs-nonce-evidence/expected.json b/fixtures/nextjs/authjs-nonce-evidence/expected.json new file mode 100644 index 0000000..6cefc6a --- /dev/null +++ b/fixtures/nextjs/authjs-nonce-evidence/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "authjs-nonce-evidence", + "framework": "nextjs", + "source_files": [ + "app/api/auth/[...nextauth]/route.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oidc_nonce_missing" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/nextjs/authjs-pkce-evidence/app/api/auth/[...nextauth]/route.ts b/fixtures/nextjs/authjs-pkce-evidence/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..bb9ec25 --- /dev/null +++ b/fixtures/nextjs/authjs-pkce-evidence/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import NextAuth from 'next-auth' +export const authOptions = { providers: [OAuthProvider({ checks: ['pkce', 'state'] })] } +export default NextAuth(authOptions) diff --git a/fixtures/nextjs/authjs-pkce-evidence/expected.json b/fixtures/nextjs/authjs-pkce-evidence/expected.json new file mode 100644 index 0000000..599a973 --- /dev/null +++ b/fixtures/nextjs/authjs-pkce-evidence/expected.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "authjs-pkce-evidence", + "framework": "nextjs", + "source_files": [ + "app/api/auth/[...nextauth]/route.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} diff --git a/fixtures/nextjs/authjs-state-evidence/app/api/auth/[...nextauth]/route.ts b/fixtures/nextjs/authjs-state-evidence/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..74bcaa2 --- /dev/null +++ b/fixtures/nextjs/authjs-state-evidence/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import NextAuth from 'next-auth' +export const authOptions = { providers: [OAuthProvider({ checks: ['pkce'] })] } +export default NextAuth(authOptions) diff --git a/fixtures/nextjs/authjs-state-evidence/expected.json b/fixtures/nextjs/authjs-state-evidence/expected.json new file mode 100644 index 0000000..84458f4 --- /dev/null +++ b/fixtures/nextjs/authjs-state-evidence/expected.json @@ -0,0 +1,17 @@ +{ + "fixture_id": "authjs-state-evidence", + "framework": "nextjs", + "source_files": [ + "app/api/auth/[...nextauth]/route.ts" + ], + "expected_artifacts": [ + "oauth_auth_code_flow" + ], + "expected_lifecycle_stages": [ + "issue" + ], + "expected_findings": [ + "oauth_state_missing" + ], + "notes": "P3 OAuth/client-storage fixture with placeholder-only values." +} From d564ca60459c25ee253a7b1cd2e2f054d56ad7f7 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:46:36 -0500 Subject: [PATCH 19/29] Fix #86: P3.10 document OAuth flow and storage coverage --- CHANGELOG.md | 1 + docs/COVERAGE_MATRIX.md | 11 +++++++++++ docs/FRAMEWORK_COVERAGE.md | 5 +++++ docs/PROVIDER_LIBRARY_COVERAGE.md | 12 ++++++++++++ docs/USAGE.md | 3 ++- 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e695e..7549172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and SessionScope uses semantic versioning as described in - Added `docs/COVERAGE_MATRIX.md` as the per-check source of truth for supported, review-required, and intentionally not-covered SessionScope evidence patterns across languages, frameworks, libraries, lifecycle stages, finding categories, and SARIF rule IDs. - Added v0.2 P1 cookie hardening coverage for `__Host-` / `__Secure-` prefix rules, `Partitioned` cookie review, broad non-session Domain review, and same-handler conflicting cookie writes across existing JS/TS/Python cookie detectors. - Added v0.2 P2 JWT crypto-trust coverage for `alg:none`, HMAC/asymmetric algorithm-confusion signals, `jku`/`x5u`/embedded-JWK header trust review, missing `nbf` validation, broad clock-skew review, and unvalidated `kid` header review across existing `jsonwebtoken`, `jose`, and PyJWT detector surfaces. +- Added v0.2 P3 OAuth/OIDC flow integrity coverage for PKCE, `state`, OIDC `nonce`, broad redirect URI review, and client storage hygiene checks for localStorage, sessionStorage, URL path/fragment token exposure, and browser-path client secrets. - Extended report redaction for OAuth/OIDC `state`, `nonce`, `code_verifier`, and `code_challenge` values in assignments, object keys, and URL parameters. ### Pre-release remediation (v0.1.0 readiness) diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index 81c08ad..d23a4c8 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -53,6 +53,17 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m | `jwt_nbf_missing` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** validation path without source-visible `nbf`/not-before enforcement or with not-before checks disabled; **not covered:** issuer policy that intentionally never emits `nbf` | validate | `missing_validation_evidence` | `missing_validation_evidence` | Not-before validation evidence is missing or disabled. | | `jwt_clock_skew_review` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **review-required:** clock tolerance/leeway above 60 seconds or dynamic clock tolerance evidence | validate | `dynamic_review_required` | `dynamic_review_required` | Broad clock skew can extend token acceptance windows. | | `jwt_kid_unvalidated_review` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** source-visible `kid` header read without visible allow-list, pinned key map, or JWKS-style validation evidence; **not covered:** live JWKS/OIDC discovery state | validate | `missing_validation_evidence` | `missing_validation_evidence` | Header key IDs should be constrained before key selection. | +| `oauth_pkce_missing_review` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | `passport-oauth2`, `openid-client`, NextAuth/Auth.js, Authlib, generic OAuth/OIDC | **review-required:** auth-code flow without source-visible `code_challenge`, `code_challenge_method`, `code_verifier`, or PKCE option; **not covered:** live provider defaults | issue | `dynamic_review_required` | `dynamic_review_required` | Confirm PKCE is enforced by source or provider defaults. | +| `oauth_state_missing` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | `passport-oauth2`, `openid-client`, NextAuth/Auth.js, Authlib, generic OAuth/OIDC | **supported:** auth-code flow without source-visible `state`; **review-required:** provider-managed state defaults | issue/validate | `missing_validation_evidence` | `missing_validation_evidence` | Auth-code flows should generate and validate state. | +| `oauth_state_static_review` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | OAuth/OIDC client code | **review-required:** `state` assigned from a literal value; value redacted | issue | `dynamic_review_required` | `dynamic_review_required` | Static state can undermine CSRF/correlation protections. | +| `oauth_state_unverified_review` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | OAuth/OIDC callback handlers | **supported:** callback reads `state` with no visible comparison to session/cache/signed-cookie/helper state | validate | `missing_validation_evidence` | `missing_validation_evidence` | Callback state should be compared before exchanging codes. | +| `oidc_nonce_missing` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | OIDC flows via `openid-client`, NextAuth/Auth.js, Authlib, generic OIDC | **supported:** `openid`-scoped flow without source-visible `nonce`; **not covered:** provider dashboard defaults | issue | `missing_validation_evidence` | `missing_validation_evidence` | OIDC flows should bind ID tokens with a nonce. | +| `oidc_nonce_unverified_review` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | OIDC ID-token verification code plus OAuth flow evidence | **supported:** nonce set without visible ID-token nonce verification option/comparison | validate | `missing_validation_evidence` | `missing_validation_evidence` | ID-token validation should verify the issued nonce. | +| `oauth_redirect_uri_wildcard_review` | JS/TS, Python | Next.js, Express, FastAPI/generic Python, generic JS/TS | OAuth/OIDC client registration/config literals | **review-required:** wildcard, bare-host, or top-level wildcard redirect URI literals; localhost loopback suppressed unless wildcarded | issue | `dynamic_review_required` | `dynamic_review_required` | Redirect URI matching should use exact callback URLs. | +| `token_in_local_storage` | JS/TS | Next.js browser/client paths, generic JS/TS | `localStorage` | **supported:** `localStorage.setItem` with token-shaped key (`access_token`, `id_token`, `refresh_token`, `jwt`, `bearer`, `auth`, `session`); **not covered:** benign allowlisted keys | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Browser-readable persistent token storage is flagged. | +| `token_in_session_storage` | JS/TS | Next.js browser/client paths, generic JS/TS | `sessionStorage` | **supported:** `sessionStorage.setItem` with token-shaped key | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Browser-readable tab/session token storage is flagged. | +| `token_in_url_path_or_fragment` | JS/TS | Next.js browser/client paths, generic JS/TS | URL builders / template strings | **supported:** token-shaped names in URL path segments or fragments; query params remain covered separately | transmit | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Tokens should not be embedded in URL paths or fragments. | +| `client_secret_in_browser_code` | JS/TS | Next.js `pages/`/`app/`, `src/components/`, `public/`, generic browser paths | OAuth client config literals/identifiers | **review-required:** `client_secret` / `clientSecret` evidence in browser-shipped path heuristics | store | `dynamic_review_required` | `dynamic_review_required` | OAuth client secrets must stay server-side. | | `bearer_token_in_url_query` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Bearer/API-key handlers | **supported:** bearer/API-key token in query URL | transmit | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Tokens accepted from URLs can leak via logs/referrers. | | `bearer_token_browser_storage` | JS/TS | Generic JS/TS, Next.js client-adjacent code | Browser APIs | **supported:** token-like values in browser storage | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Browser storage token use is flagged. | | `bearer_static_secret_literal` | JS/TS, Python | Generic JS/TS/Python | Config/source literals | **supported:** obvious placeholder/static secret literal | store | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | Static token-like literals are flagged. | diff --git a/docs/FRAMEWORK_COVERAGE.md b/docs/FRAMEWORK_COVERAGE.md index 183437b..08afb76 100644 --- a/docs/FRAMEWORK_COVERAGE.md +++ b/docs/FRAMEWORK_COVERAGE.md @@ -22,6 +22,7 @@ Supported patterns: - `NextResponse.cookies.set(...)` and `NextResponse.cookies.delete(...)` cookie storage and logout evidence, including prefix-rule and conflicting-write cookie hardening where source-visible. - `Request` header bearer reads and route-local JWT verification through supported JWT libraries such as `jose`. - Refresh route handlers that read, validate, rotate, store, expire, or revoke refresh-token evidence in local source. +- Auth.js/NextAuth OAuth/OIDC provider blocks for source-visible PKCE/state/nonce evidence and browser-client storage hygiene in `app/`, `pages/`, `src/components/`, and `public/` paths. Unsupported or dynamic patterns: @@ -42,6 +43,8 @@ Supported patterns: - `req.session.destroy(...)` server-side logout/session revocation evidence. - Session mutation near login, sign-in, auth callback, impersonation, or privilege elevation route handlers. - Refresh routes that expose validate, rotate, store, expire, or revoke operations in local source. +- Passport OAuth2 strategy construction and callback-local state evidence for source-visible P3 OAuth flow checks. +- Browser/client storage hygiene in source paths that match client-side heuristics. Unsupported or dynamic patterns: @@ -60,10 +63,12 @@ Supported patterns: - JWT encode/decode calls in dependencies and route handlers through supported Python JWT libraries. - Logout handlers with cookie deletion and visible session/token revocation helpers. - Refresh handlers with visible lookup, validation, rotation, storage, expiry, or revocation helpers. +- Authlib/generic OAuth2Session authorization URL construction for source-visible PKCE/state/nonce evidence. Unsupported or dynamic patterns: - External dependency injection containers and auth backends with no visible implementation. + - Provider-managed OAuth/OIDC behavior not represented in local source; supported source-visible patterns are documented in [`PROVIDER_LIBRARY_COVERAGE.md`](PROVIDER_LIBRARY_COVERAGE.md). - Runtime-only OpenAPI/security configuration without source-visible cookie, bearer, JWT, or session handling. diff --git a/docs/PROVIDER_LIBRARY_COVERAGE.md b/docs/PROVIDER_LIBRARY_COVERAGE.md index 5a502b7..a14b190 100644 --- a/docs/PROVIDER_LIBRARY_COVERAGE.md +++ b/docs/PROVIDER_LIBRARY_COVERAGE.md @@ -59,6 +59,18 @@ Unsupported or dynamic patterns: - Live OIDC discovery metadata, JWKS state, provider dashboard configuration, and runtime-only client registration. - Assertions that a provider validates issuer, audience, expiry, or signature unless local source exposes that validation call/configuration. +## OAuth/OIDC flow integrity + +Supported P3 OAuth/OIDC checks are source-only and review-conservative: + +- `passport-oauth2` / Express: strategy construction and callback-adjacent code can emit auth-code flow, state, PKCE, and redirect URI evidence. Provider-side redirect matching and PKCE enforcement remain review-required unless visible in source. +- `openid-client`: `authorizationUrl` / authorization URL construction can emit PKCE, state, nonce, scope, and redirect URI evidence. Live discovery and JWKS/provider metadata are not fetched. +- NextAuth/Auth.js: provider blocks and `checks` arrays can satisfy source-visible PKCE/state evidence; provider defaults are surfaced as review-required context rather than high-confidence failures. +- Authlib: `OAuth2Client`, `OAuth2Session`, and authorization URL construction can emit PKCE/state/nonce evidence for Python projects. Authlib JWT validation paths remain out of scope for the P2 JWT detector surface. +- Generic OAuth/OIDC code: crypto-near identifiers such as `state`, `nonce`, `code_verifier`, and `code_challenge` can provide flow evidence when they appear near auth-code construction. + +SessionScope never contacts authorization servers, discovery endpoints, JWKS URLs, or provider dashboards, and it does not prove runtime client registration settings. OAuth `state`, OIDC `nonce`, and PKCE values are redacted from evidence and reports. + ## Common Cloud Identity SDKs Supported patterns: diff --git a/docs/USAGE.md b/docs/USAGE.md index 669cce5..144e556 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -126,7 +126,8 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - JWT verification without issuer validation - JWT verification without audience validation - JWT crypto-trust hardening, including `jwt_alg_none_accepted`, `jwt_alg_confusion_signal`, `jwt_jku_header_trust`, `jwt_x5u_header_trust`, `jwt_embedded_jwk_trust`, `jwt_nbf_missing`, `jwt_clock_skew_review`, and `jwt_kid_unvalidated_review` -- OAuth/OIDC flow evidence for authorization-code construction across Passport OAuth2, openid-client, NextAuth/Auth.js provider blocks, Authlib, and generic OAuth/OIDC code. P3 classifiers evaluate PKCE, `state`, OIDC `nonce`, and redirect-URI risks from this source-bound evidence. +- OAuth/OIDC flow integrity, including `oauth_pkce_missing_review`, `oauth_state_missing`, `oauth_state_static_review`, `oauth_state_unverified_review`, `oidc_nonce_missing`, `oidc_nonce_unverified_review`, and `oauth_redirect_uri_wildcard_review` across Passport OAuth2, openid-client, NextAuth/Auth.js provider blocks, Authlib, and generic OAuth/OIDC code. +- Client storage hygiene, including `token_in_local_storage`, `token_in_session_storage`, `token_in_url_path_or_fragment`, and `client_secret_in_browser_code` for browser-accessible storage and URL channels. - Tokens issued without explicit expiry - Refresh tokens without rotation evidence - Logout without revocation evidence From 9c222bf81f5275e72233e489464ecb2447a5e4ea Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:47:12 -0500 Subject: [PATCH 20/29] Fix #83: restore client storage test import --- crates/sessionscope-classifier/src/client_storage.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/sessionscope-classifier/src/client_storage.rs b/crates/sessionscope-classifier/src/client_storage.rs index d90725e..2a52af0 100644 --- a/crates/sessionscope-classifier/src/client_storage.rs +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -1,7 +1,8 @@ use std::collections::BTreeSet; use sessionscope_model::{ - Artifact, EvidenceId, Finding, FindingCategory, ScanReport, Severity, stable_finding_id, + Artifact, Evidence, EvidenceId, Finding, FindingCategory, ScanReport, Severity, + stable_finding_id, }; pub fn classify(report: &ScanReport) -> Vec { From 071ef916901c9e7217f5285e7ba2c3cd6947181f Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:47:36 -0500 Subject: [PATCH 21/29] Fix #83: scope client storage test import --- crates/sessionscope-classifier/src/client_storage.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/sessionscope-classifier/src/client_storage.rs b/crates/sessionscope-classifier/src/client_storage.rs index 2a52af0..220565b 100644 --- a/crates/sessionscope-classifier/src/client_storage.rs +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -1,8 +1,7 @@ use std::collections::BTreeSet; use sessionscope_model::{ - Artifact, Evidence, EvidenceId, Finding, FindingCategory, ScanReport, Severity, - stable_finding_id, + Artifact, EvidenceId, Finding, FindingCategory, ScanReport, Severity, stable_finding_id, }; pub fn classify(report: &ScanReport) -> Vec { @@ -117,8 +116,8 @@ fn dedupe_findings(findings: Vec) -> Vec { #[cfg(test)] mod tests { use sessionscope_model::{ - ArtifactId, ArtifactType, Confidence, LifecycleEvidence, LifecycleStage, SCHEMA_VERSION, - ScanSummary, SourceLocation, + ArtifactId, ArtifactType, Confidence, Evidence, LifecycleEvidence, LifecycleStage, + SCHEMA_VERSION, ScanSummary, SourceLocation, }; use super::*; From 867fd6d6eff17beef1aea4c3674e9ed32f64ca05 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:48:42 -0500 Subject: [PATCH 22/29] Fix #82: narrow client storage URL token evidence --- crates/sessionscope-detectors/src/client_storage/mod.rs | 2 +- fixtures/generic-python/authlib-nonce/app.py | 2 +- fixtures/generic-python/authlib-pkce/app.py | 2 +- fixtures/generic-python/authlib-state/app.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/sessionscope-detectors/src/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs index dd06027..53ba9f4 100644 --- a/crates/sessionscope-detectors/src/client_storage/mod.rs +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -23,7 +23,7 @@ static DOCUMENT_COOKIE_RE: LazyLock = LazyLock::new(|| { static COOKIE_KEY_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(["'])([^="';]+)="#).expect("cookie key regex should compile")); static URL_PATH_FRAGMENT_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?i)([#/](?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)(?:[=/]|\b))"#) + Regex::new(r#"(?i)([#/](?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|session)(?:[=/]|\b))"#) .expect("url path/fragment regex should compile") }); static CLIENT_SECRET_RE: LazyLock = LazyLock::new(|| { diff --git a/fixtures/generic-python/authlib-nonce/app.py b/fixtures/generic-python/authlib-nonce/app.py index adf1084..6135ae2 100644 --- a/fixtures/generic-python/authlib-nonce/app.py +++ b/fixtures/generic-python/authlib-nonce/app.py @@ -1,4 +1,4 @@ -from authlib.integrations.requests_client import OAuth2Session +from authlib import OAuth2Session def login(): client = OAuth2Session('client-id') return client.create_authorization_url('https://issuer.example/authorize', response_type='code', scope='openid profile', state=secrets.token_urlsafe(32), nonce=secrets.token_urlsafe(32), code_challenge='PLACEHOLDER_CODE_CHALLENGE') diff --git a/fixtures/generic-python/authlib-pkce/app.py b/fixtures/generic-python/authlib-pkce/app.py index 4dde7cf..9d3424c 100644 --- a/fixtures/generic-python/authlib-pkce/app.py +++ b/fixtures/generic-python/authlib-pkce/app.py @@ -1,4 +1,4 @@ -from authlib.integrations.requests_client import OAuth2Session +from authlib import OAuth2Session def login(): client = OAuth2Session('client-id') return client.create_authorization_url('https://issuer.example/authorize', response_type='code', state=secrets.token_urlsafe(32), code_challenge='PLACEHOLDER_CODE_CHALLENGE') diff --git a/fixtures/generic-python/authlib-state/app.py b/fixtures/generic-python/authlib-state/app.py index b89e961..b1eb40b 100644 --- a/fixtures/generic-python/authlib-state/app.py +++ b/fixtures/generic-python/authlib-state/app.py @@ -1,4 +1,4 @@ -from authlib.integrations.requests_client import OAuth2Session +from authlib import OAuth2Session def login(): client = OAuth2Session('client-id') return client.create_authorization_url('https://issuer.example/authorize', response_type='code', code_challenge='PLACEHOLDER_CODE_CHALLENGE') From 48146685052728a52d9ff57255d9bb403a01d94c Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:53:34 -0500 Subject: [PATCH 23/29] Fix #77: address P3 review flow scoping and redaction --- .../src/client_storage/mod.rs | 21 ++- .../src/oauth_flow/mod.rs | 158 ++++++++++++------ 2 files changed, 130 insertions(+), 49 deletions(-) diff --git a/crates/sessionscope-detectors/src/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs index 53ba9f4..e36947f 100644 --- a/crates/sessionscope-detectors/src/client_storage/mod.rs +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -17,6 +17,10 @@ static STORAGE_RE: LazyLock = LazyLock::new(|| { ) .expect("storage regex should compile") }); +static STORAGE_VALUE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)(\b(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?:"(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^"]*"|'(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^']*')\s*,\s*)(["'])([^"']*)(["'])"#) + .expect("storage value regex should compile") +}); static DOCUMENT_COOKIE_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?i)document\s*\.\s*cookie\s*="#).expect("document cookie regex should compile") }); @@ -274,8 +278,11 @@ fn is_browser_client_path(path: &str) -> bool { } fn sanitize_storage_excerpt(line: &str) -> String { - SENSITIVE_VALUE_RE + let output = STORAGE_VALUE_RE .replace_all(line, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string(); + SENSITIVE_VALUE_RE + .replace_all(&output, format!("${{1}}${{2}}{REDACTION}${{4}}")) .to_string() } @@ -363,6 +370,18 @@ const clientSecret = 'PLACEHOLDER_SECRET_DO_NOT_USE' assert!(output.evidence.is_empty()); } + #[test] + fn redacts_storage_second_argument_literals() { + let output = ClientStorageDetector.detect(&input( + "src/components/Auth.tsx", + "localStorage.setItem('access_token', 'raw-token-value')", + )); + + let rendered = format!("{:?}", output.evidence); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("raw-token-value")); + } + #[test] fn only_flags_client_secret_on_browser_paths() { let server = ClientStorageDetector.detect(&input( diff --git a/crates/sessionscope-detectors/src/oauth_flow/mod.rs b/crates/sessionscope-detectors/src/oauth_flow/mod.rs index 20da12f..f47be25 100644 --- a/crates/sessionscope-detectors/src/oauth_flow/mod.rs +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -26,7 +26,7 @@ static STATIC_STATE_RE: LazyLock = LazyLock::new(|| { .expect("static state regex should compile") }); static STATE_VERIFY_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(state[^\n]{0,80}(===|==|!=|!==)|compare_digest\([^\n]*state|session[^\n]{0,80}state|cookies?[^\n]{0,80}state|csrf[^\n]{0,80}state)") + Regex::new(r"(?i)(state[^\n]{0,80}(===|==|!=|!==)|session[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|cookies?[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|csrf[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|compare_digest\([^\n]*state)") .expect("state verification regex should compile") }); static OPENID_RE: LazyLock = LazyLock::new(|| { @@ -37,7 +37,7 @@ static NONCE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bnonce\b").expect("nonce regex should compile")); static NONCE_VERIFY_RE: LazyLock = LazyLock::new(|| { Regex::new( - r"(?i)(verify[^\n]{0,80}nonce|nonce[^\n]{0,80}(===|==|!=|!==)|id_token[^\n]{0,80}nonce)", + r"(?i)(verify[^\n]{0,80}nonce|parse_id_token[^\n]{0,80}nonce|nonce[^\n]{0,80}(===|==|!=|!==)|id_token[^\n]{0,80}nonce[^\n]{0,80}(===|==|!=|!==))", ) .expect("nonce verification regex should compile") }); @@ -243,61 +243,83 @@ fn signal( fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> DetectionOutput { let mut output = DetectionOutput::default(); - let artifact_id = stable_artifact_id(&[DETECTOR_ID, "oauth_auth_code_flow", input.path]); - let mut lifecycle_evidence = LifecycleEvidence::default(); - let mut evidence = Vec::new(); - - for signal in signals { - let line = signal.line.to_string(); - let column = signal.column.to_string(); - let evidence_id = stable_evidence_id(&[ + let flow_lines = signals + .iter() + .filter(|signal| signal.detector_id == "oauth.flow.auth_code") + .map(|signal| signal.line) + .collect::>(); + + for flow_line in flow_lines { + let artifact_id = stable_artifact_id(&[ DETECTOR_ID, - signal.detector_id, + "oauth_auth_code_flow", input.path, - line.as_str(), - column.as_str(), + flow_line.to_string().as_str(), ]); - push_lifecycle_id(&mut lifecycle_evidence, signal.stage, evidence_id.clone()); - evidence.push(Evidence { - id: evidence_id, - lifecycle_stage: signal.stage, - location: SourceLocation { + let mut lifecycle_evidence = LifecycleEvidence::default(); + let mut evidence = Vec::new(); + + for signal in signals + .iter() + .filter(|signal| signal_belongs_to_flow(signal, flow_line)) + { + let line = signal.line.to_string(); + let column = signal.column.to_string(); + let evidence_id = stable_evidence_id(&[ + DETECTOR_ID, + signal.detector_id, + input.path, + line.as_str(), + column.as_str(), + flow_line.to_string().as_str(), + ]); + push_lifecycle_id(&mut lifecycle_evidence, signal.stage, evidence_id.clone()); + evidence.push(Evidence { + id: evidence_id, + lifecycle_stage: signal.stage, + location: SourceLocation { + path: input.path.to_string(), + line: Some(signal.line), + column: Some(signal.column), + }, + detector_id: signal.detector_id.to_string(), + confidence: signal.confidence, + excerpt: Some(signal.excerpt.clone()), + dynamic: signal.dynamic, + framework_default: signal.framework_default, + }); + } + + if evidence.is_empty() { + continue; + } + + output.artifacts.push(Artifact { + id: artifact_id, + artifact_type: ArtifactType::OAuthAuthCodeFlow, + display_name: Some(format!("oauth_auth_code_flow:{flow_line}")), + locations: vec![SourceLocation { path: input.path.to_string(), - line: Some(signal.line), - column: Some(signal.column), - }, - detector_id: signal.detector_id.to_string(), - confidence: signal.confidence, - excerpt: Some(signal.excerpt), - dynamic: signal.dynamic, - framework_default: signal.framework_default, + line: Some(flow_line), + column: Some(1), + }], + lifecycle_evidence, + confidence: Confidence::High, + framework_hints: framework_hints(input.path, input.source), + cookie_attributes: None, + jwt_attributes: None, + token_boundary_attributes: None, }); + output.evidence.extend(evidence); } - - if evidence.is_empty() { - return output; - } - - output.artifacts.push(Artifact { - id: artifact_id, - artifact_type: ArtifactType::OAuthAuthCodeFlow, - display_name: Some("oauth_auth_code_flow".to_string()), - locations: vec![SourceLocation { - path: input.path.to_string(), - line: evidence.first().and_then(|item| item.location.line), - column: Some(1), - }], - lifecycle_evidence, - confidence: Confidence::High, - framework_hints: framework_hints(input.path, input.source), - cookie_attributes: None, - jwt_attributes: None, - token_boundary_attributes: None, - }); - output.evidence = evidence; output } +fn signal_belongs_to_flow(signal: &Signal, flow_line: usize) -> bool { + signal.detector_id == "oauth.flow.auth_code" + || (signal.line + 2 >= flow_line && signal.line <= flow_line + 8) +} + fn push_lifecycle_id(lifecycle: &mut LifecycleEvidence, stage: LifecycleStage, id: EvidenceId) { let bucket = match stage { LifecycleStage::Issue => &mut lifecycle.issue, @@ -445,4 +467,44 @@ verifyIdToken(token, { nonce }) "redirect_uris: ['https://example.com']" )); } + + #[test] + fn does_not_mark_state_or_nonce_reads_as_verification() { + let output = OAuthFlowDetector.detect(&input( + r#" +const url = client.authorizationUrl({ response_type: 'code', scope: 'openid', state, nonce, code_challenge: challenge }) +const callbackState = req.query.state +const expectedState = session.oauthState +const idNonce = id_token.nonce +"#, + )); + + assert!( + !output + .evidence + .iter() + .any(|evidence| evidence.detector_id == "oauth.state.verified") + ); + assert!( + !output + .evidence + .iter() + .any(|evidence| evidence.detector_id == "oauth.nonce.verified") + ); + } + + #[test] + fn creates_separate_artifacts_for_separate_flows() { + let output = OAuthFlowDetector.detect(&input( + r#" +const good = client.authorizationUrl({ response_type: 'code', state, code_challenge: challenge }) +const farAway = true +const otherFarAway = true +const another = true +const unsafe = client.authorizationUrl({ response_type: 'code' }) +"#, + )); + + assert_eq!(output.artifacts.len(), 2); + } } From a69a59f6a8c5591d691019b591f2a6a878486d77 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 10:57:26 -0500 Subject: [PATCH 24/29] Fix #77: tighten P3 review redaction and flow bounds --- .../src/client_storage/mod.rs | 4 +-- .../src/oauth_flow/mod.rs | 32 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/sessionscope-detectors/src/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs index e36947f..38d7210 100644 --- a/crates/sessionscope-detectors/src/client_storage/mod.rs +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -18,7 +18,7 @@ static STORAGE_RE: LazyLock = LazyLock::new(|| { .expect("storage regex should compile") }); static STORAGE_VALUE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?ix)(\b(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?:"(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^"]*"|'(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^']*')\s*,\s*)(["'])([^"']*)(["'])"#) + Regex::new(r#"(?ix)(\b(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?:"(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^"]*"|'(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^']*')\s*,\s*)(["'`])([^"'`]*)(["'`])"#) .expect("storage value regex should compile") }); static DOCUMENT_COOKIE_RE: LazyLock = LazyLock::new(|| { @@ -374,7 +374,7 @@ const clientSecret = 'PLACEHOLDER_SECRET_DO_NOT_USE' fn redacts_storage_second_argument_literals() { let output = ClientStorageDetector.detect(&input( "src/components/Auth.tsx", - "localStorage.setItem('access_token', 'raw-token-value')", + "localStorage.setItem('access_token', `raw-token-value`)", )); let rendered = format!("{:?}", output.evidence); diff --git a/crates/sessionscope-detectors/src/oauth_flow/mod.rs b/crates/sessionscope-detectors/src/oauth_flow/mod.rs index f47be25..874abab 100644 --- a/crates/sessionscope-detectors/src/oauth_flow/mod.rs +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -12,7 +12,7 @@ const DETECTOR_ID: &str = "oauth.flow"; const REDACTION: &str = "[REDACTED]"; static OAUTH_FLOW_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?i)(passport-oauth2|OAuth2Strategy|authorizationUrl|authorization_url|OAuthProvider|OIDCProvider|NextAuth|OAuth2Client|OAuth2Session|register\(|authorize_redirect|response_type\s*[:=]\s*['\"]code)"#) + Regex::new(r#"(?i)(OAuth2Strategy|authorizationUrl|authorization_url|OAuthProvider|OIDCProvider|register\(|authorize_redirect|response_type\s*[:=]\s*['\"]code)"#) .expect("oauth flow regex should compile") }); static PKCE_RE: LazyLock = LazyLock::new(|| { @@ -48,7 +48,7 @@ static REDIRECT_RE: LazyLock = LazyLock::new(|| { static QUOTED_RE: LazyLock = LazyLock::new(|| Regex::new(r#"["']([^"']+)["']"#).expect("quoted regex should compile")); static OAUTH_VALUE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?ix)(\b(?:state|nonce|code_verifier|codeVerifier|code_challenge|codeChallenge)\b\s*[:=]\s*)(["'])([^"']{8,})(["'])"#) + Regex::new(r#"(?ix)(\b(?:state|nonce|code_verifier|codeVerifier|code_challenge|codeChallenge)\b\s*[:=]\s*)(["'`])([^"'`]{8,})(["'`])"#) .expect("oauth value regex should compile") }); static OAUTH_URL_VALUE_RE: LazyLock = LazyLock::new(|| { @@ -90,7 +90,7 @@ fn detect(input: &DetectorInput<'_>) -> DetectionOutput { for (index, line) in input.source.lines().enumerate() { let line_number = index + 1; - if OAUTH_FLOW_RE.is_match(line) { + if OAUTH_FLOW_RE.is_match(line) && !is_import_only_line(line) { saw_flow = true; signals.push(signal( "oauth.flow.auth_code", @@ -249,7 +249,8 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti .map(|signal| signal.line) .collect::>(); - for flow_line in flow_lines { + for (index, flow_line) in flow_lines.iter().copied().enumerate() { + let next_flow_line = flow_lines.get(index + 1).copied(); let artifact_id = stable_artifact_id(&[ DETECTOR_ID, "oauth_auth_code_flow", @@ -261,7 +262,7 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti for signal in signals .iter() - .filter(|signal| signal_belongs_to_flow(signal, flow_line)) + .filter(|signal| signal_belongs_to_flow(signal, flow_line, next_flow_line)) { let line = signal.line.to_string(); let column = signal.column.to_string(); @@ -315,9 +316,17 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti output } -fn signal_belongs_to_flow(signal: &Signal, flow_line: usize) -> bool { - signal.detector_id == "oauth.flow.auth_code" - || (signal.line + 2 >= flow_line && signal.line <= flow_line + 8) +fn signal_belongs_to_flow( + signal: &Signal, + flow_line: usize, + next_flow_line: Option, +) -> bool { + if signal.detector_id == "oauth.flow.auth_code" { + return signal.line == flow_line; + } + signal.line + 2 >= flow_line + && signal.line <= flow_line + 8 + && next_flow_line.is_none_or(|next| signal.line < next) } fn push_lifecycle_id(lifecycle: &mut LifecycleEvidence, stage: LifecycleStage, id: EvidenceId) { @@ -362,6 +371,11 @@ fn framework_hints(path: &str, source: &str) -> Vec { hints } +fn is_import_only_line(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("import ") || trimmed.starts_with("from ") +} + fn redirect_line_is_broad(line: &str) -> bool { QUOTED_RE.captures_iter(line).any(|capture| { let value = capture.get(1).map_or("", |capture| capture.as_str()); @@ -451,7 +465,7 @@ verifyIdToken(token, { nonce }) #[test] fn redacts_oauth_high_entropy_values_in_excerpts() { let output = OAuthFlowDetector.detect(&input( - "client.authorizationUrl({ response_type: 'code', state: 'abcdefghijklmnopqrstuvwxyz123456', nonce: 'ZYXWVUTSRQPONMLKJIHGFEDCBA987654' })", + "client.authorizationUrl({ response_type: 'code', state: `abcdefghijklmnopqrstuvwxyz123456`, nonce: 'ZYXWVUTSRQPONMLKJIHGFEDCBA987654' })", )); let rendered = format!("{:?}", output.evidence); assert!(!rendered.contains("abcdefghijklmnopqrstuvwxyz123456")); From b1ddd2a5ef6842fb046cfe418bcae132bbbe88cd Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 11:00:18 -0500 Subject: [PATCH 25/29] Fix #77: resolve focused P3 review blockers --- crates/sessionscope-core/src/redaction.rs | 18 ++++++++++++++++++ .../src/client_storage/mod.rs | 4 ++-- .../src/oauth_flow/mod.rs | 6 +++--- crates/sessionscope-reporters/src/lib.rs | 14 ++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/sessionscope-core/src/redaction.rs b/crates/sessionscope-core/src/redaction.rs index 0c00e26..ff0f27a 100644 --- a/crates/sessionscope-core/src/redaction.rs +++ b/crates/sessionscope-core/src/redaction.rs @@ -36,6 +36,10 @@ static COOKIE_CALL_RE: LazyLock = LazyLock::new(|| { ) .expect("cookie call regex should compile") }); +static BROWSER_STORAGE_CALL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?ix)(\b(?:localStorage|sessionStorage)\s*\.\s*setItem\s*\(\s*(?:"(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^"]*"|'(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session)[^']*')\s*,\s*)(["'`])([^"'`]*)(["'`])"#) + .expect("browser storage call regex should compile") +}); static COOKIE_VALUE_KEY_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?ix)(\bvalue\s*[:=]\s*)(["'])([^"']*)(["'])"#) .expect("cookie value key regex should compile") @@ -218,6 +222,9 @@ pub fn redact_sensitive_values(input: &str) -> String { output = COOKIE_CALL_RE .replace_all(&output, format!("${{1}}${{2}}{REDACTION}${{4}}")) .to_string(); + output = BROWSER_STORAGE_CALL_RE + .replace_all(&output, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string(); output = COOKIE_VALUE_KEY_RE .replace_all(&output, format!("${{1}}${{2}}{REDACTION}${{4}}")) .to_string(); @@ -717,6 +724,17 @@ mod tests { assert!(output.contains("code_challenge=[REDACTED]")); } + #[test] + fn redacts_browser_storage_token_values() { + let output = redact_sensitive_values( + "localStorage.setItem('access_token', `raw-token-value`); sessionStorage.setItem(\"refresh_token\", 'short-secret')", + ); + + assert!(output.contains("[REDACTED]")); + assert!(!output.contains("raw-token-value")); + assert!(!output.contains("short-secret")); + } + #[test] fn redacts_placeholder_secret_values() { let output = redact_sensitive_values( diff --git a/crates/sessionscope-detectors/src/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs index 38d7210..6b6c279 100644 --- a/crates/sessionscope-detectors/src/client_storage/mod.rs +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -32,12 +32,12 @@ static URL_PATH_FRAGMENT_RE: LazyLock = LazyLock::new(|| { }); static CLIENT_SECRET_RE: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?i)\b(client_secret|clientSecret)\b\s*[:=]\s*(["'][^"']+["']|[A-Za-z0-9._~+/-]{12,})"#, + r#"(?i)\b(client_secret|clientSecret)\b\s*[:=]\s*(["'`][^"'`]+["'`]|[A-Za-z0-9._~+/-]{12,})"#, ) .expect("client secret regex should compile") }); static SENSITIVE_VALUE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?ix)(\b(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session|client[_-]?secret|clientSecret)\b\s*[:=,]\s*)(["'])([^"']*)(["'])"#) + Regex::new(r#"(?ix)(\b(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer|auth|session|client[_-]?secret|clientSecret)\b\s*[:=,]\s*)(["'`])([^"'`]*)(["'`])"#) .expect("sensitive value regex should compile") }); diff --git a/crates/sessionscope-detectors/src/oauth_flow/mod.rs b/crates/sessionscope-detectors/src/oauth_flow/mod.rs index 874abab..5dcfe8e 100644 --- a/crates/sessionscope-detectors/src/oauth_flow/mod.rs +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -26,7 +26,7 @@ static STATIC_STATE_RE: LazyLock = LazyLock::new(|| { .expect("static state regex should compile") }); static STATE_VERIFY_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(state[^\n]{0,80}(===|==|!=|!==)|session[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|cookies?[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|csrf[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)|compare_digest\([^\n]*state)") + Regex::new(r"(?i)(state[^\n]{0,80}(===|==|!=|!==)[^\n]{0,80}(session|cookie|cache|expected|csrf)|(session|cookie|cache|expected|csrf)[^\n]{0,80}state[^\n]{0,80}(===|==|!=|!==)[^\n]{0,80}state|compare_digest\([^\n]*state)") .expect("state verification regex should compile") }); static OPENID_RE: LazyLock = LazyLock::new(|| { @@ -37,7 +37,7 @@ static NONCE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bnonce\b").expect("nonce regex should compile")); static NONCE_VERIFY_RE: LazyLock = LazyLock::new(|| { Regex::new( - r"(?i)(verify[^\n]{0,80}nonce|parse_id_token[^\n]{0,80}nonce|nonce[^\n]{0,80}(===|==|!=|!==)|id_token[^\n]{0,80}nonce[^\n]{0,80}(===|==|!=|!==))", + r"(?i)((verifyIdToken|verify_id_token|parse_id_token|jwtVerify)[^\n]{0,80}nonce|nonce[^\n]{0,80}(===|==|!=|!==)[^\n]{0,80}(expected|session|cookie|cache)|id_token[^\n]{0,80}nonce[^\n]{0,80}(===|==|!=|!==)[^\n]{0,80}(expected|session|cookie|cache))", ) .expect("nonce verification regex should compile") }); @@ -324,7 +324,7 @@ fn signal_belongs_to_flow( if signal.detector_id == "oauth.flow.auth_code" { return signal.line == flow_line; } - signal.line + 2 >= flow_line + signal.line >= flow_line && signal.line <= flow_line + 8 && next_flow_line.is_none_or(|next| signal.line < next) } diff --git a/crates/sessionscope-reporters/src/lib.rs b/crates/sessionscope-reporters/src/lib.rs index 1a8f30d..6fc682c 100644 --- a/crates/sessionscope-reporters/src/lib.rs +++ b/crates/sessionscope-reporters/src/lib.rs @@ -440,4 +440,18 @@ mod tests { } } } + + #[test] + fn render_sanitizes_browser_storage_token_values() { + let mut report = unsafe_report(); + report.evidence[0].excerpt = Some(SanitizedExcerpt::from_sanitized( + "localStorage.setItem('access_token', `raw-token-value`)".to_string(), + )); + + for format in [ReportFormat::Json, ReportFormat::Markdown] { + let output = render(&report, format); + assert!(output.contains("REDACTED"), "{format:?} did not redact"); + assert!(!output.contains("raw-token-value")); + } + } } From 180b44efb43d2d157fdc37865761cd53935a54d6 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 11:02:57 -0500 Subject: [PATCH 26/29] Fix #77: preserve pre-flow OAuth evidence in segment --- .../sessionscope-detectors/src/oauth_flow/mod.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/sessionscope-detectors/src/oauth_flow/mod.rs b/crates/sessionscope-detectors/src/oauth_flow/mod.rs index 5dcfe8e..709e49b 100644 --- a/crates/sessionscope-detectors/src/oauth_flow/mod.rs +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -250,6 +250,9 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti .collect::>(); for (index, flow_line) in flow_lines.iter().copied().enumerate() { + let previous_flow_line = index + .checked_sub(1) + .and_then(|idx| flow_lines.get(idx).copied()); let next_flow_line = flow_lines.get(index + 1).copied(); let artifact_id = stable_artifact_id(&[ DETECTOR_ID, @@ -260,10 +263,9 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti let mut lifecycle_evidence = LifecycleEvidence::default(); let mut evidence = Vec::new(); - for signal in signals - .iter() - .filter(|signal| signal_belongs_to_flow(signal, flow_line, next_flow_line)) - { + for signal in signals.iter().filter(|signal| { + signal_belongs_to_flow(signal, previous_flow_line, flow_line, next_flow_line) + }) { let line = signal.line.to_string(); let column = signal.column.to_string(); let evidence_id = stable_evidence_id(&[ @@ -318,14 +320,16 @@ fn signals_to_output(input: &DetectorInput<'_>, signals: Vec) -> Detecti fn signal_belongs_to_flow( signal: &Signal, + previous_flow_line: Option, flow_line: usize, next_flow_line: Option, ) -> bool { if signal.detector_id == "oauth.flow.auth_code" { return signal.line == flow_line; } - signal.line >= flow_line + signal.line + 8 >= flow_line && signal.line <= flow_line + 8 + && previous_flow_line.is_none_or(|previous| signal.line > previous) && next_flow_line.is_none_or(|next| signal.line < next) } From d966f7fe687e8bc4d51f32e4123893d01dfa1f34 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sat, 23 May 2026 11:06:28 -0500 Subject: [PATCH 27/29] Fix #83: keep P3 classifier tests clippy-clean --- crates/sessionscope-classifier/src/client_storage.rs | 6 ++++-- crates/sessionscope-classifier/src/oauth_flow.rs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/sessionscope-classifier/src/client_storage.rs b/crates/sessionscope-classifier/src/client_storage.rs index 220565b..b1ccd92 100644 --- a/crates/sessionscope-classifier/src/client_storage.rs +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -124,8 +124,10 @@ mod tests { fn report_with(detector_id: &str) -> ScanReport { let evidence_id = EvidenceId("evidence_storage".to_string()); - let mut lifecycle_evidence = LifecycleEvidence::default(); - lifecycle_evidence.store.push(evidence_id.clone()); + let lifecycle_evidence = LifecycleEvidence { + store: vec![evidence_id.clone()], + ..Default::default() + }; ScanReport { schema_version: SCHEMA_VERSION.to_string(), summary: ScanSummary::default(), diff --git a/crates/sessionscope-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs index 0b5adbd..09fd28e 100644 --- a/crates/sessionscope-classifier/src/oauth_flow.rs +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -345,8 +345,10 @@ mod tests { framework_default: false, }) .collect::>(); - let mut lifecycle_evidence = LifecycleEvidence::default(); - lifecycle_evidence.issue = evidence.iter().map(|item| item.id.clone()).collect(); + let lifecycle_evidence = LifecycleEvidence { + issue: evidence.iter().map(|item| item.id.clone()).collect(), + ..Default::default() + }; ScanReport { schema_version: SCHEMA_VERSION.to_string(), From 7defb93ebb8c30dbc064145ff3cb792d6d968c4f Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 08:50:25 -0500 Subject: [PATCH 28/29] Potential fix for pull request finding 'CodeQL / Cleartext logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- crates/sessionscope-reporters/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sessionscope-reporters/src/lib.rs b/crates/sessionscope-reporters/src/lib.rs index 6fc682c..f942a49 100644 --- a/crates/sessionscope-reporters/src/lib.rs +++ b/crates/sessionscope-reporters/src/lib.rs @@ -436,7 +436,7 @@ mod tests { "nonceabcdefghijklmnopqrstuvwxyzABCDEF0123456789", "verifierabcdefghijklmnopqrstuvwxyzABCDEF0123456789", ] { - assert!(!output.contains(secret), "{format:?} leaked {secret}"); + assert!(!output.contains(secret), "{format:?} leaked a sensitive value"); } } } From 22b0932695b3f051793286e39dfdf7afd9676dfb Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 08:58:31 -0500 Subject: [PATCH 29/29] Address PR #98 review findings --- crates/sessionscope-reporters/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/sessionscope-reporters/src/lib.rs b/crates/sessionscope-reporters/src/lib.rs index f942a49..8d5d657 100644 --- a/crates/sessionscope-reporters/src/lib.rs +++ b/crates/sessionscope-reporters/src/lib.rs @@ -436,7 +436,10 @@ mod tests { "nonceabcdefghijklmnopqrstuvwxyzABCDEF0123456789", "verifierabcdefghijklmnopqrstuvwxyzABCDEF0123456789", ] { - assert!(!output.contains(secret), "{format:?} leaked a sensitive value"); + assert!( + !output.contains(secret), + "{format:?} leaked a sensitive value" + ); } } }