From 89d441092b0e0f461ee3950806f6d0dab646989b Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Thu, 21 May 2026 22:21:32 -0500 Subject: [PATCH 01/41] =?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/41] =?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/41] =?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/41] =?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/41] =?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/41] =?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/41] =?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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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" + ); } } } From a6a77a2f40199772c4197a1a6678f9ffdd595a7a Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:17:14 -0500 Subject: [PATCH 30/41] =?UTF-8?q?Fix=20#87:=20P4.1=20Classifier=20?= =?UTF-8?q?=E2=80=94=20JWT=20denylist=20absent=20on=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../sessionscope-classifier/src/lifecycle.rs | 236 +++++++++++++++++- crates/sessionscope-testing/src/fixtures.rs | 47 ++++ docs/COVERAGE_MATRIX.md | 1 + docs/USAGE.md | 2 +- .../jwt-logout-with-denylist/app.ts | 21 ++ .../jwt-logout-with-denylist/expected.json | 9 + .../jwt-logout-without-denylist/app.ts | 14 ++ .../jwt-logout-without-denylist/expected.json | 9 + 9 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 fixtures/generic-ts/jwt-logout-with-denylist/app.ts create mode 100644 fixtures/generic-ts/jwt-logout-with-denylist/expected.json create mode 100644 fixtures/generic-ts/jwt-logout-without-denylist/app.ts create mode 100644 fixtures/generic-ts/jwt-logout-without-denylist/expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7549172..03b23cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and SessionScope uses semantic versioning as described in - 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. +- Added `jwt_denylist_absent_on_logout_review` lifecycle coverage for access-JWT logout flows that lack linked denylist, blocklist, or token revocation-store evidence. - 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-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index e92e98d..098b2ed 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -25,6 +25,7 @@ pub fn link(report: &ScanReport) -> Vec { pub fn classify(report: &ScanReport) -> Vec { let mut findings = Vec::new(); + let evidence_by_id = evidence_by_id(report); for path in &report.lifecycle_paths { let Some(artifact) = artifact_for_path(report, path) else { @@ -32,7 +33,6 @@ pub fn classify(report: &ScanReport) -> Vec { }; findings.extend(classify_issue_without_validate(artifact, path)); - let evidence_by_id = evidence_by_id(report); findings.extend(classify_refresh_without_revoke( artifact, path, @@ -50,6 +50,12 @@ pub fn classify(report: &ScanReport) -> Vec { path, &evidence_by_id, )); + findings.extend(classify_jwt_denylist_absent_on_logout( + artifact, + path, + report, + &evidence_by_id, + )); } findings @@ -592,6 +598,53 @@ fn classify_cookie_clear_attribute_mismatch( )) } +fn classify_jwt_denylist_absent_on_logout( + artifact: &Artifact, + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Option { + if artifact.artifact_type != ArtifactType::AccessJwt + || !(has_stage(path, LifecycleStage::Issue) || has_stage(path, LifecycleStage::Validate)) + { + return None; + } + + let logout_ids = linked_logout_handler_ids(path, report, evidence_by_id); + if logout_ids.is_empty() || has_linked_jwt_denylist_or_revoke(path, report, evidence_by_id) { + return None; + } + + let mut evidence_ids = logout_ids; + evidence_ids.extend(evidence_ids_for_stage(path, LifecycleStage::Issue)); + if evidence_ids.len() == 1 { + evidence_ids.extend(evidence_ids_for_stage(path, LifecycleStage::Validate)); + } + evidence_ids.sort(); + evidence_ids.dedup(); + + let name = artifact.display_name.as_deref().unwrap_or("access_jwt"); + Some(finding( + artifact, + path, + FindingSpec { + rule_id: "jwt_denylist_absent_on_logout_review", + category: FindingCategory::LifecycleGap, + severity: Severity::Medium, + evidence_ids, + title: format!("JWT `{name}` has logout evidence without linked denylist evidence"), + description: "A logout handler and access-JWT lifecycle evidence were detected in linked source context, but no source-bound denylist, blocklist, or token revocation-store insertion evidence was linked for the same logout flow." + .to_string(), + suggested_fix: + "Insert the JWT identifier into a denylist/blocklist or revoke-store on logout, or document the intentional short-TTL stateless model for reviewer confirmation." + .to_string(), + reviewer_question: format!( + "Where does logout revoke or denylist outstanding `{name}` tokens?" + ), + }, + )) +} + fn attribute_missing_or_mismatched( observation: &sessionscope_model::CookieAttributeObservation, attribute: &str, @@ -778,6 +831,99 @@ fn has_dynamic_or_provider_refresh( }) } +fn linked_logout_handler_ids( + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Vec { + let direct_ids = evidence_ids_for_stage(path, LifecycleStage::Revoke) + .into_iter() + .filter(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| evidence.detector_id == "logout.handler") + }) + .collect::>(); + if !direct_ids.is_empty() { + return direct_ids; + } + + report + .evidence + .iter() + .filter(|evidence| evidence.detector_id == "logout.handler") + .filter(|evidence| evidence_linked_to_path_context(evidence, path, evidence_by_id)) + .map(|evidence| evidence.id.clone()) + .collect() +} + +fn has_linked_jwt_denylist_or_revoke( + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + evidence_ids_for_stage(path, LifecycleStage::Revoke) + .iter() + .any(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| is_jwt_denylist_or_revoke_evidence(evidence)) + }) + || report.evidence.iter().any(|evidence| { + is_jwt_denylist_or_revoke_evidence(evidence) + && evidence_linked_to_path_context(evidence, path, evidence_by_id) + }) +} + +fn is_jwt_denylist_or_revoke_evidence(evidence: &Evidence) -> bool { + matches!( + evidence.detector_id.as_str(), + "logout.token_revoke" | "logout.provider_revoke" + ) || evidence + .excerpt + .as_ref() + .is_some_and(|excerpt| contains_jwt_denylist_text(excerpt.as_str())) +} + +fn contains_jwt_denylist_text(value: &str) -> bool { + let normalized = value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::() + .to_ascii_lowercase(); + (normalized.contains("denylist") + || normalized.contains("blocklist") + || normalized.contains("blacklist") + || normalized.contains("revokedtokens") + || normalized.contains("revokedtoken") + || normalized.contains("revoketoken") + || normalized.contains("addtoblocklist")) + && (normalized.contains("jwt") + || normalized.contains("jti") + || normalized.contains("access") + || normalized.contains("token")) +} + +fn evidence_linked_to_path_context( + evidence: &Evidence, + path: &LifecyclePath, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + path_evidence_locations(path, evidence_by_id) + .iter() + .any(|location| locations_are_linkable(&evidence.location, location)) +} + +fn locations_are_linkable(left: &SourceLocation, right: &SourceLocation) -> bool { + left.path == right.path + && left.line.is_some() + && right.line.is_some() + && left + .line + .zip(right.line) + .is_some_and(|(left, right)| left.abs_diff(right) <= REFRESH_LINK_MAX_LINE_DISTANCE) +} + fn client_cookie_clear_ids( path: &LifecyclePath, evidence_by_id: &BTreeMap<&str, &Evidence>, @@ -2168,6 +2314,94 @@ mod tests { assert!(finding.title.contains("dynamic refresh behavior")); } + #[test] + fn jwt_logout_without_denylist_is_lifecycle_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_access", + ArtifactType::AccessJwt, + "access_token", + LifecycleEvidence { + issue: vec![EvidenceId("evidence_issue".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence("evidence_issue", LifecycleStage::Issue, 10, false), + evidence_with_detector( + "evidence_logout", + LifecycleStage::Revoke, + "logout.handler", + 20, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + let finding = findings + .iter() + .find(|finding| finding.title.contains("without linked denylist")) + .expect("JWT logout denylist review finding"); + + assert_eq!(finding.category, FindingCategory::LifecycleGap); + assert_eq!(finding.severity, Severity::Medium); + assert!(finding.reviewer_question.is_some()); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_issue".to_string())) + ); + assert!( + finding + .evidence_ids + .contains(&EvidenceId("evidence_logout".to_string())) + ); + } + + #[test] + fn linked_jwt_revoke_prevents_logout_denylist_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_access", + ArtifactType::AccessJwt, + "access_token", + LifecycleEvidence { + issue: vec![EvidenceId("evidence_issue".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence("evidence_issue", LifecycleStage::Issue, 10, false), + evidence_with_detector( + "evidence_logout", + LifecycleStage::Revoke, + "logout.handler", + 20, + false, + ), + evidence_with_detector( + "evidence_revoke", + LifecycleStage::Revoke, + "logout.token_revoke", + 21, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + !findings + .iter() + .any(|finding| finding.title.contains("without linked denylist")), + "{findings:?}" + ); + } + #[test] fn sort_paths_orders_paths_by_artifact() { let mut paths = vec![ diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 846c377..8e099b1 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -830,6 +830,53 @@ mod tests { })); } + #[test] + fn jwt_logout_without_denylist_fixture_produces_review_gap() { + let root = fixture_root() + .join("generic-ts") + .join("jwt-logout-without-denylist"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("jwt logout without denylist fixture should scan"), + ); + + assert!(report.findings.iter().any(|finding| { + finding.category == FindingCategory::LifecycleGap + && finding.title.contains("without linked denylist") + && finding.reviewer_question.is_some() + })); + } + + #[test] + fn jwt_logout_with_denylist_fixture_stays_clean_for_denylist_gap() { + let root = fixture_root() + .join("generic-ts") + .join("jwt-logout-with-denylist"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("jwt logout with denylist fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Revoke + && evidence.detector_id == "logout.token_revoke" + })); + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("without linked denylist")), + "{:?}", + report.findings + ); + } + #[test] fn issue_27_provider_library_fixtures_emit_documented_coverage() { let cases = [ diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index 6c4b20c..13ea241 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -45,6 +45,7 @@ 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_denylist_absent_on_logout_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | JWT issue/validate APIs plus logout handlers | **review-required:** access-JWT lifecycle evidence linked by source context to logout evidence with no visible denylist, blocklist, `revokeToken`, or revocation-store insertion; **not covered:** runtime-only short-TTL policy or external gateway revocation | revoke | `lifecycle_gap` | `lifecycle_gap` | Logout for stateless access JWTs needs reviewer confirmation when no denylist/revocation evidence is visible. | | `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. | diff --git a/docs/USAGE.md b/docs/USAGE.md index 641e582..e978f9c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -130,7 +130,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - 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; `document.cookie` token writes are emitted as source evidence only in this phase. - Tokens issued without explicit expiry - Refresh tokens without rotation evidence -- Logout without revocation evidence +- Logout without revocation evidence, including `jwt_denylist_absent_on_logout_review` when access-JWT logout flows lack linked denylist/blocklist/revocation-store evidence - Password-reset tokens without expiry or single-use evidence - Session fixation risk signals - Token accepted from query parameters diff --git a/fixtures/generic-ts/jwt-logout-with-denylist/app.ts b/fixtures/generic-ts/jwt-logout-with-denylist/app.ts new file mode 100644 index 0000000..4323e37 --- /dev/null +++ b/fixtures/generic-ts/jwt-logout-with-denylist/app.ts @@ -0,0 +1,21 @@ +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); +const JWT_SECRET = "PLACEHOLDER_SECRET_DO_NOT_USE"; +const revokedTokens = new Set(); + +export function issueAccessJwt(userId: string): string { + return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: "15m", jwtid: "placeholder-jti" }); +} + +function revokeToken(accessToken: string) { + revokedTokens.add(accessToken); +} + +app.post("/logout", (request, response) => { + const accessToken = request.header("authorization") ?? ""; + revokeToken(accessToken); + response.clearCookie("session"); + response.status(204).end(); +}); diff --git a/fixtures/generic-ts/jwt-logout-with-denylist/expected.json b/fixtures/generic-ts/jwt-logout-with-denylist/expected.json new file mode 100644 index 0000000..c982fa7 --- /dev/null +++ b/fixtures/generic-ts/jwt-logout-with-denylist/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-logout-with-denylist", + "framework": "generic-typescript", + "source_files": ["app.ts"], + "expected_artifacts": ["access_jwt", "session"], + "expected_lifecycle_stages": ["issue", "revoke", "expire"], + "expected_findings": ["jwt_denylist_evidence_present"], + "notes": "Covers an access-JWT logout path with source-visible token revocation evidence so the denylist-absent review should not fire." +} diff --git a/fixtures/generic-ts/jwt-logout-without-denylist/app.ts b/fixtures/generic-ts/jwt-logout-without-denylist/app.ts new file mode 100644 index 0000000..e57652e --- /dev/null +++ b/fixtures/generic-ts/jwt-logout-without-denylist/app.ts @@ -0,0 +1,14 @@ +import express from "express"; +import jwt from "jsonwebtoken"; + +const app = express(); +const JWT_SECRET = "PLACEHOLDER_SECRET_DO_NOT_USE"; + +export function issueAccessJwt(userId: string): string { + return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: "15m" }); +} + +app.post("/logout", (_request, response) => { + response.clearCookie("session"); + response.status(204).end(); +}); diff --git a/fixtures/generic-ts/jwt-logout-without-denylist/expected.json b/fixtures/generic-ts/jwt-logout-without-denylist/expected.json new file mode 100644 index 0000000..8860de0 --- /dev/null +++ b/fixtures/generic-ts/jwt-logout-without-denylist/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-ts.jwt-logout-without-denylist", + "framework": "generic-typescript", + "source_files": ["app.ts"], + "expected_artifacts": ["access_jwt", "session"], + "expected_lifecycle_stages": ["issue", "revoke", "expire"], + "expected_findings": ["jwt_denylist_absent_on_logout_review"], + "notes": "Covers an access-JWT issuing app with logout evidence but no linked denylist, blocklist, or token revocation-store insertion." +} From 618c7988655f541c64839fb4f1c6a689b222f5e0 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:20:11 -0500 Subject: [PATCH 31/41] =?UTF-8?q?Fix=20#88:=20P4.2=20Classifier=20?= =?UTF-8?q?=E2=80=94=20refresh-family=20revocation=20absent=20on=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../sessionscope-classifier/src/lifecycle.rs | 207 ++++++++++++++++++ crates/sessionscope-testing/src/fixtures.rs | 47 ++++ docs/COVERAGE_MATRIX.md | 1 + docs/USAGE.md | 2 +- .../logout-refresh-family-missing/app.ts | 18 ++ .../expected.json | 9 + .../logout-refresh-family-revoked/app.ts | 23 ++ .../expected.json | 9 + 9 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 fixtures/express/logout-refresh-family-missing/app.ts create mode 100644 fixtures/express/logout-refresh-family-missing/expected.json create mode 100644 fixtures/express/logout-refresh-family-revoked/app.ts create mode 100644 fixtures/express/logout-refresh-family-revoked/expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b23cd..9c6bdc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and SessionScope uses semantic versioning as described in - 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. - Added `jwt_denylist_absent_on_logout_review` lifecycle coverage for access-JWT logout flows that lack linked denylist, blocklist, or token revocation-store evidence. +- Added `refresh_family_revocation_absent_on_logout_review` lifecycle coverage for refresh-token logout flows that lack linked family/user-scoped revocation evidence. - 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-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index 098b2ed..fc8cfb0 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -56,6 +56,12 @@ pub fn classify(report: &ScanReport) -> Vec { report, &evidence_by_id, )); + findings.extend(classify_refresh_family_revocation_absent_on_logout( + artifact, + path, + report, + &evidence_by_id, + )); } findings @@ -645,6 +651,61 @@ fn classify_jwt_denylist_absent_on_logout( )) } +fn classify_refresh_family_revocation_absent_on_logout( + artifact: &Artifact, + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Option { + if !is_refresh_artifact(artifact) + || !(has_stage(path, LifecycleStage::Issue) + || has_stage(path, LifecycleStage::Store) + || has_stage(path, LifecycleStage::Refresh) + || has_stage(path, LifecycleStage::Validate)) + { + return None; + } + + let logout_ids = linked_logout_handler_ids(path, report, evidence_by_id); + if logout_ids.is_empty() || has_linked_refresh_family_revoke(path, report, evidence_by_id) { + return None; + } + + let mut evidence_ids = logout_ids; + evidence_ids.extend(evidence_ids_for_stage(path, LifecycleStage::Refresh)); + if evidence_ids.len() == 1 { + evidence_ids.extend(evidence_ids_for_stage(path, LifecycleStage::Store)); + } + if evidence_ids.len() == 1 { + evidence_ids.extend(evidence_ids_for_stage(path, LifecycleStage::Issue)); + } + evidence_ids.sort(); + evidence_ids.dedup(); + + let name = artifact.display_name.as_deref().unwrap_or("refresh_token"); + Some(finding( + artifact, + path, + FindingSpec { + rule_id: "refresh_family_revocation_absent_on_logout_review", + category: FindingCategory::LifecycleGap, + severity: Severity::Medium, + evidence_ids, + title: format!( + "Refresh token `{name}` has logout evidence without family revocation" + ), + description: "Logout and refresh-token lifecycle evidence were detected in linked source context, but no source-bound user-scoped or refresh-family revocation evidence was linked for the logout flow." + .to_string(), + suggested_fix: + "Revoke the user's refresh-token family, delete user-scoped refresh-token records, or remove the refresh-family cache key during logout." + .to_string(), + reviewer_question: format!( + "Where does logout revoke every refresh token in the `{name}` family or for the current user?" + ), + }, + )) +} + fn attribute_missing_or_mismatched( observation: &sessionscope_model::CookieAttributeObservation, attribute: &str, @@ -875,6 +936,62 @@ fn has_linked_jwt_denylist_or_revoke( }) } +fn has_linked_refresh_family_revoke( + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + evidence_ids_for_stage(path, LifecycleStage::Revoke) + .iter() + .any(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| is_refresh_family_revoke_evidence(evidence)) + }) + || report.evidence.iter().any(|evidence| { + is_refresh_family_revoke_evidence(evidence) + && evidence_linked_to_path_context(evidence, path, evidence_by_id) + }) +} + +fn is_refresh_family_revoke_evidence(evidence: &Evidence) -> bool { + (evidence.detector_id == "refresh.reuse_detection" + && evidence.lifecycle_stage == LifecycleStage::Revoke) + || (matches!( + evidence.detector_id.as_str(), + "refresh.revoke" | "refresh.rotate" | "logout.token_revoke" | "logout.provider_revoke" + ) && evidence + .excerpt + .as_ref() + .is_some_and(|excerpt| contains_refresh_family_revoke_text(excerpt.as_str()))) +} + +fn contains_refresh_family_revoke_text(value: &str) -> bool { + let normalized = value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::() + .to_ascii_lowercase(); + normalized.contains("refresh") + && (normalized.contains("family") + || normalized.contains("userid") + || normalized.contains("user_id") + || normalized.contains("usersessions") + || normalized.contains("allsessions") + || normalized.contains("allrefresh") + || normalized.contains("tokenfamily") + || normalized.contains("token_family") + || normalized.contains("familyid") + || normalized.contains("family_id")) + && (normalized.contains("revoke") + || normalized.contains("delete") + || normalized.contains("del") + || normalized.contains("invalidate") + || normalized.contains("destroy") + || normalized.contains("blacklist") + || normalized.contains("denylist")) +} + fn is_jwt_denylist_or_revoke_evidence(evidence: &Evidence) -> bool { matches!( evidence.detector_id.as_str(), @@ -2402,6 +2519,96 @@ mod tests { ); } + #[test] + fn refresh_logout_without_family_revoke_is_lifecycle_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_refresh", + ArtifactType::RefreshJwt, + "refresh_token", + LifecycleEvidence { + refresh: vec![EvidenceId("evidence_refresh".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence_with_detector( + "evidence_refresh", + LifecycleStage::Refresh, + "refresh.handler", + 10, + false, + ), + evidence_with_detector( + "evidence_logout", + LifecycleStage::Revoke, + "logout.handler", + 20, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + let finding = findings + .iter() + .find(|finding| finding.title.contains("without family revocation")) + .expect("refresh family revocation review finding"); + + assert_eq!(finding.category, FindingCategory::LifecycleGap); + assert_eq!(finding.severity, Severity::Medium); + assert!(finding.reviewer_question.is_some()); + } + + #[test] + fn linked_refresh_family_revoke_prevents_logout_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_refresh", + ArtifactType::RefreshJwt, + "refresh_token", + LifecycleEvidence { + refresh: vec![EvidenceId("evidence_refresh".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence_with_detector( + "evidence_refresh", + LifecycleStage::Refresh, + "refresh.handler", + 10, + false, + ), + evidence_with_detector( + "evidence_logout", + LifecycleStage::Revoke, + "logout.handler", + 20, + false, + ), + evidence_with_excerpt( + "evidence_family_revoke", + LifecycleStage::Revoke, + "refresh.revoke", + 21, + "revokeRefreshFamily(user.id)", + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + !findings + .iter() + .any(|finding| finding.title.contains("without family revocation")), + "{findings:?}" + ); + } + #[test] fn sort_paths_orders_paths_by_artifact() { let mut paths = vec![ diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 8e099b1..89ae645 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -1091,6 +1091,53 @@ mod tests { })); } + #[test] + fn logout_refresh_family_missing_fixture_produces_review_gap() { + let root = fixture_root() + .join("express") + .join("logout-refresh-family-missing"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("logout refresh family missing fixture should scan"), + ); + + assert!(report.findings.iter().any(|finding| { + finding.category == FindingCategory::LifecycleGap + && finding.title.contains("without family revocation") + && finding.reviewer_question.is_some() + })); + } + + #[test] + fn logout_refresh_family_revoked_fixture_stays_clean_for_family_gap() { + let root = fixture_root() + .join("express") + .join("logout-refresh-family-revoked"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("logout refresh family revoked fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Revoke + && evidence.detector_id == "refresh.revoke" + })); + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("without family revocation")), + "{:?}", + report.findings + ); + } + #[test] fn unrelated_refresh_fixtures_do_not_satisfy_each_other() { let root = fixture_root().join("express"); diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index 13ea241..5db16ce 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -73,6 +73,7 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m | `bearer_missing_validation` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Bearer/API-key handlers | **supported:** token use with no validation evidence | validate | `missing_validation_evidence` | `missing_validation_evidence` | Token validation evidence is missing. | | `bearer_issue_without_expiry` | JS/TS, Python | Generic JS/TS/Python | Bearer/API-key issue helpers | **supported:** issued opaque token without expiry | expire | `lifecycle_gap` | `lifecycle_gap` | Token issue flow lacks expiry evidence. | | `bearer_missing_rotation_or_revocation` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Bearer/API-key lifecycle helpers | **supported:** refresh/reuse lifecycle without revoke/rotate evidence | revoke | `lifecycle_gap` | `lifecycle_gap` | Token lifecycle lacks rotation or revocation evidence. | +| `refresh_family_revocation_absent_on_logout_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Refresh-token lifecycle helpers plus logout handlers | **review-required:** refresh-token lifecycle evidence linked by source context to logout evidence with no visible family/user-scoped refresh revocation such as `revokeRefreshFamily`, `delete refresh_tokens where user_id`, or refresh-family cache deletion; **not covered:** provider dashboard revocation outside source | revoke | `lifecycle_gap` | `lifecycle_gap` | Logout should revoke the user's refresh-token family, not only clear a browser cookie. | | `bearer_missing_scope_evidence` | JS/TS, Python | Generic JS/TS/Python, provider fixtures | Provider/token-boundary APIs | **supported:** token lacks scope/boundary evidence | validate | `missing_validation_evidence` | `missing_validation_evidence` | Scope or boundary evidence is missing. | | `bearer_dynamic_provider_review` | JS/TS, Python | Next.js, Express, generic JS/TS/Python | Auth0, Okta, Cognito, Azure AD, Firebase, Supabase, Clerk, Passport, NextAuth/AuthJS, OAuth/OIDC generic | **review-required:** provider-managed token behavior | refresh/revoke | `dynamic_review_required` | `dynamic_review_required` | Provider-managed lifecycle behavior needs human review. | | `query_param_token_acceptance_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Request/query APIs | **review-required:** dynamic query token acceptance | transmit | `dynamic_review_required` | `dynamic_review_required` | Query token acceptance needs review. | diff --git a/docs/USAGE.md b/docs/USAGE.md index e978f9c..99c3e18 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -130,7 +130,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - 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; `document.cookie` token writes are emitted as source evidence only in this phase. - Tokens issued without explicit expiry - Refresh tokens without rotation evidence -- Logout without revocation evidence, including `jwt_denylist_absent_on_logout_review` when access-JWT logout flows lack linked denylist/blocklist/revocation-store evidence +- Logout without revocation evidence, including `jwt_denylist_absent_on_logout_review` when access-JWT logout flows lack linked denylist/blocklist/revocation-store evidence and `refresh_family_revocation_absent_on_logout_review` when refresh-token logout flows lack family/user-scoped revocation evidence - Password-reset tokens without expiry or single-use evidence - Session fixation risk signals - Token accepted from query parameters diff --git a/fixtures/express/logout-refresh-family-missing/app.ts b/fixtures/express/logout-refresh-family-missing/app.ts new file mode 100644 index 0000000..8a8a828 --- /dev/null +++ b/fixtures/express/logout-refresh-family-missing/app.ts @@ -0,0 +1,18 @@ +import express from "express"; + +const app = express(); + +async function issueRefreshToken(userId: string) { + return refreshTokens.create({ userId, expiresAt: Date.now() + 86400 }); +} + +app.post("/refresh", async (request, response) => { + const refreshToken = request.cookies.refresh_token; + const nextToken = await issueRefreshToken(request.user.id); + response.cookie("refresh_token", nextToken, { httpOnly: true, maxAge: 86400 }); +}); + +app.post("/logout", (_request, response) => { + response.clearCookie("refresh_token"); + response.status(204).end(); +}); diff --git a/fixtures/express/logout-refresh-family-missing/expected.json b/fixtures/express/logout-refresh-family-missing/expected.json new file mode 100644 index 0000000..5408e26 --- /dev/null +++ b/fixtures/express/logout-refresh-family-missing/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "express.logout-refresh-family-missing", + "framework": "express", + "source_files": ["app.ts"], + "expected_artifacts": ["refresh_token"], + "expected_lifecycle_stages": ["issue", "store", "refresh", "revoke", "expire"], + "expected_findings": ["refresh_family_revocation_absent_on_logout_review"], + "notes": "Covers logout that clears the refresh cookie while leaving the server-side refresh-token family active." +} diff --git a/fixtures/express/logout-refresh-family-revoked/app.ts b/fixtures/express/logout-refresh-family-revoked/app.ts new file mode 100644 index 0000000..cfb2bd7 --- /dev/null +++ b/fixtures/express/logout-refresh-family-revoked/app.ts @@ -0,0 +1,23 @@ +import express from "express"; + +const app = express(); + +async function issueRefreshToken(userId: string) { + return refreshTokens.create({ userId, expiresAt: Date.now() + 86400 }); +} + +async function revokeRefreshFamily(userId: string) { + await refreshTokens.deleteMany({ user_id: userId }); +} + +app.post("/refresh", async (request, response) => { + const refreshToken = request.cookies.refresh_token; + const nextToken = await issueRefreshToken(request.user.id); + response.cookie("refresh_token", nextToken, { httpOnly: true, maxAge: 86400 }); +}); + +app.post("/logout", async (request, response) => { + await revokeRefreshFamily(request.user.id); + response.clearCookie("refresh_token"); + response.status(204).end(); +}); diff --git a/fixtures/express/logout-refresh-family-revoked/expected.json b/fixtures/express/logout-refresh-family-revoked/expected.json new file mode 100644 index 0000000..6caa589 --- /dev/null +++ b/fixtures/express/logout-refresh-family-revoked/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "express.logout-refresh-family-revoked", + "framework": "express", + "source_files": ["app.ts"], + "expected_artifacts": ["refresh_token"], + "expected_lifecycle_stages": ["issue", "store", "refresh", "revoke", "expire"], + "expected_findings": ["refresh_family_revocation_evidence_present"], + "notes": "Covers logout that calls a user-scoped refresh-family revocation helper." +} From ff6d451748239bce8d47ae06cb4dfff118c09025 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:22:54 -0500 Subject: [PATCH 32/41] =?UTF-8?q?Fix=20#89:=20P4.3=20Classifier=20?= =?UTF-8?q?=E2=80=94=20sliding=20expiry=20without=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../sessionscope-classifier/src/lifecycle.rs | 234 ++++++++++++++++++ crates/sessionscope-testing/src/fixtures.rs | 68 +++++ docs/COVERAGE_MATRIX.md | 1 + docs/USAGE.md | 1 + fixtures/express/fixed-expiry-session/app.ts | 15 ++ .../fixed-expiry-session/expected.json | 9 + .../sliding-expiry-with-rotation/app.ts | 19 ++ .../expected.json | 9 + .../sliding-expiry-without-rotation/app.ts | 16 ++ .../expected.json | 9 + 11 files changed, 382 insertions(+) create mode 100644 fixtures/express/fixed-expiry-session/app.ts create mode 100644 fixtures/express/fixed-expiry-session/expected.json create mode 100644 fixtures/express/sliding-expiry-with-rotation/app.ts create mode 100644 fixtures/express/sliding-expiry-with-rotation/expected.json create mode 100644 fixtures/express/sliding-expiry-without-rotation/app.ts create mode 100644 fixtures/express/sliding-expiry-without-rotation/expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6bdc5..41b2305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and SessionScope uses semantic versioning as described in - 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. - Added `jwt_denylist_absent_on_logout_review` lifecycle coverage for access-JWT logout flows that lack linked denylist, blocklist, or token revocation-store evidence. - Added `refresh_family_revocation_absent_on_logout_review` lifecycle coverage for refresh-token logout flows that lack linked family/user-scoped revocation evidence. +- Added `sliding_expiry_without_rotation_review` lifecycle coverage for rolling/sliding session expiry that lacks linked session or refresh-token rotation evidence. - 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-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index fc8cfb0..dbd90cf 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -62,6 +62,12 @@ pub fn classify(report: &ScanReport) -> Vec { report, &evidence_by_id, )); + findings.extend(classify_sliding_expiry_without_rotation( + artifact, + path, + report, + &evidence_by_id, + )); } findings @@ -706,6 +712,48 @@ fn classify_refresh_family_revocation_absent_on_logout( )) } +fn classify_sliding_expiry_without_rotation( + artifact: &Artifact, + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Option { + if !matches!( + artifact.artifact_type, + ArtifactType::SessionCookie + | ArtifactType::SignedCookie + | ArtifactType::SessionRecord + | ArtifactType::RefreshJwt + | ArtifactType::Unknown + ) || !has_sliding_expiry_evidence(path, evidence_by_id) + || has_linked_rotation_evidence(path, report, evidence_by_id) + { + return None; + } + + let evidence_ids = sliding_expiry_ids(path, evidence_by_id); + let name = artifact.display_name.as_deref().unwrap_or("session"); + Some(finding( + artifact, + path, + FindingSpec { + rule_id: "sliding_expiry_without_rotation_review", + category: FindingCategory::LifecycleGap, + severity: Severity::Low, + evidence_ids, + title: format!("Session `{name}` uses sliding expiry without linked rotation"), + description: "Sliding or rolling TTL/Max-Age evidence was detected, but no linked session regeneration, session-key cycling, refresh-token rotation, or reissue evidence was found for the same lifecycle path." + .to_string(), + suggested_fix: + "Pair sliding expiry with session or refresh-token rotation, or document the framework-managed rotation behavior for reviewer confirmation." + .to_string(), + reviewer_question: format!( + "Where is `{name}` rotated when its idle/sliding expiry is extended?" + ), + }, + )) +} + fn attribute_missing_or_mismatched( observation: &sessionscope_model::CookieAttributeObservation, attribute: &str, @@ -966,6 +1014,85 @@ fn is_refresh_family_revoke_evidence(evidence: &Evidence) -> bool { .is_some_and(|excerpt| contains_refresh_family_revoke_text(excerpt.as_str()))) } +fn has_sliding_expiry_evidence( + path: &LifecyclePath, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + !sliding_expiry_ids(path, evidence_by_id).is_empty() +} + +fn sliding_expiry_ids( + path: &LifecyclePath, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Vec { + fallback_path_ids(path) + .into_iter() + .filter(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| is_sliding_expiry_evidence(evidence)) + }) + .collect() +} + +fn is_sliding_expiry_evidence(evidence: &Evidence) -> bool { + matches!( + evidence.detector_id.as_str(), + "session.middleware" | "refresh.expire" | "refresh.store" + ) && evidence + .excerpt + .as_ref() + .is_some_and(|excerpt| contains_sliding_expiry_text(excerpt.as_str())) +} + +fn contains_sliding_expiry_text(value: &str) -> bool { + let normalized = value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::() + .to_ascii_lowercase(); + (normalized.contains("rolling") + || normalized.contains("sliding") + || normalized.contains("idle") + || normalized.contains("touch") + || normalized.contains("refreshsessionttl") + || normalized.contains("extend_session") + || normalized.contains("extendsession")) + && (normalized.contains("maxage") + || normalized.contains("ttl") + || normalized.contains("expires") + || normalized.contains("expiresat") + || normalized.contains("expire")) +} + +fn has_linked_rotation_evidence( + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + evidence_ids_for_stage(path, LifecycleStage::Refresh) + .iter() + .any(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| is_rotation_evidence(evidence)) + }) + || report.evidence.iter().any(|evidence| { + is_rotation_evidence(evidence) + && evidence_linked_to_path_context(evidence, path, evidence_by_id) + }) +} + +fn is_rotation_evidence(evidence: &Evidence) -> bool { + matches!( + evidence.detector_id.as_str(), + "session.regenerate" + | "session.reissue" + | "session.framework_default_regenerate" + | "refresh.rotate" + ) +} + fn contains_refresh_family_revoke_text(value: &str) -> bool { let normalized = value .chars() @@ -2609,6 +2736,113 @@ mod tests { ); } + #[test] + fn sliding_expiry_without_rotation_is_lifecycle_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_session", + ArtifactType::SessionRecord, + "session", + LifecycleEvidence { + store: vec![EvidenceId("evidence_sliding".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![evidence_with_excerpt( + "evidence_sliding", + LifecycleStage::Store, + "session.middleware", + 10, + "session({ rolling: true, cookie: { maxAge: 900000 } })", + )], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + let finding = findings + .iter() + .find(|finding| finding.title.contains("sliding expiry")) + .expect("sliding expiry review finding"); + + assert_eq!(finding.category, FindingCategory::LifecycleGap); + assert_eq!(finding.severity, Severity::Low); + assert!(finding.reviewer_question.is_some()); + } + + #[test] + fn linked_session_rotation_prevents_sliding_expiry_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_session", + ArtifactType::SessionRecord, + "session", + LifecycleEvidence { + store: vec![EvidenceId("evidence_sliding".to_string())], + refresh: vec![EvidenceId("evidence_rotate".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence_with_excerpt( + "evidence_sliding", + LifecycleStage::Store, + "session.middleware", + 10, + "session({ rolling: true, cookie: { maxAge: 900000 } })", + ), + evidence_with_detector( + "evidence_rotate", + LifecycleStage::Refresh, + "session.regenerate", + 20, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + !findings + .iter() + .any(|finding| finding.title.contains("sliding expiry")), + "{findings:?}" + ); + } + + #[test] + fn fixed_expiry_session_does_not_trigger_sliding_review() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_session", + ArtifactType::SessionRecord, + "session", + LifecycleEvidence { + store: vec![EvidenceId("evidence_store".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![evidence_with_excerpt( + "evidence_store", + LifecycleStage::Store, + "session.middleware", + 10, + "session({ cookie: { maxAge: 900000 } })", + )], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + !findings + .iter() + .any(|finding| finding.title.contains("sliding expiry")), + "{findings:?}" + ); + } + #[test] fn sort_paths_orders_paths_by_artifact() { let mut paths = vec![ diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 89ae645..8720447 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -1138,6 +1138,74 @@ mod tests { ); } + #[test] + fn sliding_expiry_fixture_produces_rotation_review_gap() { + let root = fixture_root() + .join("express") + .join("sliding-expiry-without-rotation"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("sliding expiry fixture should scan"), + ); + + assert!(report.findings.iter().any(|finding| { + finding.category == FindingCategory::LifecycleGap + && finding.title.contains("sliding expiry") + && finding.reviewer_question.is_some() + })); + } + + #[test] + fn sliding_expiry_with_rotation_fixture_stays_clean_for_rotation_gap() { + let root = fixture_root() + .join("express") + .join("sliding-expiry-with-rotation"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("sliding expiry with rotation fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Refresh + && evidence.detector_id == "session.regenerate" + })); + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("sliding expiry")), + "{:?}", + report.findings + ); + } + + #[test] + fn fixed_expiry_fixture_stays_clean_for_sliding_review() { + let root = fixture_root().join("express").join("fixed-expiry-session"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("fixed expiry fixture should scan"), + ); + + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("sliding expiry")), + "{:?}", + report.findings + ); + } + #[test] fn unrelated_refresh_fixtures_do_not_satisfy_each_other() { let root = fixture_root().join("express"); diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index 5db16ce..aaf82c6 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -30,6 +30,7 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m | `cookie_dynamic_path_scope` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **review-required:** dynamic Path option | transmit | `dynamic_review_required` | `dynamic_review_required` | Runtime path scope requires review. | | `cookie_dynamic_expiry` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **review-required:** dynamic Max-Age or Expires | expire | `dynamic_review_required` | `dynamic_review_required` | Runtime lifetime requires review. | | `cookie_missing_expiry` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **supported:** session-like/signed cookie with no explicit expiry | expire | `lifecycle_gap` | `lifecycle_gap` | Explicit lifetime evidence is missing. | +| `sliding_expiry_without_rotation_review` | JS/TS, Python | Express, Django/session helpers, generic JS/TS/Python session or refresh helpers | Session middleware, session regeneration, refresh TTL helpers | **review-required:** rolling/sliding/idle TTL or Max-Age extension evidence without linked `session.regenerate`, `cycle_key`, session reissue, or refresh-token rotation; **not covered:** runtime-only framework settings outside scanned source | expire/refresh | `lifecycle_gap` | `lifecycle_gap` | Sliding expiry should be paired with session or refresh-token rotation evidence. | | `cookie_host_prefix_path_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with Path missing or not `/`; **review-required:** dynamic Path | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must set `Path=/`. | | `cookie_host_prefix_domain_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with any Domain; **review-required:** dynamic Domain | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must omit Domain and remain host-only. | | `cookie_host_prefix_secure_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with missing/false Secure; **review-required:** dynamic/default Secure | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must be Secure. | diff --git a/docs/USAGE.md b/docs/USAGE.md index 99c3e18..bca8389 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -129,6 +129,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - 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; `document.cookie` token writes are emitted as source evidence only in this phase. - Tokens issued without explicit expiry +- Sliding/idle expiry without linked rotation evidence, including `sliding_expiry_without_rotation_review` - Refresh tokens without rotation evidence - Logout without revocation evidence, including `jwt_denylist_absent_on_logout_review` when access-JWT logout flows lack linked denylist/blocklist/revocation-store evidence and `refresh_family_revocation_absent_on_logout_review` when refresh-token logout flows lack family/user-scoped revocation evidence - Password-reset tokens without expiry or single-use evidence diff --git a/fixtures/express/fixed-expiry-session/app.ts b/fixtures/express/fixed-expiry-session/app.ts new file mode 100644 index 0000000..4dde900 --- /dev/null +++ b/fixtures/express/fixed-expiry-session/app.ts @@ -0,0 +1,15 @@ +import express from "express"; +import session from "express-session"; + +const app = express(); + +app.use(session({ + secret: "PLACEHOLDER_SECRET_DO_NOT_USE", + resave: false, + saveUninitialized: false, + cookie: { httpOnly: true, secure: true, maxAge: 900000 }, +})); + +app.get("/account", (_request, response) => { + response.json({ ok: true }); +}); diff --git a/fixtures/express/fixed-expiry-session/expected.json b/fixtures/express/fixed-expiry-session/expected.json new file mode 100644 index 0000000..cc51ea2 --- /dev/null +++ b/fixtures/express/fixed-expiry-session/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "express.fixed-expiry-session", + "framework": "express", + "source_files": ["app.ts"], + "expected_artifacts": ["session"], + "expected_lifecycle_stages": ["store"], + "expected_findings": ["fixed_expiry_session_no_sliding_review"], + "notes": "Covers fixed-expiry Express session middleware that should not trigger sliding-expiry review." +} diff --git a/fixtures/express/sliding-expiry-with-rotation/app.ts b/fixtures/express/sliding-expiry-with-rotation/app.ts new file mode 100644 index 0000000..dacbdd6 --- /dev/null +++ b/fixtures/express/sliding-expiry-with-rotation/app.ts @@ -0,0 +1,19 @@ +import express from "express"; +import session from "express-session"; + +const app = express(); + +app.use(session({ + secret: "PLACEHOLDER_SECRET_DO_NOT_USE", + resave: true, + saveUninitialized: false, + rolling: true, + cookie: { httpOnly: true, secure: true, maxAge: 900000 }, +})); + +app.post("/login", (request, response) => { + request.session.regenerate(() => { + request.session.userId = "placeholder-user"; + response.json({ ok: true }); + }); +}); diff --git a/fixtures/express/sliding-expiry-with-rotation/expected.json b/fixtures/express/sliding-expiry-with-rotation/expected.json new file mode 100644 index 0000000..517bd1f --- /dev/null +++ b/fixtures/express/sliding-expiry-with-rotation/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "express.sliding-expiry-with-rotation", + "framework": "express", + "source_files": ["app.ts"], + "expected_artifacts": ["session"], + "expected_lifecycle_stages": ["store", "refresh"], + "expected_findings": ["sliding_expiry_rotation_evidence_present"], + "notes": "Covers rolling session middleware with visible session regeneration evidence." +} diff --git a/fixtures/express/sliding-expiry-without-rotation/app.ts b/fixtures/express/sliding-expiry-without-rotation/app.ts new file mode 100644 index 0000000..0beda26 --- /dev/null +++ b/fixtures/express/sliding-expiry-without-rotation/app.ts @@ -0,0 +1,16 @@ +import express from "express"; +import session from "express-session"; + +const app = express(); + +app.use(session({ + secret: "PLACEHOLDER_SECRET_DO_NOT_USE", + resave: true, + saveUninitialized: false, + rolling: true, + cookie: { httpOnly: true, secure: true, maxAge: 900000 }, +})); + +app.get("/account", (request, response) => { + response.json({ ok: true }); +}); diff --git a/fixtures/express/sliding-expiry-without-rotation/expected.json b/fixtures/express/sliding-expiry-without-rotation/expected.json new file mode 100644 index 0000000..bc5939d --- /dev/null +++ b/fixtures/express/sliding-expiry-without-rotation/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "express.sliding-expiry-without-rotation", + "framework": "express", + "source_files": ["app.ts"], + "expected_artifacts": ["session"], + "expected_lifecycle_stages": ["store"], + "expected_findings": ["sliding_expiry_without_rotation_review"], + "notes": "Covers Express rolling session middleware that extends Max-Age on each request without visible session rotation evidence." +} From 4ecbc6879b76155c3ed18eff054e46b4e47840cd Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:26:18 -0500 Subject: [PATCH 33/41] =?UTF-8?q?Fix=20#90:=20P4.4=20Classifier=20?= =?UTF-8?q?=E2=80=94=20password-change=20without=20global=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + .../sessionscope-classifier/src/lifecycle.rs | 153 ++++++++++++++++++ .../src/sessions/mod.rs | 112 ++++++++++++- crates/sessionscope-testing/src/fixtures.rs | 82 ++++++++++ docs/COVERAGE_MATRIX.md | 1 + docs/USAGE.md | 1 + .../expected.json | 9 ++ .../password-change-global-revoke/views.py | 6 + .../expected.json | 9 ++ .../views.py | 4 + .../password-validation-utility/app.py | 2 + .../password-validation-utility/expected.json | 9 ++ 12 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 fixtures/django/password-change-global-revoke/expected.json create mode 100644 fixtures/django/password-change-global-revoke/views.py create mode 100644 fixtures/django/password-change-without-global-revoke/expected.json create mode 100644 fixtures/django/password-change-without-global-revoke/views.py create mode 100644 fixtures/generic-python/password-validation-utility/app.py create mode 100644 fixtures/generic-python/password-validation-utility/expected.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b2305..af6f8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and SessionScope uses semantic versioning as described in - Added `jwt_denylist_absent_on_logout_review` lifecycle coverage for access-JWT logout flows that lack linked denylist, blocklist, or token revocation-store evidence. - Added `refresh_family_revocation_absent_on_logout_review` lifecycle coverage for refresh-token logout flows that lack linked family/user-scoped revocation evidence. - Added `sliding_expiry_without_rotation_review` lifecycle coverage for rolling/sliding session expiry that lacks linked session or refresh-token rotation evidence. +- Added `password_change_global_revocation_absent_review` lifecycle coverage for password-change handlers that lack linked global session invalidation, refresh-family revocation, or token-version bump evidence. - 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-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index dbd90cf..381ff87 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -68,6 +68,12 @@ pub fn classify(report: &ScanReport) -> Vec { report, &evidence_by_id, )); + findings.extend(classify_password_change_global_revocation_absent( + artifact, + path, + report, + &evidence_by_id, + )); } findings @@ -754,6 +760,44 @@ fn classify_sliding_expiry_without_rotation( )) } +fn classify_password_change_global_revocation_absent( + artifact: &Artifact, + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Option { + let handler_ids = password_change_handler_ids(path, evidence_by_id); + if handler_ids.is_empty() + || has_linked_password_change_global_revoke(path, report, evidence_by_id) + { + return None; + } + + let mut evidence_ids = handler_ids; + evidence_ids.sort(); + evidence_ids.dedup(); + + Some(finding( + artifact, + path, + FindingSpec { + rule_id: "password_change_global_revocation_absent_review", + category: FindingCategory::LifecycleGap, + severity: Severity::Medium, + evidence_ids, + title: "Password-change handler lacks linked global session revocation".to_string(), + description: "A password-change handler was detected, but no linked global session invalidation, refresh-family revocation, or token-version bump evidence was found in the same source scope." + .to_string(), + suggested_fix: + "After password changes, revoke all active sessions/refresh-token families or bump a token/session version checked during authentication." + .to_string(), + reviewer_question: + "Where are existing sessions and refresh-token families invalidated after this password change?" + .to_string(), + }, + )) +} + fn attribute_missing_or_mismatched( observation: &sessionscope_model::CookieAttributeObservation, attribute: &str, @@ -1083,6 +1127,41 @@ fn has_linked_rotation_evidence( }) } +fn password_change_handler_ids( + path: &LifecyclePath, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> Vec { + fallback_path_ids(path) + .into_iter() + .filter(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| evidence.detector_id == "password_change.handler") + }) + .collect() +} + +fn has_linked_password_change_global_revoke( + path: &LifecyclePath, + report: &ScanReport, + evidence_by_id: &BTreeMap<&str, &Evidence>, +) -> bool { + fallback_path_ids(path).iter().any(|evidence_id| { + evidence_by_id + .get(evidence_id.0.as_str()) + .is_some_and(|evidence| is_password_change_global_revoke_evidence(evidence)) + }) || report.evidence.iter().any(|evidence| { + is_password_change_global_revoke_evidence(evidence) + && evidence_linked_to_path_context(evidence, path, evidence_by_id) + }) +} + +fn is_password_change_global_revoke_evidence(evidence: &Evidence) -> bool { + evidence.detector_id == "password_change.global_revoke" + || evidence.detector_id == "logout.session_destroy" + || is_refresh_family_revoke_evidence(evidence) +} + fn is_rotation_evidence(evidence: &Evidence) -> bool { matches!( evidence.detector_id.as_str(), @@ -2843,6 +2922,80 @@ mod tests { ); } + #[test] + fn password_change_without_global_revoke_is_lifecycle_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_password_change", + ArtifactType::Unknown, + "password_change", + LifecycleEvidence { + revoke: vec![EvidenceId("evidence_handler".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![evidence_with_detector( + "evidence_handler", + LifecycleStage::Revoke, + "password_change.handler", + 10, + false, + )], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + let finding = findings + .iter() + .find(|finding| finding.title.contains("Password-change handler")) + .expect("password-change global revocation review finding"); + + assert_eq!(finding.category, FindingCategory::LifecycleGap); + assert_eq!(finding.severity, Severity::Medium); + assert!(finding.reviewer_question.is_some()); + } + + #[test] + fn linked_global_revoke_prevents_password_change_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_password_change", + ArtifactType::Unknown, + "password_change", + LifecycleEvidence { + revoke: vec![EvidenceId("evidence_handler".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence_with_detector( + "evidence_handler", + LifecycleStage::Revoke, + "password_change.handler", + 10, + false, + ), + evidence_with_detector( + "evidence_global_revoke", + LifecycleStage::Revoke, + "password_change.global_revoke", + 12, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + !findings + .iter() + .any(|finding| finding.title.contains("Password-change handler")), + "{findings:?}" + ); + } + #[test] fn sort_paths_orders_paths_by_artifact() { let mut paths = vec![ diff --git a/crates/sessionscope-detectors/src/sessions/mod.rs b/crates/sessionscope-detectors/src/sessions/mod.rs index ed776d2..368ea95 100644 --- a/crates/sessionscope-detectors/src/sessions/mod.rs +++ b/crates/sessionscope-detectors/src/sessions/mod.rs @@ -562,7 +562,20 @@ fn collect_js_signals(node: Node<'_>, source: &str, signals: &mut Vec) { "function_declaration" | "method_definition" => { let name = js_function_name(node, source).unwrap_or_default(); let normalized = normalize_symbol(&name); - if is_auth_handler_context(&normalized) { + if is_password_change_handler_context(&normalized) { + signals.push(signal( + SignalSpec::revoke( + "password_change.handler", + ArtifactType::Unknown, + "password_change", + "javascript", + Confidence::Medium, + true, + ), + node, + source, + )); + } else if is_auth_handler_context(&normalized) { signals.push(session_fixation_signal( "session.auth_transition", LifecycleStage::Issue, @@ -603,7 +616,20 @@ fn collect_js_signals(node: Node<'_>, source: &str, signals: &mut Vec) { )); } let normalized = normalize_symbol_without_literals(&text); - if is_auth_handler_context(&normalized) { + if is_password_change_handler_context(&normalized) { + signals.push(signal( + SignalSpec::revoke( + "password_change.handler", + ArtifactType::Unknown, + "password_change", + "nextjs", + Confidence::Medium, + true, + ), + node, + source, + )); + } else if is_auth_handler_context(&normalized) { signals.push(session_fixation_signal( "session.auth_transition", LifecycleStage::Issue, @@ -796,6 +822,21 @@ fn collect_js_call_signal(node: Node<'_>, source: &str, signals: &mut Vec, source: &str, signals: &mut Vec, source: &str, signals: &mut Vec bool { || normalized.contains("destroysession") } +fn is_password_change_handler_context(normalized: &str) -> bool { + (normalized.contains("passwordchange") + || normalized.contains("password_change") + || normalized.contains("change_password") + || normalized.contains("changepassword") + || normalized.contains("updatepassword") + || normalized.contains("update_password") + || normalized.contains("password_update") + || normalized.contains("passwordupdate")) + && !normalized.contains("validate") + && !normalized.contains("validator") + && !normalized.contains("verify") + && !normalized.contains("check") + && !normalized.contains("strength") +} + +fn is_global_password_change_revocation_call(normalized: &str) -> bool { + (normalized.contains("revokeallsessions") + || normalized.contains("revoke_all_sessions") + || normalized.contains("invalidateallsessions") + || normalized.contains("invalidate_all_sessions") + || normalized.contains("bump token version") + || normalized.contains("bumptokenversion") + || normalized.contains("bump_token_version") + || normalized.contains("tokenversion") + || normalized.contains("token_version") + || normalized.contains("cycle_key") + || normalized.contains("cyclekey")) + || (normalized.contains("refresh") + && (normalized.contains("user") + || normalized.contains("family") + || normalized.contains("allsessions") + || normalized.contains("all_sessions")) + && (normalized.contains("revoke") + || normalized.contains("delete") + || normalized.contains("invalidate") + || normalized.contains("destroy"))) +} + fn is_js_provider_revoke_call(normalized: &str) -> bool { is_provider_revoke_text(normalized) } diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 8720447..5b89f02 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -1206,6 +1206,88 @@ mod tests { ); } + #[test] + fn password_change_without_global_revoke_fixture_produces_review_gap() { + let root = fixture_root() + .join("django") + .join("password-change-without-global-revoke"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("password-change without global revoke fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Revoke + && evidence.detector_id == "password_change.handler" + })); + assert!(report.findings.iter().any(|finding| { + finding.category == FindingCategory::LifecycleGap + && finding.title.contains("Password-change handler") + && finding.reviewer_question.is_some() + })); + } + + #[test] + fn password_change_global_revoke_fixture_stays_clean_for_global_gap() { + let root = fixture_root() + .join("django") + .join("password-change-global-revoke"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("password-change global revoke fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Revoke + && evidence.detector_id == "password_change.global_revoke" + })); + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("Password-change handler")), + "{:?}", + report.findings + ); + } + + #[test] + fn password_validation_utility_fixture_does_not_emit_password_change_review() { + let root = fixture_root() + .join("generic-python") + .join("password-validation-utility"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("password validation utility fixture should scan"), + ); + + assert!( + !report + .evidence + .iter() + .any(|evidence| evidence.detector_id == "password_change.handler"), + "{:?}", + report.evidence + ); + assert!( + !report + .findings + .iter() + .any(|finding| finding.title.contains("Password-change handler")), + "{:?}", + report.findings + ); + } + #[test] fn unrelated_refresh_fixtures_do_not_satisfy_each_other() { let root = fixture_root().join("express"); diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index aaf82c6..a3ddebb 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -75,6 +75,7 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m | `bearer_issue_without_expiry` | JS/TS, Python | Generic JS/TS/Python | Bearer/API-key issue helpers | **supported:** issued opaque token without expiry | expire | `lifecycle_gap` | `lifecycle_gap` | Token issue flow lacks expiry evidence. | | `bearer_missing_rotation_or_revocation` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Bearer/API-key lifecycle helpers | **supported:** refresh/reuse lifecycle without revoke/rotate evidence | revoke | `lifecycle_gap` | `lifecycle_gap` | Token lifecycle lacks rotation or revocation evidence. | | `refresh_family_revocation_absent_on_logout_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Refresh-token lifecycle helpers plus logout handlers | **review-required:** refresh-token lifecycle evidence linked by source context to logout evidence with no visible family/user-scoped refresh revocation such as `revokeRefreshFamily`, `delete refresh_tokens where user_id`, or refresh-family cache deletion; **not covered:** provider dashboard revocation outside source | revoke | `lifecycle_gap` | `lifecycle_gap` | Logout should revoke the user's refresh-token family, not only clear a browser cookie. | +| `password_change_global_revocation_absent_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Password-change handlers plus session/refresh revocation helpers | **review-required:** password-change handler evidence without linked global session invalidation, refresh-family revocation, or token-version bump; **not covered:** standalone password validators or runtime-only identity-provider policy | revoke | `lifecycle_gap` | `lifecycle_gap` | Password changes should invalidate existing sessions and refresh families. | | `bearer_missing_scope_evidence` | JS/TS, Python | Generic JS/TS/Python, provider fixtures | Provider/token-boundary APIs | **supported:** token lacks scope/boundary evidence | validate | `missing_validation_evidence` | `missing_validation_evidence` | Scope or boundary evidence is missing. | | `bearer_dynamic_provider_review` | JS/TS, Python | Next.js, Express, generic JS/TS/Python | Auth0, Okta, Cognito, Azure AD, Firebase, Supabase, Clerk, Passport, NextAuth/AuthJS, OAuth/OIDC generic | **review-required:** provider-managed token behavior | refresh/revoke | `dynamic_review_required` | `dynamic_review_required` | Provider-managed lifecycle behavior needs human review. | | `query_param_token_acceptance_review` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic JS/TS/Python | Request/query APIs | **review-required:** dynamic query token acceptance | transmit | `dynamic_review_required` | `dynamic_review_required` | Query token acceptance needs review. | diff --git a/docs/USAGE.md b/docs/USAGE.md index bca8389..a867a7c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -132,6 +132,7 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - Sliding/idle expiry without linked rotation evidence, including `sliding_expiry_without_rotation_review` - Refresh tokens without rotation evidence - Logout without revocation evidence, including `jwt_denylist_absent_on_logout_review` when access-JWT logout flows lack linked denylist/blocklist/revocation-store evidence and `refresh_family_revocation_absent_on_logout_review` when refresh-token logout flows lack family/user-scoped revocation evidence +- Password-change without global session or refresh-family revocation evidence, including `password_change_global_revocation_absent_review` - Password-reset tokens without expiry or single-use evidence - Session fixation risk signals - Token accepted from query parameters diff --git a/fixtures/django/password-change-global-revoke/expected.json b/fixtures/django/password-change-global-revoke/expected.json new file mode 100644 index 0000000..918fa8f --- /dev/null +++ b/fixtures/django/password-change-global-revoke/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "django.password-change-global-revoke", + "framework": "django", + "source_files": ["views.py"], + "expected_artifacts": ["password_change", "session"], + "expected_lifecycle_stages": ["revoke"], + "expected_findings": ["password_change_global_revocation_evidence_present"], + "notes": "Covers a password-change handler with visible global session invalidation and token-version bump evidence." +} diff --git a/fixtures/django/password-change-global-revoke/views.py b/fixtures/django/password-change-global-revoke/views.py new file mode 100644 index 0000000..4458b63 --- /dev/null +++ b/fixtures/django/password-change-global-revoke/views.py @@ -0,0 +1,6 @@ +def password_change_complete(request): + request.user.set_password(request.POST["new_password"]) + request.user.save() + revoke_all_sessions(request.user.pk) + bump_token_version(request.user.pk) + return {"ok": True} diff --git a/fixtures/django/password-change-without-global-revoke/expected.json b/fixtures/django/password-change-without-global-revoke/expected.json new file mode 100644 index 0000000..9a36030 --- /dev/null +++ b/fixtures/django/password-change-without-global-revoke/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "django.password-change-without-global-revoke", + "framework": "django", + "source_files": ["views.py"], + "expected_artifacts": ["password_change"], + "expected_lifecycle_stages": ["revoke"], + "expected_findings": ["password_change_global_revocation_absent_review"], + "notes": "Covers a password-change handler that updates the password hash without global session, refresh-family, or token-version revocation evidence." +} diff --git a/fixtures/django/password-change-without-global-revoke/views.py b/fixtures/django/password-change-without-global-revoke/views.py new file mode 100644 index 0000000..7fd02cc --- /dev/null +++ b/fixtures/django/password-change-without-global-revoke/views.py @@ -0,0 +1,4 @@ +def password_change_complete(request): + request.user.set_password(request.POST["new_password"]) + request.user.save() + return {"ok": True} diff --git a/fixtures/generic-python/password-validation-utility/app.py b/fixtures/generic-python/password-validation-utility/app.py new file mode 100644 index 0000000..12aaa0c --- /dev/null +++ b/fixtures/generic-python/password-validation-utility/app.py @@ -0,0 +1,2 @@ +def validate_password_strength(candidate: str) -> bool: + return len(candidate) >= 12 and any(ch.isdigit() for ch in candidate) diff --git a/fixtures/generic-python/password-validation-utility/expected.json b/fixtures/generic-python/password-validation-utility/expected.json new file mode 100644 index 0000000..744767e --- /dev/null +++ b/fixtures/generic-python/password-validation-utility/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "generic-python.password-validation-utility", + "framework": "generic-python", + "source_files": ["app.py"], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": ["password_validation_utility_no_password_change_review"], + "notes": "Covers a standalone password-validation helper that should not be treated as a password-change handler." +} From 40618f153e75d0078cc186a8b476fffdfa0b890a Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:28:16 -0500 Subject: [PATCH 34/41] =?UTF-8?q?Fix=20#91:=20P4.5=20Fixtures=20=E2=80=94?= =?UTF-8?q?=20false-positive=20coverage=20for=20new=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + crates/sessionscope-testing/src/fixtures.rs | 97 +++++++++++++++++-- .../expected.json | 11 +++ .../clean-baseline-password-refresh/views.py | 2 + .../express/clean-baseline-lifecycle/app.ts | 7 ++ .../clean-baseline-lifecycle/expected.json | 11 +++ .../fastapi/clean-baseline-lifecycle/app.py | 7 ++ .../clean-baseline-lifecycle/expected.json | 11 +++ fixtures/generic-js/clean-baseline-jwt/app.js | 3 + .../clean-baseline-jwt/expected.json | 11 +++ .../clean-baseline-jwt-refresh/app.py | 2 + .../clean-baseline-jwt-refresh/expected.json | 11 +++ .../clean-baseline-jwt-oauth/app.ts | 3 + .../clean-baseline-jwt-oauth/expected.json | 11 +++ .../expected.json | 11 +++ .../clean-baseline-oauth-storage/route.ts | 3 + tests/README.md | 7 ++ 17 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 fixtures/django/clean-baseline-password-refresh/expected.json create mode 100644 fixtures/django/clean-baseline-password-refresh/views.py create mode 100644 fixtures/express/clean-baseline-lifecycle/app.ts create mode 100644 fixtures/express/clean-baseline-lifecycle/expected.json create mode 100644 fixtures/fastapi/clean-baseline-lifecycle/app.py create mode 100644 fixtures/fastapi/clean-baseline-lifecycle/expected.json create mode 100644 fixtures/generic-js/clean-baseline-jwt/app.js create mode 100644 fixtures/generic-js/clean-baseline-jwt/expected.json create mode 100644 fixtures/generic-python/clean-baseline-jwt-refresh/app.py create mode 100644 fixtures/generic-python/clean-baseline-jwt-refresh/expected.json create mode 100644 fixtures/generic-ts/clean-baseline-jwt-oauth/app.ts create mode 100644 fixtures/generic-ts/clean-baseline-jwt-oauth/expected.json create mode 100644 fixtures/nextjs/clean-baseline-oauth-storage/expected.json create mode 100644 fixtures/nextjs/clean-baseline-oauth-storage/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index af6f8e8..eac56db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and SessionScope uses semantic versioning as described in - Added `refresh_family_revocation_absent_on_logout_review` lifecycle coverage for refresh-token logout flows that lack linked family/user-scoped revocation evidence. - Added `sliding_expiry_without_rotation_review` lifecycle coverage for rolling/sliding session expiry that lacks linked session or refresh-token rotation evidence. - Added `password_change_global_revocation_absent_review` lifecycle coverage for password-change handlers that lack linked global session invalidation, refresh-family revocation, or token-version bump evidence. +- Added clean-baseline false-positive fixtures across Express, Next.js, FastAPI, Django, generic JS/TS, and generic Python to guard every v0.2 P1-P4 check ID. - 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-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 5b89f02..21b83b2 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -132,13 +132,15 @@ mod tests { assert!(!case.expected.framework.is_empty()); assert!(!case.expected.notes.is_empty()); assert!(!case.expected.source_files.is_empty()); - assert!( - !case.expected.expected_artifacts.is_empty() - || !case.expected.expected_lifecycle_stages.is_empty() - || !case.expected.expected_findings.is_empty(), - "{} should include at least one expectation", - case.expected.fixture_id - ); + if !case.expected.fixture_id.contains("clean-baseline") { + assert!( + !case.expected.expected_artifacts.is_empty() + || !case.expected.expected_lifecycle_stages.is_empty() + || !case.expected.expected_findings.is_empty(), + "{} should include at least one expectation", + case.expected.fixture_id + ); + } for source_file in &case.expected.source_files { assert!( @@ -762,6 +764,87 @@ mod tests { } } + #[test] + fn clean_baseline_fixtures_have_no_findings() { + const NEW_P1_P4_CHECK_IDS: &[&str] = &[ + "cookie_host_prefix_path_violation", + "cookie_host_prefix_domain_violation", + "cookie_host_prefix_secure_violation", + "cookie_secure_prefix_secure_violation", + "cookie_samesite_none_without_secure", + "cookie_partitioned_review", + "cookie_domain_leak_review", + "cookie_conflicting_writes_review", + "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", + "jwt_kid_unvalidated_review", + "oauth_pkce_missing_review", + "oauth_state_missing", + "oauth_state_static_review", + "oauth_state_unverified_review", + "oidc_nonce_missing", + "oidc_nonce_unverified_review", + "oauth_redirect_uri_wildcard_review", + "token_in_local_storage", + "token_in_session_storage", + "token_in_url_path_or_fragment", + "client_secret_in_browser_code", + "jwt_denylist_absent_on_logout_review", + "refresh_family_revocation_absent_on_logout_review", + "sliding_expiry_without_rotation_review", + "password_change_global_revocation_absent_review", + ]; + let roots = [ + fixture_root() + .join("express") + .join("clean-baseline-lifecycle"), + fixture_root() + .join("nextjs") + .join("clean-baseline-oauth-storage"), + fixture_root() + .join("fastapi") + .join("clean-baseline-lifecycle"), + fixture_root() + .join("django") + .join("clean-baseline-password-refresh"), + fixture_root().join("generic-js").join("clean-baseline-jwt"), + fixture_root() + .join("generic-ts") + .join("clean-baseline-jwt-oauth"), + fixture_root() + .join("generic-python") + .join("clean-baseline-jwt-refresh"), + ]; + + assert!( + NEW_P1_P4_CHECK_IDS.len() >= 31, + "new check inventory should stay explicit" + ); + + for root in roots { + 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.is_empty(), + "{} should not produce any findings, including {:?}; got {:?}", + root.display(), + NEW_P1_P4_CHECK_IDS, + report.findings + ); + } + } + #[test] fn logout_fixtures_emit_revoke_evidence() { let cases = [ diff --git a/fixtures/django/clean-baseline-password-refresh/expected.json b/fixtures/django/clean-baseline-password-refresh/expected.json new file mode 100644 index 0000000..6faf68c --- /dev/null +++ b/fixtures/django/clean-baseline-password-refresh/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "django.clean-baseline-password-refresh", + "framework": "django", + "source_files": [ + "views.py" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean Django baseline with no password-change or refresh findings." +} diff --git a/fixtures/django/clean-baseline-password-refresh/views.py b/fixtures/django/clean-baseline-password-refresh/views.py new file mode 100644 index 0000000..70eddd2 --- /dev/null +++ b/fixtures/django/clean-baseline-password-refresh/views.py @@ -0,0 +1,2 @@ +def health_view(request): + return {"ok": True} diff --git a/fixtures/express/clean-baseline-lifecycle/app.ts b/fixtures/express/clean-baseline-lifecycle/app.ts new file mode 100644 index 0000000..d274a34 --- /dev/null +++ b/fixtures/express/clean-baseline-lifecycle/app.ts @@ -0,0 +1,7 @@ +import express from "express"; + +const app = express(); + +app.get("/health", (_request, response) => { + response.json({ ok: true }); +}); diff --git a/fixtures/express/clean-baseline-lifecycle/expected.json b/fixtures/express/clean-baseline-lifecycle/expected.json new file mode 100644 index 0000000..001a214 --- /dev/null +++ b/fixtures/express/clean-baseline-lifecycle/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "express.clean-baseline-lifecycle", + "framework": "express", + "source_files": [ + "app.ts" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean Express baseline with no token/session lifecycle findings." +} diff --git a/fixtures/fastapi/clean-baseline-lifecycle/app.py b/fixtures/fastapi/clean-baseline-lifecycle/app.py new file mode 100644 index 0000000..1887d14 --- /dev/null +++ b/fixtures/fastapi/clean-baseline-lifecycle/app.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/health") +def health(): + return {"ok": True} diff --git a/fixtures/fastapi/clean-baseline-lifecycle/expected.json b/fixtures/fastapi/clean-baseline-lifecycle/expected.json new file mode 100644 index 0000000..d69ff16 --- /dev/null +++ b/fixtures/fastapi/clean-baseline-lifecycle/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "fastapi.clean-baseline-lifecycle", + "framework": "fastapi", + "source_files": [ + "app.py" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean FastAPI baseline with no token/session lifecycle findings." +} diff --git a/fixtures/generic-js/clean-baseline-jwt/app.js b/fixtures/generic-js/clean-baseline-jwt/app.js new file mode 100644 index 0000000..12cf541 --- /dev/null +++ b/fixtures/generic-js/clean-baseline-jwt/app.js @@ -0,0 +1,3 @@ +export function buildGreeting(name) { + return `hello ${name}`; +} diff --git a/fixtures/generic-js/clean-baseline-jwt/expected.json b/fixtures/generic-js/clean-baseline-jwt/expected.json new file mode 100644 index 0000000..163221e --- /dev/null +++ b/fixtures/generic-js/clean-baseline-jwt/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "generic-js.clean-baseline-jwt", + "framework": "generic-javascript", + "source_files": [ + "app.js" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean JavaScript baseline with no JWT or client-storage findings." +} diff --git a/fixtures/generic-python/clean-baseline-jwt-refresh/app.py b/fixtures/generic-python/clean-baseline-jwt-refresh/app.py new file mode 100644 index 0000000..5449413 --- /dev/null +++ b/fixtures/generic-python/clean-baseline-jwt-refresh/app.py @@ -0,0 +1,2 @@ +def build_greeting(name: str) -> str: + return f"hello {name}" diff --git a/fixtures/generic-python/clean-baseline-jwt-refresh/expected.json b/fixtures/generic-python/clean-baseline-jwt-refresh/expected.json new file mode 100644 index 0000000..481a73f --- /dev/null +++ b/fixtures/generic-python/clean-baseline-jwt-refresh/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "generic-python.clean-baseline-jwt-refresh", + "framework": "generic-python", + "source_files": [ + "app.py" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean Python baseline with no JWT, refresh, or password-change findings." +} diff --git a/fixtures/generic-ts/clean-baseline-jwt-oauth/app.ts b/fixtures/generic-ts/clean-baseline-jwt-oauth/app.ts new file mode 100644 index 0000000..16e8ab9 --- /dev/null +++ b/fixtures/generic-ts/clean-baseline-jwt-oauth/app.ts @@ -0,0 +1,3 @@ +export function buildGreeting(name: string): string { + return `hello ${name}`; +} diff --git a/fixtures/generic-ts/clean-baseline-jwt-oauth/expected.json b/fixtures/generic-ts/clean-baseline-jwt-oauth/expected.json new file mode 100644 index 0000000..5cd3ce6 --- /dev/null +++ b/fixtures/generic-ts/clean-baseline-jwt-oauth/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "generic-ts.clean-baseline-jwt-oauth", + "framework": "generic-typescript", + "source_files": [ + "app.ts" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean TypeScript baseline with no JWT, OAuth, or lifecycle findings." +} diff --git a/fixtures/nextjs/clean-baseline-oauth-storage/expected.json b/fixtures/nextjs/clean-baseline-oauth-storage/expected.json new file mode 100644 index 0000000..f718d2d --- /dev/null +++ b/fixtures/nextjs/clean-baseline-oauth-storage/expected.json @@ -0,0 +1,11 @@ +{ + "fixture_id": "nextjs.clean-baseline-oauth-storage", + "framework": "nextjs", + "source_files": [ + "route.ts" + ], + "expected_artifacts": [], + "expected_lifecycle_stages": [], + "expected_findings": [], + "notes": "Clean Next.js baseline with no OAuth or client-storage findings." +} diff --git a/fixtures/nextjs/clean-baseline-oauth-storage/route.ts b/fixtures/nextjs/clean-baseline-oauth-storage/route.ts new file mode 100644 index 0000000..9146308 --- /dev/null +++ b/fixtures/nextjs/clean-baseline-oauth-storage/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ ok: true }); +} diff --git a/tests/README.md b/tests/README.md index 3c50fb9..bc58bd0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,3 +4,10 @@ Workspace-level integration scenarios should live here when they need to scan fixture repositories or compare multiple output formats. Crate-local unit and CLI tests should stay next to their crate when possible. +## False-positive fixture contract + +Clean baseline fixtures live under `fixtures/*/clean-baseline-*`. Their +`expected_findings` arrays are intentionally empty, and the fixture harness +asserts that scanning each clean baseline produces no findings. These fixtures +provide the cumulative false-positive guard for every v0.2 P1-P4 check ID. + From 406c2dd23e4024440aeb14bfd757db69dd8bab9e Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:30:17 -0500 Subject: [PATCH 35/41] Fix #92: P4.6 JSON snapshot tests per framework family --- AGENTS.md | 7 + CHANGELOG.md | 1 + CONTRIBUTING.md | 6 + .../tests/json_snapshots.rs | 101 + tests/README.md | 10 + .../django-session-and-reset-flow.json | 900 ++++ .../express-cookie-session-lifecycle.json | 3901 +++++++++++++++++ .../fastapi-dependency-auth-lifecycle.json | 2357 ++++++++++ .../generic-js-jwt-crypto-trust-alg-none.json | 658 +++ .../generic-python-jwt-and-reset.json | 2227 ++++++++++ .../snapshots/generic-ts-jwt-validation.json | 1874 ++++++++ .../snapshots/nextjs-route-handler-auth.json | 2903 ++++++++++++ 12 files changed, 14945 insertions(+) create mode 100644 crates/sessionscope-testing/tests/json_snapshots.rs create mode 100644 tests/integration/snapshots/django-session-and-reset-flow.json create mode 100644 tests/integration/snapshots/express-cookie-session-lifecycle.json create mode 100644 tests/integration/snapshots/fastapi-dependency-auth-lifecycle.json create mode 100644 tests/integration/snapshots/generic-js-jwt-crypto-trust-alg-none.json create mode 100644 tests/integration/snapshots/generic-python-jwt-and-reset.json create mode 100644 tests/integration/snapshots/generic-ts-jwt-validation.json create mode 100644 tests/integration/snapshots/nextjs-route-handler-auth.json diff --git a/AGENTS.md b/AGENTS.md index 5a0018c..8eb414c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,13 @@ cargo check --workspace cargo test -q ``` +Regenerate committed JSON report snapshots after intentional report-output +changes: + +```bash +SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS=1 cargo test -p sessionscope-testing --test json_snapshots +``` + For broader local validation: ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index eac56db..5df913c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and SessionScope uses semantic versioning as described in - Added `sliding_expiry_without_rotation_review` lifecycle coverage for rolling/sliding session expiry that lacks linked session or refresh-token rotation evidence. - Added `password_change_global_revocation_absent_review` lifecycle coverage for password-change handlers that lack linked global session invalidation, refresh-family revocation, or token-version bump evidence. - Added clean-baseline false-positive fixtures across Express, Next.js, FastAPI, Django, generic JS/TS, and generic Python to guard every v0.2 P1-P4 check ID. +- Added hand-rolled JSON report snapshot tests for representative Express, Next.js, FastAPI, Django, generic JS, generic TS, and generic Python fixtures. - 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/CONTRIBUTING.md b/CONTRIBUTING.md index 4005454..02bcba7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,12 @@ cargo check --workspace cargo test --workspace --all-targets ``` +Regenerate committed JSON report snapshots after intentional output changes: + +```bash +SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS=1 cargo test -p sessionscope-testing --test json_snapshots +``` + Run the CLI during development: ```bash diff --git a/crates/sessionscope-testing/tests/json_snapshots.rs b/crates/sessionscope-testing/tests/json_snapshots.rs new file mode 100644 index 0000000..79a1050 --- /dev/null +++ b/crates/sessionscope-testing/tests/json_snapshots.rs @@ -0,0 +1,101 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use sessionscope_classifier::classify; +use sessionscope_core::{ScanConfig, scan_path}; +use sessionscope_detectors::DetectorRegistry; +use sessionscope_reporters::{ReportFormat, render}; +use sessionscope_testing::fixtures::fixture_root; +use sessionscope_testing::snapshots::normalize_snapshot_paths; + +const SNAPSHOT_CASES: &[SnapshotCase] = &[ + SnapshotCase { + name: "express-cookie-session-lifecycle", + fixture_segments: &["express", "cookie-session-lifecycle"], + }, + SnapshotCase { + name: "nextjs-route-handler-auth", + fixture_segments: &["nextjs", "route-handler-auth"], + }, + SnapshotCase { + name: "fastapi-dependency-auth-lifecycle", + fixture_segments: &["fastapi", "dependency-auth-lifecycle"], + }, + SnapshotCase { + name: "django-session-and-reset-flow", + fixture_segments: &["django", "session-and-reset-flow"], + }, + SnapshotCase { + name: "generic-js-jwt-crypto-trust-alg-none", + fixture_segments: &["generic-js", "jwt-crypto-trust-alg-none"], + }, + SnapshotCase { + name: "generic-ts-jwt-validation", + fixture_segments: &["generic-ts", "jwt-validation"], + }, + SnapshotCase { + name: "generic-python-jwt-and-reset", + fixture_segments: &["generic-python", "jwt-and-reset"], + }, +]; + +struct SnapshotCase { + name: &'static str, + fixture_segments: &'static [&'static str], +} + +#[test] +fn json_snapshots_match_representative_framework_fixtures() { + let update = std::env::var_os("SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS").is_some(); + let snapshot_root = snapshot_root(); + + for case in SNAPSHOT_CASES { + let rendered = render_snapshot(case); + let snapshot_path = snapshot_root.join(format!("{}.json", case.name)); + + if update { + fs::write(&snapshot_path, &rendered).unwrap_or_else(|error| { + panic!("failed to update {}: {error}", snapshot_path.display()) + }); + continue; + } + + let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|error| { + panic!( + "failed to read snapshot {}: {error}", + snapshot_path.display() + ) + }); + assert_eq!( + normalize_snapshot_paths(&expected), + normalize_snapshot_paths(&rendered), + "JSON snapshot mismatch for {}; regenerate with `SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS=1 cargo test -p sessionscope-testing --test json_snapshots`", + case.name + ); + } +} + +fn render_snapshot(case: &SnapshotCase) -> String { + let root = case + .fixture_segments + .iter() + .fold(fixture_root(), |path, segment| path.join(segment)); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .unwrap_or_else(|error| panic!("{} should scan: {error}", root.display())), + ); + render(&report, ReportFormat::Json) +} + +fn snapshot_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("integration") + .join("snapshots") +} diff --git a/tests/README.md b/tests/README.md index bc58bd0..81e8966 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,3 +11,13 @@ Clean baseline fixtures live under `fixtures/*/clean-baseline-*`. Their asserts that scanning each clean baseline produces no findings. These fixtures provide the cumulative false-positive guard for every v0.2 P1-P4 check ID. +## JSON snapshots + +Canonical JSON report snapshots live in `tests/integration/snapshots/` and are +checked by the hand-rolled `sessionscope-testing` integration test. Regenerate +them after intentional report-output changes with: + +```bash +SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS=1 cargo test -p sessionscope-testing --test json_snapshots +``` + diff --git a/tests/integration/snapshots/django-session-and-reset-flow.json b/tests/integration/snapshots/django-session-and-reset-flow.json new file mode 100644 index 0000000..4732d73 --- /dev/null +++ b/tests/integration/snapshots/django-session-and-reset-flow.json @@ -0,0 +1,900 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 3, + "files_scanned": 3, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "settings.py", + "language": "python", + "artifacts": [ + { + "id": "artifact_99ec026c78bdccbb", + "artifact_type": "session_cookie", + "display_name": "sessionid", + "locations": [ + { + "path": "settings.py", + "line": 2, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_99ec026c78bdccbb", + "evidence_ca1fe3d007a72505", + "evidence_ec9bef1d37b762fd" + ], + "transmit": [ + "evidence_1c3527e01b7878d2", + "evidence_1ec1adbc2e3731f8", + "evidence_b696d1f40628568a", + "evidence_c6eb4d0f4c5683ab" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_054287c83ffbc9d9", + "evidence_a720310d31ee0f13" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "django" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_ec9bef1d37b762fd" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_1c3527e01b7878d2" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "Lax", + "evidence_ids": [ + "evidence_1ec1adbc2e3731f8" + ], + "confidence": "high" + }, + "max_age": { + "state": "framework_default", + "value": "1209600", + "evidence_ids": [ + "evidence_054287c83ffbc9d9" + ], + "confidence": "low" + }, + "expires": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_a720310d31ee0f13" + ], + "confidence": "low" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_b696d1f40628568a" + ], + "confidence": "low" + }, + "domain": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_c6eb4d0f4c5683ab" + ], + "confidence": "low" + } + } + } + ], + "evidence": [ + { + "id": "evidence_ca1fe3d007a72505", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_99ec026c78bdccbb", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "Django SESSION_COOKIE_* settings", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c6eb4d0f4c5683ab", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "low", + "excerpt": "Domain defaults to none for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_b696d1f40628568a", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_a720310d31ee0f13", + "lifecycle_stage": "expire", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "low", + "excerpt": "Expires defaults to none for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_054287c83ffbc9d9", + "lifecycle_stage": "expire", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "low", + "excerpt": "Max-Age defaults to 1209600 for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ec9bef1d37b762fd", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 27 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "SESSION_COOKIE_HTTPONLY = True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1c3527e01b7878d2", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 3, + "column": 25 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "SESSION_COOKIE_SECURE = True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1ec1adbc2e3731f8", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 4, + "column": 27 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SESSION_COOKIE_SAMESITE = \"Lax\"", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "views.py", + "language": "python", + "artifacts": [ + { + "id": "artifact_2d4eceb282cb58d6", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "views.py", + "line": 18, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_4e76a3048eeb65c6" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_4dc9e49ac137a92c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "views.py", + "line": 19, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_08fb01f27b9526e6" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_c807d79fde47d60c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "views.py", + "line": 20, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_46e713f862bd7a7a" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "django" + ] + }, + { + "id": "artifact_e34a318309f55157", + "artifact_type": "session_cookie", + "display_name": "sessionid", + "locations": [ + { + "path": "views.py", + "line": 22, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_9d5c6bbd4b320590" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + } + ], + "evidence": [ + { + "id": "evidence_4e76a3048eeb65c6", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 18, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "def complete_logout(request):\n revoke_user_sessions(request.user.pk)\n logout(request)\n response = HttpResponse(status=204)\n response.delete_cookie(\"[REDACTED]\")\n return response", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_08fb01f27b9526e6", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 19, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "revoke_user_sessions(request.user.pk)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_46e713f862bd7a7a", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 20, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "logout(request)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9d5c6bbd4b320590", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 22, + "column": 5 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.delete_cookie(\"sessionid\")", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_99ec026c78bdccbb", + "artifact_type": "session_cookie", + "display_name": "sessionid", + "locations": [ + { + "path": "settings.py", + "line": 2, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_99ec026c78bdccbb", + "evidence_ca1fe3d007a72505", + "evidence_ec9bef1d37b762fd" + ], + "transmit": [ + "evidence_1c3527e01b7878d2", + "evidence_1ec1adbc2e3731f8", + "evidence_b696d1f40628568a", + "evidence_c6eb4d0f4c5683ab" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_054287c83ffbc9d9", + "evidence_a720310d31ee0f13" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "django" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_ec9bef1d37b762fd" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_1c3527e01b7878d2" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "Lax", + "evidence_ids": [ + "evidence_1ec1adbc2e3731f8" + ], + "confidence": "high" + }, + "max_age": { + "state": "framework_default", + "value": "1209600", + "evidence_ids": [ + "evidence_054287c83ffbc9d9" + ], + "confidence": "low" + }, + "expires": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_a720310d31ee0f13" + ], + "confidence": "low" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_b696d1f40628568a" + ], + "confidence": "low" + }, + "domain": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_c6eb4d0f4c5683ab" + ], + "confidence": "low" + } + } + }, + { + "id": "artifact_2d4eceb282cb58d6", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "views.py", + "line": 18, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_4e76a3048eeb65c6" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_4dc9e49ac137a92c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "views.py", + "line": 19, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_08fb01f27b9526e6" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_c807d79fde47d60c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "views.py", + "line": 20, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_46e713f862bd7a7a" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "django" + ] + }, + { + "id": "artifact_e34a318309f55157", + "artifact_type": "session_cookie", + "display_name": "sessionid", + "locations": [ + { + "path": "views.py", + "line": 22, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_9d5c6bbd4b320590" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + } + ], + "evidence": [ + { + "id": "evidence_ca1fe3d007a72505", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_99ec026c78bdccbb", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "Django SESSION_COOKIE_* settings", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c6eb4d0f4c5683ab", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "low", + "excerpt": "Domain defaults to none for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_b696d1f40628568a", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_a720310d31ee0f13", + "lifecycle_stage": "expire", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "low", + "excerpt": "Expires defaults to none for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_054287c83ffbc9d9", + "lifecycle_stage": "expire", + "location": { + "path": "settings.py", + "line": 2, + "column": 1 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "low", + "excerpt": "Max-Age defaults to 1209600 for django", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ec9bef1d37b762fd", + "lifecycle_stage": "store", + "location": { + "path": "settings.py", + "line": 2, + "column": 27 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "SESSION_COOKIE_HTTPONLY = True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1c3527e01b7878d2", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 3, + "column": 25 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "SESSION_COOKIE_SECURE = True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1ec1adbc2e3731f8", + "lifecycle_stage": "transmit", + "location": { + "path": "settings.py", + "line": 4, + "column": 27 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SESSION_COOKIE_SAMESITE = \"Lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4e76a3048eeb65c6", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 18, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "def complete_logout(request):\n revoke_user_sessions(request.user.pk)\n logout(request)\n response = HttpResponse(status=204)\n response.delete_cookie(\"[REDACTED]\")\n return response", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_08fb01f27b9526e6", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 19, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "revoke_user_sessions(request.user.pk)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_46e713f862bd7a7a", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 20, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "logout(request)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9d5c6bbd4b320590", + "lifecycle_stage": "revoke", + "location": { + "path": "views.py", + "line": 22, + "column": 5 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.delete_cookie(\"sessionid\")", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_e1461ac0bc618003", + "artifact_ids": [ + "artifact_2d4eceb282cb58d6" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_4e76a3048eeb65c6" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_981eddef51e17919", + "artifact_ids": [ + "artifact_4dc9e49ac137a92c", + "artifact_c807d79fde47d60c", + "artifact_e34a318309f55157" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_08fb01f27b9526e6", + "evidence_46e713f862bd7a7a", + "evidence_9d5c6bbd4b320590" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_1503d38d0577da92", + "artifact_ids": [ + "artifact_99ec026c78bdccbb" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_99ec026c78bdccbb", + "evidence_ca1fe3d007a72505", + "evidence_ec9bef1d37b762fd" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_1c3527e01b7878d2", + "evidence_1ec1adbc2e3731f8", + "evidence_b696d1f40628568a", + "evidence_c6eb4d0f4c5683ab" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_054287c83ffbc9d9", + "evidence_a720310d31ee0f13" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `sessionid`?" + } + ], + "findings": [] +} \ No newline at end of file diff --git a/tests/integration/snapshots/express-cookie-session-lifecycle.json b/tests/integration/snapshots/express-cookie-session-lifecycle.json new file mode 100644 index 0000000..1c27f4d --- /dev/null +++ b/tests/integration/snapshots/express-cookie-session-lifecycle.json @@ -0,0 +1,3901 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "app.ts", + "language": "type_script", + "artifacts": [ + { + "id": "artifact_6f837089594b74c4", + "artifact_type": "opaque_bearer_token", + "display_name": "access_token", + "locations": [ + { + "path": "app.ts", + "line": 6, + "column": 1 + }, + { + "path": "app.ts", + "line": 7, + "column": 9 + }, + { + "path": "app.ts", + "line": 8, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_5f28697326110c85" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_ad3b0634ef3c54d4", + "evidence_db7c57d295560814" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_64c56fd5a910792c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 6, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_4e63c7300962690f" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express", + "scope:111" + ] + }, + { + "id": "artifact_5a72289a03ddd485", + "artifact_type": "signed_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 8, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_5a72289a03ddd485", + "evidence_b275602954778885", + "evidence_bf3ab86dfecb4f83" + ], + "transmit": [ + "evidence_05cc4e424d4d22c6", + "evidence_60893ace05d1a945", + "evidence_b798deb3d44bb045", + "evidence_bfaf3eddb9c369eb" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_9fc5df1d137d1f72", + "evidence_e040efa573297113" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b275602954778885" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b798deb3d44bb045" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_60893ace05d1a945" + ], + "confidence": "high" + }, + "max_age": { + "state": "dynamic", + "value": "15 * 60 * 1000", + "evidence_ids": [ + "evidence_9fc5df1d137d1f72" + ], + "confidence": "medium" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_e040efa573297113" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_05cc4e424d4d22c6" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_bfaf3eddb9c369eb" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_7a9827578e77376e", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 17, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_fa34ee456988eedf" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express", + "scope:402" + ] + }, + { + "id": "artifact_8dcee4862e48c13e", + "artifact_type": "session_cookie", + "display_name": "legacy_session", + "locations": [ + { + "path": "app.ts", + "line": 18, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_8dcee4862e48c13e", + "evidence_9638cf3168c14e11", + "evidence_df5a3400b3bd5568" + ], + "transmit": [ + "evidence_49b87b12f7780901", + "evidence_71ff2402f81dfcc0", + "evidence_7fbcf7c04e205441", + "evidence_894b46e27a69f2f1" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_278dad9b2113bd18", + "evidence_8d28c83b347146c6" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "missing", + "value": "false", + "evidence_ids": [ + "evidence_9638cf3168c14e11" + ], + "confidence": "high" + }, + "secure": { + "state": "missing", + "evidence_ids": [ + "evidence_894b46e27a69f2f1" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "none", + "evidence_ids": [ + "evidence_7fbcf7c04e205441" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_8d28c83b347146c6" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_278dad9b2113bd18" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_49b87b12f7780901" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_71ff2402f81dfcc0" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_154fd160cf3f690a", + "artifact_type": "refresh_jwt", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_305ee20048c6d267" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_ec4e55657a78fe6a", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_ef953c43774a0588" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_f963d258ec5440c8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_7fa7941e2d1c0707" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_faaf8106c79488c1", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_ffa3238b17ab9f6e" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_faaf8106c79488c1", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_2900c77baaeed029" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_151942804270435d", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + }, + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_267820b395eba278" + ], + "revoke": [ + "evidence_19d5635581c34e60", + "evidence_93b05c309b27bbf4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript", + "nextjs" + ] + }, + { + "id": "artifact_63d7d91a0736c3b6", + "artifact_type": "refresh_jwt", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_69cff3144d75bdc3" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_3d68a57fcf1a38a2", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_5bff9dee81659348" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_76910dbf5c5d9aa5", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_232e817eb8f7f9ee", + "evidence_752ffe3fd38ec118", + "evidence_76910dbf5c5d9aa5" + ], + "transmit": [ + "evidence_0375d1c6f42c91c0", + "evidence_3ce0cc0488566657", + "evidence_4206339cd0e7ae44", + "evidence_ee3153fb557b84d0" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_090de3fc0c51f0a8", + "evidence_9117595604464e2e" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_232e817eb8f7f9ee" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_4206339cd0e7ae44" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "strict", + "evidence_ids": [ + "evidence_0375d1c6f42c91c0" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_9117595604464e2e" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_090de3fc0c51f0a8" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_3ce0cc0488566657" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_ee3153fb557b84d0" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_85de78ac6aa3bc33", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_6fd6657096c7cb64" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_eac51c20eb23c659", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_2db359b7b6469528" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_f4f9e68f679d0cfc", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_655039e7e2d8798c" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_bdfbab6a12855f82", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_891184dd9d805d4d" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_e0ff886aac6b2e9f", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_e598ec1bf7c310fd" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_f40c4f1b8f755160", + "artifact_type": "opaque_bearer_token", + "display_name": "session_id", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + }, + { + "path": "app.ts", + "line": 36, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_0ee1c1d13af4e500", + "evidence_91a717f6ce1e2113" + ], + "expire": [], + "introspect": [ + "evidence_48674ab370561cc2", + "evidence_7e45838a032ab2e5", + "evidence_9c80d870069c511d", + "evidence_ded9725f66a2bc9a" + ] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ], + "token_boundary_attributes": { + "service": { + "state": "present", + "value": "server", + "evidence_ids": [ + "evidence_9c80d870069c511d", + "evidence_ded9725f66a2bc9a" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "server", + "evidence_ids": [ + "evidence_48674ab370561cc2", + "evidence_7e45838a032ab2e5" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ccce4e2d7769b026", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 36, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_a765a8bfc656851c" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_f5fadee3e8437b01", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 37, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_bceedbcb218327ae" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_9880b67e3360e95a", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 38, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_af325d42d3c49c7e" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_93d2ba050ef7b6a8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 42, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_abfcc0c427dc8c0b" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + } + ], + "evidence": [ + { + "id": "evidence_4e63c7300962690f", + "lifecycle_stage": "issue", + "location": { + "path": "app.ts", + "line": 6, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (_request, response) => {\n const accessToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_db7c57d295560814", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 6, + "column": 1 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (_request, response) => {\n const accessToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5f28697326110c85", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 7, + "column": 9 + }, + "detector_id": "bearer.literal.static", + "confidence": "high", + "excerpt": "accessToken = \"[REDACTED]\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bf3ab86dfecb4f83", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5a72289a03ddd485", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " const [REDACTED] = \"[REDACTED]\";\n response.cookie(\"session\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bfaf3eddb9c369eb", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_05cc4e424d4d22c6", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ad3b0634ef3c54d4", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e040efa573297113", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b275602954778885", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 9, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b798deb3d44bb045", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 10, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_60893ace05d1a945", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 11, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9fc5df1d137d1f72", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 12, + "column": 13 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "medium", + "excerpt": "Max-Age: 15 * 60 * 1000", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_fa34ee456988eedf", + "lifecycle_stage": "issue", + "location": { + "path": "app.ts", + "line": 17, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "app.post(\"/legacy-login\", (_request, response) => {\n response.cookie(\"legacy_session\", \"[REDACTED]\", {\n httpOnly: false,\n sameSite: \"none\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_df5a3400b3bd5568", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8dcee4862e48c13e", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "app.post(\"/legacy-login\", (_request, response) => {\n response.cookie(\"legacy_session\", [REDACTED], {\n httpOnly: false,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_71ff2402f81dfcc0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49b87b12f7780901", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_894b46e27a69f2f1", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_278dad9b2113bd18", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8d28c83b347146c6", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9638cf3168c14e11", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 19, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: false", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7fbcf7c04e205441", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 20, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"none\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ef953c43774a0588", + "lifecycle_stage": "validate", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.validate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_267820b395eba278", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "bearer.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7fa7941e2d1c0707", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ffa3238b17ab9f6e", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_19d5635581c34e60", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_305ee20048c6d267", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "logout.token_revoke", + "confidence": "medium", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_2900c77baaeed029", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_93b05c309b27bbf4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_69cff3144d75bdc3", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "logout.token_revoke", + "confidence": "medium", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_5bff9dee81659348", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "refresh.revoke", + "confidence": "high", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_752ffe3fd38ec118", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_76910dbf5c5d9aa5", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " const [REDACTED] = \"[REDACTED]\";\n response.cookie(\"refresh_token\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2db359b7b6469528", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "refresh.store", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ee3153fb557b84d0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3ce0cc0488566657", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6fd6657096c7cb64", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_090de3fc0c51f0a8", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9117595604464e2e", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_232e817eb8f7f9ee", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 29, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4206339cd0e7ae44", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 30, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0375d1c6f42c91c0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 31, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"strict\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_891184dd9d805d4d", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0ee1c1d13af4e500", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_655039e7e2d8798c", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e598ec1bf7c310fd", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ded9725f66a2bc9a", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.boundary.service", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_48674ab370561cc2", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.boundary.trust_boundary", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_91a717f6ce1e2113", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a765a8bfc656851c", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9c80d870069c511d", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.boundary.service", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e45838a032ab2e5", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.boundary.trust_boundary", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bceedbcb218327ae", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 37, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.clearCookie(\"session\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_af325d42d3c49c7e", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 38, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.clearCookie(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_abfcc0c427dc8c0b", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 42, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "medium", + "excerpt": "function revokeRefreshToken(_token: string) {\n return signingSecret.length > 0;\n}", + "dynamic": true, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_6f837089594b74c4", + "artifact_type": "opaque_bearer_token", + "display_name": "access_token", + "locations": [ + { + "path": "app.ts", + "line": 6, + "column": 1 + }, + { + "path": "app.ts", + "line": 7, + "column": 9 + }, + { + "path": "app.ts", + "line": 8, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_5f28697326110c85" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_ad3b0634ef3c54d4", + "evidence_db7c57d295560814" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_64c56fd5a910792c", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 6, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_4e63c7300962690f" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express", + "scope:111" + ] + }, + { + "id": "artifact_5a72289a03ddd485", + "artifact_type": "signed_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 8, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_5a72289a03ddd485", + "evidence_b275602954778885", + "evidence_bf3ab86dfecb4f83" + ], + "transmit": [ + "evidence_05cc4e424d4d22c6", + "evidence_60893ace05d1a945", + "evidence_b798deb3d44bb045", + "evidence_bfaf3eddb9c369eb" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_9fc5df1d137d1f72", + "evidence_e040efa573297113" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b275602954778885" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b798deb3d44bb045" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_60893ace05d1a945" + ], + "confidence": "high" + }, + "max_age": { + "state": "dynamic", + "value": "15 * 60 * 1000", + "evidence_ids": [ + "evidence_9fc5df1d137d1f72" + ], + "confidence": "medium" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_e040efa573297113" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_05cc4e424d4d22c6" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_bfaf3eddb9c369eb" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_7a9827578e77376e", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 17, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_fa34ee456988eedf" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express", + "scope:402" + ] + }, + { + "id": "artifact_8dcee4862e48c13e", + "artifact_type": "session_cookie", + "display_name": "legacy_session", + "locations": [ + { + "path": "app.ts", + "line": 18, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_8dcee4862e48c13e", + "evidence_9638cf3168c14e11", + "evidence_df5a3400b3bd5568" + ], + "transmit": [ + "evidence_49b87b12f7780901", + "evidence_71ff2402f81dfcc0", + "evidence_7fbcf7c04e205441", + "evidence_894b46e27a69f2f1" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_278dad9b2113bd18", + "evidence_8d28c83b347146c6" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "missing", + "value": "false", + "evidence_ids": [ + "evidence_9638cf3168c14e11" + ], + "confidence": "high" + }, + "secure": { + "state": "missing", + "evidence_ids": [ + "evidence_894b46e27a69f2f1" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "none", + "evidence_ids": [ + "evidence_7fbcf7c04e205441" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_8d28c83b347146c6" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_278dad9b2113bd18" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_49b87b12f7780901" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_71ff2402f81dfcc0" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_154fd160cf3f690a", + "artifact_type": "refresh_jwt", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_305ee20048c6d267" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_ec4e55657a78fe6a", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_ef953c43774a0588" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_f963d258ec5440c8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_7fa7941e2d1c0707" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_faaf8106c79488c1", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_ffa3238b17ab9f6e" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_faaf8106c79488c1", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_2900c77baaeed029" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_151942804270435d", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "app.ts", + "line": 24, + "column": 1 + }, + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_267820b395eba278" + ], + "revoke": [ + "evidence_19d5635581c34e60", + "evidence_93b05c309b27bbf4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript", + "nextjs" + ] + }, + { + "id": "artifact_63d7d91a0736c3b6", + "artifact_type": "refresh_jwt", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_69cff3144d75bdc3" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_3d68a57fcf1a38a2", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 26, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_5bff9dee81659348" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_76910dbf5c5d9aa5", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_232e817eb8f7f9ee", + "evidence_752ffe3fd38ec118", + "evidence_76910dbf5c5d9aa5" + ], + "transmit": [ + "evidence_0375d1c6f42c91c0", + "evidence_3ce0cc0488566657", + "evidence_4206339cd0e7ae44", + "evidence_ee3153fb557b84d0" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_090de3fc0c51f0a8", + "evidence_9117595604464e2e" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_232e817eb8f7f9ee" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_4206339cd0e7ae44" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "strict", + "evidence_ids": [ + "evidence_0375d1c6f42c91c0" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_9117595604464e2e" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_090de3fc0c51f0a8" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_3ce0cc0488566657" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_ee3153fb557b84d0" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_85de78ac6aa3bc33", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_6fd6657096c7cb64" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_eac51c20eb23c659", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 28, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_2db359b7b6469528" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "refresh" + ] + }, + { + "id": "artifact_f4f9e68f679d0cfc", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_655039e7e2d8798c" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_bdfbab6a12855f82", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_891184dd9d805d4d" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_e0ff886aac6b2e9f", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_e598ec1bf7c310fd" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_f40c4f1b8f755160", + "artifact_type": "opaque_bearer_token", + "display_name": "session_id", + "locations": [ + { + "path": "app.ts", + "line": 35, + "column": 1 + }, + { + "path": "app.ts", + "line": 36, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_0ee1c1d13af4e500", + "evidence_91a717f6ce1e2113" + ], + "expire": [], + "introspect": [ + "evidence_48674ab370561cc2", + "evidence_7e45838a032ab2e5", + "evidence_9c80d870069c511d", + "evidence_ded9725f66a2bc9a" + ] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ], + "token_boundary_attributes": { + "service": { + "state": "present", + "value": "server", + "evidence_ids": [ + "evidence_9c80d870069c511d", + "evidence_ded9725f66a2bc9a" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "server", + "evidence_ids": [ + "evidence_48674ab370561cc2", + "evidence_7e45838a032ab2e5" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ccce4e2d7769b026", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 36, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_a765a8bfc656851c" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_f5fadee3e8437b01", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.ts", + "line": 37, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_bceedbcb218327ae" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_9880b67e3360e95a", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 38, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_af325d42d3c49c7e" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "express" + ] + }, + { + "id": "artifact_93d2ba050ef7b6a8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "app.ts", + "line": 42, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_abfcc0c427dc8c0b" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + } + ], + "evidence": [ + { + "id": "evidence_4e63c7300962690f", + "lifecycle_stage": "issue", + "location": { + "path": "app.ts", + "line": 6, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (_request, response) => {\n const accessToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_db7c57d295560814", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 6, + "column": 1 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (_request, response) => {\n const accessToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5f28697326110c85", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 7, + "column": 9 + }, + "detector_id": "bearer.literal.static", + "confidence": "high", + "excerpt": "accessToken = \"[REDACTED]\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bf3ab86dfecb4f83", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5a72289a03ddd485", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " const [REDACTED] = \"[REDACTED]\";\n response.cookie(\"session\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bfaf3eddb9c369eb", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_05cc4e424d4d22c6", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ad3b0634ef3c54d4", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", accessToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n maxAge: 15 * 60 * 1000,\n signed: true,\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e040efa573297113", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 8, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b275602954778885", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 9, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b798deb3d44bb045", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 10, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_60893ace05d1a945", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 11, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9fc5df1d137d1f72", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 12, + "column": 13 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "medium", + "excerpt": "Max-Age: 15 * 60 * 1000", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_fa34ee456988eedf", + "lifecycle_stage": "issue", + "location": { + "path": "app.ts", + "line": 17, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "app.post(\"/legacy-login\", (_request, response) => {\n response.cookie(\"legacy_session\", \"[REDACTED]\", {\n httpOnly: false,\n sameSite: \"none\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_df5a3400b3bd5568", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8dcee4862e48c13e", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "app.post(\"/legacy-login\", (_request, response) => {\n response.cookie(\"legacy_session\", [REDACTED], {\n httpOnly: false,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_71ff2402f81dfcc0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49b87b12f7780901", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_894b46e27a69f2f1", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_278dad9b2113bd18", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8d28c83b347146c6", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 18, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9638cf3168c14e11", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 19, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: false", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7fbcf7c04e205441", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 20, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"none\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ef953c43774a0588", + "lifecycle_stage": "validate", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.validate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_267820b395eba278", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "bearer.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7fa7941e2d1c0707", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ffa3238b17ab9f6e", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_19d5635581c34e60", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken = \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_305ee20048c6d267", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "logout.token_revoke", + "confidence": "medium", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_2900c77baaeed029", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 24, + "column": 1 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n const previousRefreshToken = request.cookies?.refresh_token ?? \"[REDACTED]\";\n revokeRefreshToken(previousRefreshToken);\n const rotatedRefreshToken: \"[REDACTED]\";\n response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_93b05c309b27bbf4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_69cff3144d75bdc3", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "logout.token_revoke", + "confidence": "medium", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_5bff9dee81659348", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 26, + "column": 3 + }, + "detector_id": "refresh.revoke", + "confidence": "high", + "excerpt": "revokeRefreshToken(previousRefreshToken)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_752ffe3fd38ec118", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_76910dbf5c5d9aa5", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " const [REDACTED] = \"[REDACTED]\";\n response.cookie(\"refresh_token\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2db359b7b6469528", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "refresh.store", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ee3153fb557b84d0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3ce0cc0488566657", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for express", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6fd6657096c7cb64", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "refresh.rotate", + "confidence": "high", + "excerpt": "response.cookie(\"[REDACTED]\", rotatedRefreshToken, {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n })", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_090de3fc0c51f0a8", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9117595604464e2e", + "lifecycle_stage": "expire", + "location": { + "path": "app.ts", + "line": 28, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_232e817eb8f7f9ee", + "lifecycle_stage": "store", + "location": { + "path": "app.ts", + "line": 29, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4206339cd0e7ae44", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 30, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0375d1c6f42c91c0", + "lifecycle_stage": "transmit", + "location": { + "path": "app.ts", + "line": 31, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"strict\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_891184dd9d805d4d", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0ee1c1d13af4e500", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_655039e7e2d8798c", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e598ec1bf7c310fd", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ded9725f66a2bc9a", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.boundary.service", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_48674ab370561cc2", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 35, + "column": 1 + }, + "detector_id": "bearer.boundary.trust_boundary", + "confidence": "high", + "excerpt": "app.post(\"[REDACTED]\", (request, response) => {\n destroyServerSession(request.sessionID);\n response.clearCookie(\"[REDACTED]\");\n response.clearCookie(\"[REDACTED]\");\n response.sendStatus(204);\n})", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_91a717f6ce1e2113", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.revoke", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a765a8bfc656851c", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9c80d870069c511d", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.boundary.service", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e45838a032ab2e5", + "lifecycle_stage": "introspect", + "location": { + "path": "app.ts", + "line": 36, + "column": 3 + }, + "detector_id": "bearer.boundary.trust_boundary", + "confidence": "high", + "excerpt": "destroyServerSession(request.sessionID)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bceedbcb218327ae", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 37, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.clearCookie(\"session\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_af325d42d3c49c7e", + "lifecycle_stage": "revoke", + "location": { + "path": "app.ts", + "line": 38, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.clearCookie(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_abfcc0c427dc8c0b", + "lifecycle_stage": "refresh", + "location": { + "path": "app.ts", + "line": 42, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "medium", + "excerpt": "function revokeRefreshToken(_token: string) {\n return signingSecret.length > 0;\n}", + "dynamic": true, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_4033ab72bb1235bf", + "artifact_ids": [ + "artifact_151942804270435d" + ], + "stages": [ + { + "stage": "refresh", + "evidence_ids": [ + "evidence_267820b395eba278" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_19d5635581c34e60", + "evidence_93b05c309b27bbf4" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_5b475db68188077b", + "artifact_ids": [ + "artifact_154fd160cf3f690a", + "artifact_3d68a57fcf1a38a2", + "artifact_63d7d91a0736c3b6", + "artifact_76910dbf5c5d9aa5", + "artifact_85de78ac6aa3bc33", + "artifact_93d2ba050ef7b6a8", + "artifact_9880b67e3360e95a", + "artifact_bdfbab6a12855f82", + "artifact_eac51c20eb23c659", + "artifact_ec4e55657a78fe6a", + "artifact_f963d258ec5440c8", + "artifact_faaf8106c79488c1" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_232e817eb8f7f9ee", + "evidence_2db359b7b6469528", + "evidence_752ffe3fd38ec118", + "evidence_76910dbf5c5d9aa5" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_0375d1c6f42c91c0", + "evidence_3ce0cc0488566657", + "evidence_4206339cd0e7ae44", + "evidence_ee3153fb557b84d0" + ] + }, + { + "stage": "validate", + "evidence_ids": [ + "evidence_ef953c43774a0588" + ] + }, + { + "stage": "refresh", + "evidence_ids": [ + "evidence_6fd6657096c7cb64", + "evidence_7fa7941e2d1c0707", + "evidence_891184dd9d805d4d", + "evidence_abfcc0c427dc8c0b", + "evidence_ffa3238b17ab9f6e" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_2900c77baaeed029", + "evidence_305ee20048c6d267", + "evidence_5bff9dee81659348", + "evidence_69cff3144d75bdc3", + "evidence_af325d42d3c49c7e" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_090de3fc0c51f0a8", + "evidence_9117595604464e2e" + ] + } + ], + "confidence": "low", + "dynamic": true, + "reviewer_question": "Which production code path determines the effective lifecycle behavior for `refresh_token`?" + }, + { + "id": "lifecycle_path_86c116b37f0e2ad3", + "artifact_ids": [ + "artifact_5a72289a03ddd485", + "artifact_ccce4e2d7769b026", + "artifact_e0ff886aac6b2e9f", + "artifact_f5fadee3e8437b01" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_5a72289a03ddd485", + "evidence_b275602954778885", + "evidence_bf3ab86dfecb4f83" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_05cc4e424d4d22c6", + "evidence_60893ace05d1a945", + "evidence_b798deb3d44bb045", + "evidence_bfaf3eddb9c369eb" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_a765a8bfc656851c", + "evidence_bceedbcb218327ae", + "evidence_e598ec1bf7c310fd" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_9fc5df1d137d1f72", + "evidence_e040efa573297113" + ] + } + ], + "confidence": "low", + "dynamic": true, + "reviewer_question": "Which production code path determines the effective lifecycle behavior for `session`?" + }, + { + "id": "lifecycle_path_4c077575b3f09cbd", + "artifact_ids": [ + "artifact_64c56fd5a910792c" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_4e63c7300962690f" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_2e8eb975b1c526e6", + "artifact_ids": [ + "artifact_6f837089594b74c4" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_5f28697326110c85" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_ad3b0634ef3c54d4", + "evidence_db7c57d295560814" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_572969cbd1e8798b", + "artifact_ids": [ + "artifact_7a9827578e77376e" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_fa34ee456988eedf" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_f569078327ea3a2d", + "artifact_ids": [ + "artifact_8dcee4862e48c13e" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_8dcee4862e48c13e", + "evidence_9638cf3168c14e11", + "evidence_df5a3400b3bd5568" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_49b87b12f7780901", + "evidence_71ff2402f81dfcc0", + "evidence_7fbcf7c04e205441", + "evidence_894b46e27a69f2f1" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_278dad9b2113bd18", + "evidence_8d28c83b347146c6" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `legacy_session`?" + }, + { + "id": "lifecycle_path_0e964f654c165e5c", + "artifact_ids": [ + "artifact_f40c4f1b8f755160" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_0ee1c1d13af4e500", + "evidence_91a717f6ce1e2113" + ] + }, + { + "stage": "introspect", + "evidence_ids": [ + "evidence_48674ab370561cc2", + "evidence_7e45838a032ab2e5", + "evidence_9c80d870069c511d", + "evidence_ded9725f66a2bc9a" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_805b1ae29e882bbe", + "artifact_ids": [ + "artifact_f4f9e68f679d0cfc" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_655039e7e2d8798c" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + } + ], + "findings": [ + { + "id": "finding_9d395752f36e474c", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_6f837089594b74c4" + ], + "evidence_ids": [ + "evidence_5f28697326110c85" + ], + "title": "Token `access_token` has static secret-like literal evidence", + "description": "Source evidence contains a static token/API-key style literal. The value is redacted in reports, but the code path should not carry runtime secrets in source.", + "suggested_fix": "Move runtime token values to approved secret storage and keep source/config references value-free.", + "reviewer_question": "Is this placeholder-only fixture code, or can production source contain a token value here?" + }, + { + "id": "finding_25b7beeb5b710c99", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_8dcee4862e48c13e" + ], + "evidence_ids": [ + "evidence_7fbcf7c04e205441", + "evidence_894b46e27a69f2f1" + ], + "title": "Cookie `legacy_session` sets SameSite=None without Secure evidence", + "description": "SameSite=None was detected, but Secure attribute evidence was not detected for this cookie-setting call.", + "suggested_fix": "Set Secure whenever SameSite=None is used.", + "reviewer_question": "Is this cookie intentionally available in cross-site requests?" + }, + { + "id": "finding_55131de590e94521", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_8dcee4862e48c13e" + ], + "evidence_ids": [ + "evidence_894b46e27a69f2f1" + ], + "title": "Cookie `legacy_session` does not set Secure", + "description": "No Secure attribute evidence was detected for this cookie-setting call.", + "suggested_fix": "Set Secure for cookies that should only be sent over HTTPS.", + "reviewer_question": "Is this cookie ever used in an externally reachable production environment?" + }, + { + "id": "finding_a38cf3af681ec5dc", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_8dcee4862e48c13e" + ], + "evidence_ids": [ + "evidence_9638cf3168c14e11" + ], + "title": "Session-like cookie `legacy_session` does not set HttpOnly", + "description": "No HttpOnly attribute evidence was detected for this cookie-setting call.", + "suggested_fix": "Set HttpOnly on session cookies so client-side scripts cannot read them.", + "reviewer_question": "Is this cookie intended to be inaccessible to browser JavaScript?" + }, + { + "id": "finding_05e18a66378175d4", + "category": "lifecycle_gap", + "severity": "medium", + "artifact_ids": [ + "artifact_76910dbf5c5d9aa5" + ], + "evidence_ids": [ + "evidence_655039e7e2d8798c", + "evidence_6fd6657096c7cb64", + "evidence_7fa7941e2d1c0707", + "evidence_891184dd9d805d4d", + "evidence_abfcc0c427dc8c0b", + "evidence_ffa3238b17ab9f6e" + ], + "title": "Refresh token `refresh_token` has logout evidence without family revocation", + "description": "Logout and refresh-token lifecycle evidence were detected in linked source context, but no source-bound user-scoped or refresh-family revocation evidence was linked for the logout flow.", + "suggested_fix": "Revoke the user's refresh-token family, delete user-scoped refresh-token records, or remove the refresh-family cache key during logout.", + "reviewer_question": "Where does logout revoke every refresh token in the `refresh_token` family or for the current user?" + }, + { + "id": "finding_b39b8f710f933855", + "category": "dynamic_review_required", + "severity": "medium", + "artifact_ids": [ + "artifact_64c56fd5a910792c" + ], + "evidence_ids": [ + "evidence_4e63c7300962690f" + ], + "title": "Session regeneration evidence was not found near login", + "description": "An authentication transition was detected, but nearby static evidence did not show an explicit session regeneration, cookie reissue, or recognized framework-default rotation point.", + "suggested_fix": "Regenerate the server-side session with [REDACTED](...) after authentication and privilege changes before storing the authenticated user state.", + "reviewer_question": "Where is the session identifier rotated after this authentication transition?" + }, + { + "id": "finding_d475e20fdbc03e20", + "category": "dynamic_review_required", + "severity": "medium", + "artifact_ids": [ + "artifact_7a9827578e77376e" + ], + "evidence_ids": [ + "evidence_fa34ee456988eedf" + ], + "title": "Session regeneration evidence was not found near login", + "description": "An authentication transition was detected, but nearby static evidence did not show an explicit session regeneration, cookie reissue, or recognized framework-default rotation point.", + "suggested_fix": "Regenerate the server-side session with [REDACTED](...) after authentication and privilege changes before storing the authenticated user state.", + "reviewer_question": "Where is the session identifier rotated after this authentication transition?" + }, + { + "id": "finding_620ea7721befb84c", + "category": "lifecycle_gap", + "severity": "low", + "artifact_ids": [ + "artifact_8dcee4862e48c13e" + ], + "evidence_ids": [ + "evidence_278dad9b2113bd18", + "evidence_8d28c83b347146c6" + ], + "title": "Cookie `legacy_session` has no explicit expiry evidence", + "description": "No Max-Age or Expires evidence was detected for this cookie-setting call.", + "suggested_fix": "Add an explicit Max-Age or Expires value when the cookie should have a bounded lifetime.", + "reviewer_question": "Should this cookie be session-scoped, or should it have an explicit lifetime?" + }, + { + "id": "finding_2229884393d65240", + "category": "dynamic_review_required", + "severity": "low", + "artifact_ids": [ + "artifact_5a72289a03ddd485" + ], + "evidence_ids": [ + "evidence_9fc5df1d137d1f72", + "evidence_e040efa573297113" + ], + "title": "Cookie `session` has dynamic expiry evidence", + "description": "Cookie lifetime appears to depend on dynamic Max-Age or Expires evidence.", + "suggested_fix": "Confirm the effective cookie lifetime and document the production value.", + "reviewer_question": "What effective lifetime does this cookie have in production?" + } + ] +} \ No newline at end of file diff --git a/tests/integration/snapshots/fastapi-dependency-auth-lifecycle.json b/tests/integration/snapshots/fastapi-dependency-auth-lifecycle.json new file mode 100644 index 0000000..bd7a791 --- /dev/null +++ b/tests/integration/snapshots/fastapi-dependency-auth-lifecycle.json @@ -0,0 +1,2357 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "app.py", + "language": "python", + "artifacts": [ + { + "id": "artifact_1f6194ee35663737", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "app.py", + "line": 14, + "column": 12 + }, + { + "path": "app.py", + "line": 29, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_04e27dc796edd948", + "evidence_1d120b1c0c19e112", + "evidence_485ff7f12a7a83f8", + "evidence_67ba10e56375217d", + "evidence_98eb78b1628dff5a", + "evidence_b1e065af58c3925c" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_0430950bd7d6b709", + "evidence_1a96e0988bbbf340", + "evidence_400f7ea0d2ddb252", + "evidence_67f9caad64bf13a6", + "evidence_6b8e2bb85b4a5c59", + "evidence_751429b89c10824f", + "evidence_8251d59500cb750b", + "evidence_879be86c887ffc25", + "evidence_9ec068fd376b7d89", + "evidence_b61b525c331695f4" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_dd7490cd811b1d0c" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue, validate", + "evidence_ids": [ + "evidence_67ba10e56375217d", + "evidence_b61b525c331695f4" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_04e27dc796edd948", + "evidence_9ec068fd376b7d89" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_879be86c887ffc25", + "evidence_98eb78b1628dff5a" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_0430950bd7d6b709", + "evidence_485ff7f12a7a83f8" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "expires_at", + "evidence_ids": [ + "evidence_dd7490cd811b1d0c" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_6b8e2bb85b4a5c59" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "PyJWT decode default", + "evidence_ids": [ + "evidence_400f7ea0d2ddb252" + ], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "user_id", + "evidence_ids": [ + "evidence_1d120b1c0c19e112" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email_verified": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_0430950bd7d6b709", + "evidence_485ff7f12a7a83f8" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_6fe7bd9aa9441210", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 39, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_bab2541ae5986d41" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "fastapi", + "scope:898" + ] + }, + { + "id": "artifact_6662c62fe10196d2", + "artifact_type": "opaque_bearer_token", + "display_name": "access_token", + "locations": [ + { + "path": "app.py", + "line": 40, + "column": 5 + }, + { + "path": "app.py", + "line": 40, + "column": 13 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_0277e4a57e2be5d0" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "token_boundary_attributes": { + "issuer": { + "state": "present", + "evidence_ids": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_12991725538e196c", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 41, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_12991725538e196c", + "evidence_2e869cc62fb447f5", + "evidence_ee55e24a0d65191a" + ], + "transmit": [ + "evidence_063c471d6ac166fc", + "evidence_427e3aca09f2a901", + "evidence_4b35d34e5e07ffc4", + "evidence_61c3d2780025a546" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_51e68c6ad8120fd6", + "evidence_a7c8930136f027d4" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_2e869cc62fb447f5" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_61c3d2780025a546" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_4b35d34e5e07ffc4" + ], + "confidence": "high" + }, + "max_age": { + "state": "present", + "value": "900", + "evidence_ids": [ + "evidence_51e68c6ad8120fd6" + ], + "confidence": "high" + }, + "expires": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_a7c8930136f027d4" + ], + "confidence": "low" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_427e3aca09f2a901" + ], + "confidence": "low" + }, + "domain": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_063c471d6ac166fc" + ], + "confidence": "low" + } + } + }, + { + "id": "artifact_35be80ef8141f7cb", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "app.py", + "line": 55, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_7e0f8ab5f17e8838" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_9dd7f790adcb8674", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "app.py", + "line": 60, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_43dfb90d25264dc4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "fastapi" + ] + }, + { + "id": "artifact_9a37039751082f3f", + "artifact_type": "unknown", + "display_name": "security_dependency", + "locations": [ + { + "path": "app.py", + "line": 60, + "column": 37 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_c5dd88fd06c26093" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "fastapi" + ] + }, + { + "id": "artifact_345218b3714d0c8a", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 61, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_c0a93958834374e4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_26239a282b5467e3", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 62, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_41eb635462b4ba20" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + } + ], + "evidence": [ + { + "id": "evidence_67ba10e56375217d", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 14, + "column": 12 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "pyjwt.encode issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1d120b1c0c19e112", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 16, + "column": 20 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_485ff7f12a7a83f8", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 17, + "column": 20 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b1e065af58c3925c", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 18, + "column": 20 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_dd7490cd811b1d0c", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 19, + "column": 20 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "expires_at", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_98eb78b1628dff5a", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 21, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_04e27dc796edd948", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 22, + "column": 19 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "\"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_400f7ea0d2ddb252", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "PyJWT decode enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6b8e2bb85b4a5c59", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b61b525c331695f4", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_879be86c887ffc25", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9ec068fd376b7d89", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 32, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8251d59500cb750b", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 32, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0430950bd7d6b709", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 33, + "column": 16 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_67f9caad64bf13a6", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 33, + "column": 16 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_751429b89c10824f", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 34, + "column": 18 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1a96e0988bbbf340", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 34, + "column": 18 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bab2541ae5986d41", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 39, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "def login(response: Response):\n token = issue_access_token(\"[REDACTED]\")\n response.set_cookie(\n \"[REDACTED]\",\n token,\n httponly=True,\n secure=True,\n samesite=\"[REDACTED]\",\n max_age=900,\n )\n return {\"[REDACTED]\": True}", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0277e4a57e2be5d0", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 40, + "column": 5 + }, + "detector_id": "bearer.store.config", + "confidence": "high", + "excerpt": "token = issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8f65a9c03ac6856e", + "lifecycle_stage": "introspect", + "location": { + "path": "app.py", + "line": 40, + "column": 5 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "token = issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d40d72776f6ad953", + "lifecycle_stage": "introspect", + "location": { + "path": "app.py", + "line": 40, + "column": 13 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ee55e24a0d65191a", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_12991725538e196c", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " [REDACTED] = issue_access_[REDACTED](\"placeholder-user\")\n response.set_cookie(\n \"session\",", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_063c471d6ac166fc", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "low", + "excerpt": "Domain defaults to none for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_427e3aca09f2a901", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_a7c8930136f027d4", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "low", + "excerpt": "Expires defaults to none for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_2e869cc62fb447f5", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 44, + "column": 18 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_61c3d2780025a546", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 45, + "column": 16 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4b35d34e5e07ffc4", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 46, + "column": 18 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_51e68c6ad8120fd6", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 47, + "column": 17 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age: 900", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e0f8ab5f17e8838", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 55, + "column": 5 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "store_reset_token(\"[REDACTED]\", \"[REDACTED]\", expires_at)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_43dfb90d25264dc4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 60, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "def logout(response: Response, user=Depends(current_user)):\n revoke_session(user[\"[REDACTED]\"])\n response.delete_cookie(\"[REDACTED]\")\n return Response(status_code=204)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c5dd88fd06c26093", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 60, + "column": 37 + }, + "detector_id": "fastapi.security_dependency", + "confidence": "medium", + "excerpt": "Depends(current_user)", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_c0a93958834374e4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 61, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "revoke_session(user[\"[REDACTED]\"])", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_41eb635462b4ba20", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 62, + "column": 5 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.delete_cookie(\"session\")", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_1f6194ee35663737", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "app.py", + "line": 14, + "column": 12 + }, + { + "path": "app.py", + "line": 29, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_04e27dc796edd948", + "evidence_1d120b1c0c19e112", + "evidence_485ff7f12a7a83f8", + "evidence_67ba10e56375217d", + "evidence_98eb78b1628dff5a", + "evidence_b1e065af58c3925c" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_0430950bd7d6b709", + "evidence_1a96e0988bbbf340", + "evidence_400f7ea0d2ddb252", + "evidence_67f9caad64bf13a6", + "evidence_6b8e2bb85b4a5c59", + "evidence_751429b89c10824f", + "evidence_8251d59500cb750b", + "evidence_879be86c887ffc25", + "evidence_9ec068fd376b7d89", + "evidence_b61b525c331695f4" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_dd7490cd811b1d0c" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue, validate", + "evidence_ids": [ + "evidence_67ba10e56375217d", + "evidence_b61b525c331695f4" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_04e27dc796edd948", + "evidence_9ec068fd376b7d89" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_879be86c887ffc25", + "evidence_98eb78b1628dff5a" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_0430950bd7d6b709", + "evidence_485ff7f12a7a83f8" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "expires_at", + "evidence_ids": [ + "evidence_dd7490cd811b1d0c" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_6b8e2bb85b4a5c59" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "PyJWT decode default", + "evidence_ids": [ + "evidence_400f7ea0d2ddb252" + ], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "user_id", + "evidence_ids": [ + "evidence_1d120b1c0c19e112" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email_verified": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_0430950bd7d6b709", + "evidence_485ff7f12a7a83f8" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_751429b89c10824f", + "evidence_b1e065af58c3925c" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_6fe7bd9aa9441210", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 39, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_bab2541ae5986d41" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "fastapi", + "scope:898" + ] + }, + { + "id": "artifact_6662c62fe10196d2", + "artifact_type": "opaque_bearer_token", + "display_name": "access_token", + "locations": [ + { + "path": "app.py", + "line": 40, + "column": 5 + }, + { + "path": "app.py", + "line": 40, + "column": 13 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_0277e4a57e2be5d0" + ], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "token_boundary_attributes": { + "issuer": { + "state": "present", + "evidence_ids": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_12991725538e196c", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 41, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_12991725538e196c", + "evidence_2e869cc62fb447f5", + "evidence_ee55e24a0d65191a" + ], + "transmit": [ + "evidence_063c471d6ac166fc", + "evidence_427e3aca09f2a901", + "evidence_4b35d34e5e07ffc4", + "evidence_61c3d2780025a546" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_51e68c6ad8120fd6", + "evidence_a7c8930136f027d4" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_2e869cc62fb447f5" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "True", + "evidence_ids": [ + "evidence_61c3d2780025a546" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_4b35d34e5e07ffc4" + ], + "confidence": "high" + }, + "max_age": { + "state": "present", + "value": "900", + "evidence_ids": [ + "evidence_51e68c6ad8120fd6" + ], + "confidence": "high" + }, + "expires": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_a7c8930136f027d4" + ], + "confidence": "low" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_427e3aca09f2a901" + ], + "confidence": "low" + }, + "domain": { + "state": "framework_default", + "value": "none", + "evidence_ids": [ + "evidence_063c471d6ac166fc" + ], + "confidence": "low" + } + } + }, + { + "id": "artifact_35be80ef8141f7cb", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "app.py", + "line": 55, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_7e0f8ab5f17e8838" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_9dd7f790adcb8674", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "app.py", + "line": 60, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_43dfb90d25264dc4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "fastapi" + ] + }, + { + "id": "artifact_9a37039751082f3f", + "artifact_type": "unknown", + "display_name": "security_dependency", + "locations": [ + { + "path": "app.py", + "line": 60, + "column": 37 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_c5dd88fd06c26093" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "fastapi" + ] + }, + { + "id": "artifact_345218b3714d0c8a", + "artifact_type": "session_record", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 61, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_c0a93958834374e4" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + }, + { + "id": "artifact_26239a282b5467e3", + "artifact_type": "session_cookie", + "display_name": "session", + "locations": [ + { + "path": "app.py", + "line": 62, + "column": 5 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_41eb635462b4ba20" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "python" + ] + } + ], + "evidence": [ + { + "id": "evidence_67ba10e56375217d", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 14, + "column": 12 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "pyjwt.encode issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1d120b1c0c19e112", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 16, + "column": 20 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_485ff7f12a7a83f8", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 17, + "column": 20 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b1e065af58c3925c", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 18, + "column": 20 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_dd7490cd811b1d0c", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 19, + "column": 20 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "expires_at", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_98eb78b1628dff5a", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 21, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_04e27dc796edd948", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 22, + "column": 19 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "\"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_400f7ea0d2ddb252", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "PyJWT decode enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6b8e2bb85b4a5c59", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b61b525c331695f4", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 29, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_879be86c887ffc25", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9ec068fd376b7d89", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 32, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8251d59500cb750b", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 32, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0430950bd7d6b709", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 33, + "column": 16 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_67f9caad64bf13a6", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 33, + "column": 16 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_751429b89c10824f", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 34, + "column": 18 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1a96e0988bbbf340", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 34, + "column": 18 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_bab2541ae5986d41", + "lifecycle_stage": "issue", + "location": { + "path": "app.py", + "line": 39, + "column": 1 + }, + "detector_id": "session.auth_transition", + "confidence": "high", + "excerpt": "def login(response: Response):\n token = issue_access_token(\"[REDACTED]\")\n response.set_cookie(\n \"[REDACTED]\",\n token,\n httponly=True,\n secure=True,\n samesite=\"[REDACTED]\",\n max_age=900,\n )\n return {\"[REDACTED]\": True}", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0277e4a57e2be5d0", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 40, + "column": 5 + }, + "detector_id": "bearer.store.config", + "confidence": "high", + "excerpt": "token = issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8f65a9c03ac6856e", + "lifecycle_stage": "introspect", + "location": { + "path": "app.py", + "line": 40, + "column": 5 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "token = issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d40d72776f6ad953", + "lifecycle_stage": "introspect", + "location": { + "path": "app.py", + "line": 40, + "column": 13 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "issue_access_token(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ee55e24a0d65191a", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_12991725538e196c", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": " [REDACTED] = issue_access_[REDACTED](\"placeholder-user\")\n response.set_cookie(\n \"session\",", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_063c471d6ac166fc", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "low", + "excerpt": "Domain defaults to none for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_427e3aca09f2a901", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_a7c8930136f027d4", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 41, + "column": 5 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "low", + "excerpt": "Expires defaults to none for python", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_2e869cc62fb447f5", + "lifecycle_stage": "store", + "location": { + "path": "app.py", + "line": 44, + "column": 18 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_61c3d2780025a546", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 45, + "column": 16 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: True", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4b35d34e5e07ffc4", + "lifecycle_stage": "transmit", + "location": { + "path": "app.py", + "line": 46, + "column": 18 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_51e68c6ad8120fd6", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 47, + "column": 17 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age: 900", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e0f8ab5f17e8838", + "lifecycle_stage": "expire", + "location": { + "path": "app.py", + "line": 55, + "column": 5 + }, + "detector_id": "bearer.expire", + "confidence": "high", + "excerpt": "store_reset_token(\"[REDACTED]\", \"[REDACTED]\", expires_at)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_43dfb90d25264dc4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 60, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "high", + "excerpt": "def logout(response: Response, user=Depends(current_user)):\n revoke_session(user[\"[REDACTED]\"])\n response.delete_cookie(\"[REDACTED]\")\n return Response(status_code=204)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c5dd88fd06c26093", + "lifecycle_stage": "validate", + "location": { + "path": "app.py", + "line": 60, + "column": 37 + }, + "detector_id": "fastapi.security_dependency", + "confidence": "medium", + "excerpt": "Depends(current_user)", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_c0a93958834374e4", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 61, + "column": 5 + }, + "detector_id": "logout.session_destroy", + "confidence": "high", + "excerpt": "revoke_session(user[\"[REDACTED]\"])", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_41eb635462b4ba20", + "lifecycle_stage": "revoke", + "location": { + "path": "app.py", + "line": 62, + "column": 5 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "response.delete_cookie(\"session\")", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_f8d48d8053a90bb6", + "artifact_ids": [ + "artifact_12991725538e196c", + "artifact_26239a282b5467e3", + "artifact_345218b3714d0c8a" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_12991725538e196c", + "evidence_2e869cc62fb447f5", + "evidence_ee55e24a0d65191a" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_063c471d6ac166fc", + "evidence_427e3aca09f2a901", + "evidence_4b35d34e5e07ffc4", + "evidence_61c3d2780025a546" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_41eb635462b4ba20", + "evidence_c0a93958834374e4" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_51e68c6ad8120fd6", + "evidence_a7c8930136f027d4" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `session`?" + }, + { + "id": "lifecycle_path_7245767eb9cf69d6", + "artifact_ids": [ + "artifact_1f6194ee35663737" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_04e27dc796edd948", + "evidence_1d120b1c0c19e112", + "evidence_485ff7f12a7a83f8", + "evidence_67ba10e56375217d", + "evidence_98eb78b1628dff5a", + "evidence_b1e065af58c3925c" + ] + }, + { + "stage": "validate", + "evidence_ids": [ + "evidence_0430950bd7d6b709", + "evidence_1a96e0988bbbf340", + "evidence_400f7ea0d2ddb252", + "evidence_67f9caad64bf13a6", + "evidence_6b8e2bb85b4a5c59", + "evidence_751429b89c10824f", + "evidence_8251d59500cb750b", + "evidence_879be86c887ffc25", + "evidence_9ec068fd376b7d89", + "evidence_b61b525c331695f4" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_dd7490cd811b1d0c" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `access_jwt`?" + }, + { + "id": "lifecycle_path_9bd8d97cc0befed7", + "artifact_ids": [ + "artifact_35be80ef8141f7cb" + ], + "stages": [ + { + "stage": "expire", + "evidence_ids": [ + "evidence_7e0f8ab5f17e8838" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_d31556a0b43c2904", + "artifact_ids": [ + "artifact_6662c62fe10196d2" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_0277e4a57e2be5d0" + ] + }, + { + "stage": "introspect", + "evidence_ids": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_16d22fa8a59e37e3", + "artifact_ids": [ + "artifact_6fe7bd9aa9441210" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_bab2541ae5986d41" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_29d30d2dc0b5cd9c", + "artifact_ids": [ + "artifact_9a37039751082f3f" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_c5dd88fd06c26093" + ] + } + ], + "confidence": "medium", + "dynamic": true, + "reviewer_question": "Which production code path determines the effective lifecycle behavior for `security_dependency`?" + }, + { + "id": "lifecycle_path_c9b761a2e59e2fbd", + "artifact_ids": [ + "artifact_9dd7f790adcb8674" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_43dfb90d25264dc4" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + } + ], + "findings": [ + { + "id": "finding_123f9760ef48ff07", + "category": "lifecycle_gap", + "severity": "medium", + "artifact_ids": [ + "artifact_1f6194ee35663737" + ], + "evidence_ids": [ + "evidence_04e27dc796edd948", + "evidence_1d120b1c0c19e112", + "evidence_43dfb90d25264dc4", + "evidence_485ff7f12a7a83f8", + "evidence_67ba10e56375217d", + "evidence_98eb78b1628dff5a", + "evidence_b1e065af58c3925c" + ], + "title": "JWT `access_jwt` has logout evidence without linked denylist evidence", + "description": "A logout handler and access-JWT lifecycle evidence were detected in linked source context, but no source-bound denylist, blocklist, or token revocation-store insertion evidence was linked for the same logout flow.", + "suggested_fix": "Insert the JWT identifier into a denylist/blocklist or revoke-store on logout, or document the intentional short-TTL stateless model for reviewer confirmation.", + "reviewer_question": "Where does logout revoke or denylist outstanding `access_jwt` tokens?" + }, + { + "id": "finding_579fa11bcc87ed34", + "category": "dynamic_review_required", + "severity": "medium", + "artifact_ids": [ + "artifact_6fe7bd9aa9441210" + ], + "evidence_ids": [ + "evidence_bab2541ae5986d41" + ], + "title": "Session regeneration evidence was not found near login", + "description": "An authentication transition was detected, but nearby static evidence did not show an explicit session regeneration, cookie reissue, or recognized framework-default rotation point.", + "suggested_fix": "Identify the framework's session rotation primitive and call it during authentication and privilege transitions.", + "reviewer_question": "Where is the session identifier rotated after this authentication transition?" + }, + { + "id": "finding_c13478d416e9902b", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_1f6194ee35663737" + ], + "evidence_ids": [ + "evidence_b61b525c331695f4", + "evidence_9ec068fd376b7d89", + "evidence_879be86c887ffc25", + "evidence_0430950bd7d6b709", + "evidence_751429b89c10824f", + "evidence_6b8e2bb85b4a5c59", + "evidence_400f7ea0d2ddb252", + "evidence_8251d59500cb750b", + "evidence_1a96e0988bbbf340", + "evidence_67f9caad64bf13a6" + ], + "title": "JWT `access_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + }, + { + "id": "finding_570c03dd66d92170", + "category": "dynamic_review_required", + "severity": "low", + "artifact_ids": [ + "artifact_6662c62fe10196d2" + ], + "evidence_ids": [ + "evidence_0277e4a57e2be5d0" + ], + "title": "Token `access_token` has provider-managed or dynamic handling", + "description": "Bearer/API-key behavior appears dynamic, provider-managed, config-driven, or server-to-server only, so the static evidence should be reviewed before treating lifecycle controls as deterministic.", + "suggested_fix": "Document the provider, wrapper, or server-side policy for issuance, storage, validation, scope, expiry, rotation, and revocation.", + "reviewer_question": "Which runtime configuration or provider settings govern this token lifecycle?" + }, + { + "id": "finding_07dfd3c55c8e74d9", + "category": "dynamic_review_required", + "severity": "low", + "artifact_ids": [ + "artifact_6662c62fe10196d2" + ], + "evidence_ids": [ + "evidence_8f65a9c03ac6856e", + "evidence_d40d72776f6ad953" + ], + "title": "Token `access_token` has boundary metadata without visible constraints", + "description": "Issuer, provider, or trust-boundary context was detected without nearby audience, service, tenant, or scope constraint evidence.", + "suggested_fix": "Document or expose the concrete audience, service, tenant, or scope policy that constrains this token.", + "reviewer_question": "Which local or provider policy prevents this token from being reused outside the intended boundary?" + }, + { + "id": "finding_51328750744a1db3", + "category": "framework_default_assumed", + "severity": "low", + "artifact_ids": [ + "artifact_1f6194ee35663737" + ], + "evidence_ids": [ + "evidence_400f7ea0d2ddb252" + ], + "title": "JWT `access_jwt` expiry enforcement relies on library defaults", + "description": "JWT validation appears to rely on the library default for expiration enforcement.", + "suggested_fix": "Make expiration enforcement explicit or document the library version and default.", + "reviewer_question": "Which JWT library version and settings determine expiration enforcement here?" + } + ] +} \ No newline at end of file diff --git a/tests/integration/snapshots/generic-js-jwt-crypto-trust-alg-none.json b/tests/integration/snapshots/generic-js-jwt-crypto-trust-alg-none.json new file mode 100644 index 0000000..7736125 --- /dev/null +++ b/tests/integration/snapshots/generic-js-jwt-crypto-trust-alg-none.json @@ -0,0 +1,658 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "auth.js", + "language": "java_script", + "artifacts": [ + { + "id": "artifact_3be9a9359fa26462", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.js", + "line": 8, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_19b19b3ff8c58764", + "evidence_2d5339153a64426a", + "evidence_32f7bf5d08e4949e", + "evidence_71052ce1d63e5977", + "evidence_7491e2691c2a91d9", + "evidence_756ae30fbb990830", + "evidence_8b4dac52bdc5a9a1", + "evidence_9faf5bd09db59388", + "evidence_cc285f22f612f773", + "evidence_d4b59bd0fc8ab376", + "evidence_e9efd84e48d00ea0" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_32f7bf5d08e4949e" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_19b19b3ff8c58764" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "PUBLIC_KEY", + "evidence_ids": [ + "evidence_cc285f22f612f773" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_9faf5bd09db59388" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_7491e2691c2a91d9" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "jsonwebtoken.verify default", + "evidence_ids": [ + "evidence_756ae30fbb990830" + ], + "confidence": "low" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_9faf5bd09db59388" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_756ae30fbb990830", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jsonwebtoken.verify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_7491e2691c2a91d9", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_32f7bf5d08e4949e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cc285f22f612f773", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_19b19b3ff8c58764", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 9, + "column": 17 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"none\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2d5339153a64426a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 9, + "column": 17 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"none\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9faf5bd09db59388", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 10, + "column": 13 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_71052ce1d63e5977", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 10, + "column": 13 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d4b59bd0fc8ab376", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 11, + "column": 15 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8b4dac52bdc5a9a1", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 11, + "column": 15 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e9efd84e48d00ea0", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 12, + "column": 22 + }, + "detector_id": "jwt.option.ignore_not_before", + "confidence": "high", + "excerpt": "false", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_3be9a9359fa26462", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.js", + "line": 8, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_19b19b3ff8c58764", + "evidence_2d5339153a64426a", + "evidence_32f7bf5d08e4949e", + "evidence_71052ce1d63e5977", + "evidence_7491e2691c2a91d9", + "evidence_756ae30fbb990830", + "evidence_8b4dac52bdc5a9a1", + "evidence_9faf5bd09db59388", + "evidence_cc285f22f612f773", + "evidence_d4b59bd0fc8ab376", + "evidence_e9efd84e48d00ea0" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_32f7bf5d08e4949e" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_19b19b3ff8c58764" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "PUBLIC_KEY", + "evidence_ids": [ + "evidence_cc285f22f612f773" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_9faf5bd09db59388" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_7491e2691c2a91d9" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "jsonwebtoken.verify default", + "evidence_ids": [ + "evidence_756ae30fbb990830" + ], + "confidence": "low" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_9faf5bd09db59388" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_d4b59bd0fc8ab376" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_756ae30fbb990830", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jsonwebtoken.verify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_7491e2691c2a91d9", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_32f7bf5d08e4949e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cc285f22f612f773", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 8, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_19b19b3ff8c58764", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 9, + "column": 17 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"none\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2d5339153a64426a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 9, + "column": 17 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"none\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9faf5bd09db59388", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 10, + "column": 13 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_71052ce1d63e5977", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 10, + "column": 13 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d4b59bd0fc8ab376", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 11, + "column": 15 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8b4dac52bdc5a9a1", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 11, + "column": 15 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e9efd84e48d00ea0", + "lifecycle_stage": "validate", + "location": { + "path": "auth.js", + "line": 12, + "column": 22 + }, + "detector_id": "jwt.option.ignore_not_before", + "confidence": "high", + "excerpt": "false", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_9c0d9318b54ed0b7", + "artifact_ids": [ + "artifact_3be9a9359fa26462" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_19b19b3ff8c58764", + "evidence_2d5339153a64426a", + "evidence_32f7bf5d08e4949e", + "evidence_71052ce1d63e5977", + "evidence_7491e2691c2a91d9", + "evidence_756ae30fbb990830", + "evidence_8b4dac52bdc5a9a1", + "evidence_9faf5bd09db59388", + "evidence_cc285f22f612f773", + "evidence_d4b59bd0fc8ab376", + "evidence_e9efd84e48d00ea0" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `access_jwt`?" + } + ], + "findings": [ + { + "id": "finding_640685938c485fae", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_3be9a9359fa26462" + ], + "evidence_ids": [ + "evidence_19b19b3ff8c58764", + "evidence_2d5339153a64426a" + ], + "title": "JWT `access_jwt` validation accepts the `none` algorithm", + "description": "JWT verification evidence includes an explicit `none` algorithm allow-list entry.", + "suggested_fix": "Remove `none` from accepted algorithms and pin the expected signing algorithm.", + "reviewer_question": "Is any validation path intentionally accepting unsigned JWTs?" + }, + { + "id": "finding_e804d2976aba0c7d", + "category": "framework_default_assumed", + "severity": "low", + "artifact_ids": [ + "artifact_3be9a9359fa26462" + ], + "evidence_ids": [ + "evidence_756ae30fbb990830" + ], + "title": "JWT `access_jwt` expiry enforcement relies on library defaults", + "description": "JWT validation appears to rely on the library default for expiration enforcement.", + "suggested_fix": "Make expiration enforcement explicit or document the library version and default.", + "reviewer_question": "Which JWT library version and settings determine expiration enforcement here?" + } + ] +} \ No newline at end of file diff --git a/tests/integration/snapshots/generic-python-jwt-and-reset.json b/tests/integration/snapshots/generic-python-jwt-and-reset.json new file mode 100644 index 0000000..60439e5 --- /dev/null +++ b/tests/integration/snapshots/generic-python-jwt-and-reset.json @@ -0,0 +1,2227 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "auth.py", + "language": "python", + "artifacts": [ + { + "id": "artifact_a87fee6937c3c5c7", + "artifact_type": "password_reset_token", + "display_name": "password_reset_token", + "locations": [ + { + "path": "auth.py", + "line": 1, + "column": 1 + }, + { + "path": "auth.py", + "line": 50, + "column": 1 + }, + { + "path": "auth.py", + "line": 56, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_c5a43c05b6279e38" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_80683a2cfc20663b" + ], + "expire": [ + "evidence_e9c83016ca543396" + ], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [] + }, + { + "id": "artifact_5ab48324a39c720a", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.py", + "line": 13, + "column": 12 + }, + { + "path": "auth.py", + "line": 32, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_0db6dad318cae9a9", + "evidence_1223d5c32d123d00", + "evidence_34d607a0bb1d3d05", + "evidence_66b972e811a6b871", + "evidence_8bf72e77fdfd44ba", + "evidence_8e62ee09cb0654d8", + "evidence_9160c5207317c308", + "evidence_b530b3c4d437e08f", + "evidence_c48ce5a507402d19", + "evidence_d884fc6584947a6b", + "evidence_e8028b0c166ba4b6" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_0ab760ae7e24a689", + "evidence_170f3794e1490ccc", + "evidence_1b688919407d804f", + "evidence_1d56066840a264c2", + "evidence_2c7d70c35fd4b8ad", + "evidence_a1e1544ec64e0957", + "evidence_d206780c19923731", + "evidence_f02b22268faf0d3f", + "evidence_fc5c6aaa1812035c", + "evidence_fec3b86f55948e8d" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_3c608d31e7bef137" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue, validate", + "evidence_ids": [ + "evidence_34d607a0bb1d3d05", + "evidence_f02b22268faf0d3f" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_9160c5207317c308", + "evidence_a1e1544ec64e0957" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_0ab760ae7e24a689", + "evidence_1223d5c32d123d00" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_8bf72e77fdfd44ba", + "evidence_fec3b86f55948e8d" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "now + timedelta(minutes=15)", + "evidence_ids": [ + "evidence_3c608d31e7bef137" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_fc5c6aaa1812035c" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "PyJWT decode default", + "evidence_ids": [ + "evidence_2c7d70c35fd4b8ad" + ], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "user_id", + "evidence_ids": [ + "evidence_e8028b0c166ba4b6" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_c48ce5a507402d19" + ], + "confidence": "high" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0db6dad318cae9a9" + ], + "confidence": "high" + }, + "email": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_d884fc6584947a6b" + ], + "confidence": "high" + }, + "email_verified": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_66b972e811a6b871" + ], + "confidence": "high" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_b530b3c4d437e08f" + ], + "confidence": "high" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_8bf72e77fdfd44ba", + "evidence_fec3b86f55948e8d" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + }, + "service": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_c48ce5a507402d19" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_a567090b3e97a4ac", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "auth.py", + "line": 32, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_9542572d86d58518" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [ + "evidence_29a5362edc28c085", + "evidence_6d7d27e2299a1798" + ] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_6d7d27e2299a1798" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_29a5362edc28c085" + ], + "confidence": "high" + }, + "scope": { + "state": "present", + "evidence_ids": [ + "evidence_9542572d86d58518" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_0501ca124ed65f0c", + "artifact_type": "access_jwt", + "display_name": "legacy_access_jwt", + "locations": [ + { + "path": "auth.py", + "line": 42, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_0137a9a1c5e9adb6", + "evidence_2d742ec24e4a7048", + "evidence_3d20bc7d9c1ee3c2", + "evidence_40ada113ad4ede86", + "evidence_95a08416ba76dc4e", + "evidence_aa4c099b052917bb", + "evidence_b82ffcde06e94810", + "evidence_d9f3ca1a5b645e72", + "evidence_e199d2bbe1351c3b" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_3d20bc7d9c1ee3c2" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_d9f3ca1a5b645e72" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_0137a9a1c5e9adb6" + ], + "confidence": "high" + }, + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_95a08416ba76dc4e" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_aa4c099b052917bb" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "verify_exp: false", + "evidence_ids": [ + "evidence_40ada113ad4ede86" + ], + "confidence": "high" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_95a08416ba76dc4e" + ], + "confidence": "high" + }, + "provider": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_e9c83016ca543396", + "lifecycle_stage": "expire", + "location": { + "path": "auth.py", + "line": 1, + "column": 1 + }, + "detector_id": "reset.expire", + "confidence": "medium", + "excerpt": "password_reset_token expire evidence", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_34d607a0bb1d3d05", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 13, + "column": 12 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "pyjwt.encode issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e8028b0c166ba4b6", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 15, + "column": 20 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c48ce5a507402d19", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 16, + "column": 29 + }, + "detector_id": "jwt.attribute.workspace_id", + "confidence": "high", + "excerpt": "workspace ID claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0db6dad318cae9a9", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 17, + "column": 23 + }, + "detector_id": "jwt.attribute.groups", + "confidence": "high", + "excerpt": "groups claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d884fc6584947a6b", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 18, + "column": 22 + }, + "detector_id": "jwt.attribute.email", + "confidence": "high", + "excerpt": "email claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_66b972e811a6b871", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 19, + "column": 31 + }, + "detector_id": "jwt.attribute.email_verified", + "confidence": "high", + "excerpt": "email verified claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b530b3c4d437e08f", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 20, + "column": 20 + }, + "detector_id": "jwt.attribute.auth_class", + "confidence": "high", + "excerpt": "auth class claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8bf72e77fdfd44ba", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 21, + "column": 20 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8e62ee09cb0654d8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 22, + "column": 20 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3c608d31e7bef137", + "lifecycle_stage": "expire", + "location": { + "path": "auth.py", + "line": 24, + "column": 20 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "now + timedelta(minutes=15)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1223d5c32d123d00", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 26, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9160c5207317c308", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 27, + "column": 19 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "\"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9542572d86d58518", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.scope", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2c7d70c35fd4b8ad", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "PyJWT decode enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_fc5c6aaa1812035c", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f02b22268faf0d3f", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_29a5362edc28c085", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.boundary.audience", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6d7d27e2299a1798", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0ab760ae7e24a689", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 34, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a1e1544ec64e0957", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 35, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d206780c19923731", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 35, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_fec3b86f55948e8d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 36, + "column": 16 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_170f3794e1490ccc", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 36, + "column": 16 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1b688919407d804f", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 37, + "column": 18 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1d56066840a264c2", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 37, + "column": 18 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_95a08416ba76dc4e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_40ada113ad4ede86", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "PyJWT expiry enforcement is disabled with verify_exp: false", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e199d2bbe1351c3b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_aa4c099b052917bb", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3d20bc7d9c1ee3c2", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0137a9a1c5e9adb6", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 44, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d9f3ca1a5b645e72", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 45, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b82ffcde06e94810", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 45, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2d742ec24e4a7048", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 46, + "column": 32 + }, + "detector_id": "jwt.option.ignore_expiration", + "confidence": "high", + "excerpt": "False", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c5a43c05b6279e38", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 50, + "column": 1 + }, + "detector_id": "reset.issue", + "confidence": "medium", + "excerpt": "password_reset_token issue evidence", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_80683a2cfc20663b", + "lifecycle_stage": "revoke", + "location": { + "path": "auth.py", + "line": 56, + "column": 1 + }, + "detector_id": "reset.single_use", + "confidence": "medium", + "excerpt": "password_reset_token revoke evidence", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_a87fee6937c3c5c7", + "artifact_type": "password_reset_token", + "display_name": "password_reset_token", + "locations": [ + { + "path": "auth.py", + "line": 1, + "column": 1 + }, + { + "path": "auth.py", + "line": 50, + "column": 1 + }, + { + "path": "auth.py", + "line": 56, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_c5a43c05b6279e38" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_80683a2cfc20663b" + ], + "expire": [ + "evidence_e9c83016ca543396" + ], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [] + }, + { + "id": "artifact_5ab48324a39c720a", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.py", + "line": 13, + "column": 12 + }, + { + "path": "auth.py", + "line": 32, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_0db6dad318cae9a9", + "evidence_1223d5c32d123d00", + "evidence_34d607a0bb1d3d05", + "evidence_66b972e811a6b871", + "evidence_8bf72e77fdfd44ba", + "evidence_8e62ee09cb0654d8", + "evidence_9160c5207317c308", + "evidence_b530b3c4d437e08f", + "evidence_c48ce5a507402d19", + "evidence_d884fc6584947a6b", + "evidence_e8028b0c166ba4b6" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_0ab760ae7e24a689", + "evidence_170f3794e1490ccc", + "evidence_1b688919407d804f", + "evidence_1d56066840a264c2", + "evidence_2c7d70c35fd4b8ad", + "evidence_a1e1544ec64e0957", + "evidence_d206780c19923731", + "evidence_f02b22268faf0d3f", + "evidence_fc5c6aaa1812035c", + "evidence_fec3b86f55948e8d" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_3c608d31e7bef137" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue, validate", + "evidence_ids": [ + "evidence_34d607a0bb1d3d05", + "evidence_f02b22268faf0d3f" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_9160c5207317c308", + "evidence_a1e1544ec64e0957" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_0ab760ae7e24a689", + "evidence_1223d5c32d123d00" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_8bf72e77fdfd44ba", + "evidence_fec3b86f55948e8d" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "now + timedelta(minutes=15)", + "evidence_ids": [ + "evidence_3c608d31e7bef137" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_fc5c6aaa1812035c" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "PyJWT decode default", + "evidence_ids": [ + "evidence_2c7d70c35fd4b8ad" + ], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "user_id", + "evidence_ids": [ + "evidence_e8028b0c166ba4b6" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_c48ce5a507402d19" + ], + "confidence": "high" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0db6dad318cae9a9" + ], + "confidence": "high" + }, + "email": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_d884fc6584947a6b" + ], + "confidence": "high" + }, + "email_verified": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_66b972e811a6b871" + ], + "confidence": "high" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_b530b3c4d437e08f" + ], + "confidence": "high" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_8bf72e77fdfd44ba", + "evidence_fec3b86f55948e8d" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + }, + "service": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_c48ce5a507402d19" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_1b688919407d804f", + "evidence_8e62ee09cb0654d8" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_a567090b3e97a4ac", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "auth.py", + "line": 32, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_9542572d86d58518" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [ + "evidence_29a5362edc28c085", + "evidence_6d7d27e2299a1798" + ] + }, + "confidence": "high", + "framework_hints": [ + "python" + ], + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_6d7d27e2299a1798" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_29a5362edc28c085" + ], + "confidence": "high" + }, + "scope": { + "state": "present", + "evidence_ids": [ + "evidence_9542572d86d58518" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_0501ca124ed65f0c", + "artifact_type": "access_jwt", + "display_name": "legacy_access_jwt", + "locations": [ + { + "path": "auth.py", + "line": 42, + "column": 12 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_0137a9a1c5e9adb6", + "evidence_2d742ec24e4a7048", + "evidence_3d20bc7d9c1ee3c2", + "evidence_40ada113ad4ede86", + "evidence_95a08416ba76dc4e", + "evidence_aa4c099b052917bb", + "evidence_b82ffcde06e94810", + "evidence_d9f3ca1a5b645e72", + "evidence_e199d2bbe1351c3b" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "pyjwt" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_3d20bc7d9c1ee3c2" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_d9f3ca1a5b645e72" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_0137a9a1c5e9adb6" + ], + "confidence": "high" + }, + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_95a08416ba76dc4e" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_aa4c099b052917bb" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "verify_exp: false", + "evidence_ids": [ + "evidence_40ada113ad4ede86" + ], + "confidence": "high" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_95a08416ba76dc4e" + ], + "confidence": "high" + }, + "provider": { + "state": "missing", + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_e9c83016ca543396", + "lifecycle_stage": "expire", + "location": { + "path": "auth.py", + "line": 1, + "column": 1 + }, + "detector_id": "reset.expire", + "confidence": "medium", + "excerpt": "password_reset_token expire evidence", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_34d607a0bb1d3d05", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 13, + "column": 12 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "pyjwt.encode issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e8028b0c166ba4b6", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 15, + "column": 20 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c48ce5a507402d19", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 16, + "column": 29 + }, + "detector_id": "jwt.attribute.workspace_id", + "confidence": "high", + "excerpt": "workspace ID claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0db6dad318cae9a9", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 17, + "column": 23 + }, + "detector_id": "jwt.attribute.groups", + "confidence": "high", + "excerpt": "groups claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d884fc6584947a6b", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 18, + "column": 22 + }, + "detector_id": "jwt.attribute.email", + "confidence": "high", + "excerpt": "email claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_66b972e811a6b871", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 19, + "column": 31 + }, + "detector_id": "jwt.attribute.email_verified", + "confidence": "high", + "excerpt": "email verified claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b530b3c4d437e08f", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 20, + "column": 20 + }, + "detector_id": "jwt.attribute.auth_class", + "confidence": "high", + "excerpt": "auth class claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8bf72e77fdfd44ba", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 21, + "column": 20 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8e62ee09cb0654d8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 22, + "column": 20 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3c608d31e7bef137", + "lifecycle_stage": "expire", + "location": { + "path": "auth.py", + "line": 24, + "column": 20 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "now + timedelta(minutes=15)", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1223d5c32d123d00", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 26, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9160c5207317c308", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 27, + "column": 19 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "\"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9542572d86d58518", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.scope", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2c7d70c35fd4b8ad", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "PyJWT decode enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_fc5c6aaa1812035c", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f02b22268faf0d3f", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_29a5362edc28c085", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.boundary.audience", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6d7d27e2299a1798", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.py", + "line": 32, + "column": 12 + }, + "detector_id": "bearer.boundary.issuer", + "confidence": "high", + "excerpt": "jwt.decode(\n token,\n JWT_SECRET,\n algorithms=[\"[REDACTED]\"],\n issuer=ISSUER,\n audience=AUDIENCE,\n )", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0ab760ae7e24a689", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 34, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a1e1544ec64e0957", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 35, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d206780c19923731", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 35, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_fec3b86f55948e8d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 36, + "column": 16 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_170f3794e1490ccc", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 36, + "column": 16 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1b688919407d804f", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 37, + "column": 18 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1d56066840a264c2", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 37, + "column": 18 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_95a08416ba76dc4e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_40ada113ad4ede86", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "PyJWT expiry enforcement is disabled with verify_exp: false", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e199d2bbe1351c3b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_aa4c099b052917bb", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "PyJWT decode verifies signatures when a key is supplied", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3d20bc7d9c1ee3c2", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 42, + "column": 12 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "pyjwt.decode validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0137a9a1c5e9adb6", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 44, + "column": 9 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_d9f3ca1a5b645e72", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 45, + "column": 20 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b82ffcde06e94810", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 45, + "column": 20 + }, + "detector_id": "jwt.option.algorithms", + "confidence": "high", + "excerpt": "[\"HS256\"]", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2d742ec24e4a7048", + "lifecycle_stage": "validate", + "location": { + "path": "auth.py", + "line": 46, + "column": 32 + }, + "detector_id": "jwt.option.ignore_expiration", + "confidence": "high", + "excerpt": "False", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c5a43c05b6279e38", + "lifecycle_stage": "issue", + "location": { + "path": "auth.py", + "line": 50, + "column": 1 + }, + "detector_id": "reset.issue", + "confidence": "medium", + "excerpt": "password_reset_token issue evidence", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_80683a2cfc20663b", + "lifecycle_stage": "revoke", + "location": { + "path": "auth.py", + "line": 56, + "column": 1 + }, + "detector_id": "reset.single_use", + "confidence": "medium", + "excerpt": "password_reset_token revoke evidence", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_982133d31f7e5221", + "artifact_ids": [ + "artifact_0501ca124ed65f0c" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_0137a9a1c5e9adb6", + "evidence_2d742ec24e4a7048", + "evidence_3d20bc7d9c1ee3c2", + "evidence_40ada113ad4ede86", + "evidence_95a08416ba76dc4e", + "evidence_aa4c099b052917bb", + "evidence_b82ffcde06e94810", + "evidence_d9f3ca1a5b645e72", + "evidence_e199d2bbe1351c3b" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_ba5f204467cb2a68", + "artifact_ids": [ + "artifact_5ab48324a39c720a" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_0db6dad318cae9a9", + "evidence_1223d5c32d123d00", + "evidence_34d607a0bb1d3d05", + "evidence_66b972e811a6b871", + "evidence_8bf72e77fdfd44ba", + "evidence_8e62ee09cb0654d8", + "evidence_9160c5207317c308", + "evidence_b530b3c4d437e08f", + "evidence_c48ce5a507402d19", + "evidence_d884fc6584947a6b", + "evidence_e8028b0c166ba4b6" + ] + }, + { + "stage": "validate", + "evidence_ids": [ + "evidence_0ab760ae7e24a689", + "evidence_170f3794e1490ccc", + "evidence_1b688919407d804f", + "evidence_1d56066840a264c2", + "evidence_2c7d70c35fd4b8ad", + "evidence_a1e1544ec64e0957", + "evidence_d206780c19923731", + "evidence_f02b22268faf0d3f", + "evidence_fc5c6aaa1812035c", + "evidence_fec3b86f55948e8d" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_3c608d31e7bef137" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `access_jwt`?" + }, + { + "id": "lifecycle_path_a44c356c99aa4aab", + "artifact_ids": [ + "artifact_a567090b3e97a4ac" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_9542572d86d58518" + ] + }, + { + "stage": "introspect", + "evidence_ids": [ + "evidence_29a5362edc28c085", + "evidence_6d7d27e2299a1798" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_c9f1e8d4e5b9d941", + "artifact_ids": [ + "artifact_a87fee6937c3c5c7" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_c5a43c05b6279e38" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_80683a2cfc20663b" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_e9c83016ca543396" + ] + } + ], + "confidence": "medium", + "dynamic": false, + "reviewer_question": null + } + ], + "findings": [ + { + "id": "finding_660d8ee7895a862d", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_0501ca124ed65f0c" + ], + "evidence_ids": [ + "evidence_40ada113ad4ede86" + ], + "title": "JWT `legacy_access_jwt` expiry enforcement is disabled or absent", + "description": "Evidence shows this JWT validation path does not enforce expiration.", + "suggested_fix": "Require expiration enforcement when validating JWTs.", + "reviewer_question": "Can expired tokens be accepted on this path?" + }, + { + "id": "finding_e3f631efe8ee5d51", + "category": "missing_validation_evidence", + "severity": "medium", + "artifact_ids": [ + "artifact_0501ca124ed65f0c" + ], + "evidence_ids": [ + "evidence_95a08416ba76dc4e" + ], + "title": "JWT `legacy_access_jwt` verification has no audience evidence", + "description": "JWT verification evidence does not include an expected audience check.", + "suggested_fix": "Require an expected audience when verifying JWTs.", + "reviewer_question": "Should this service reject tokens with an unexpected audience?" + }, + { + "id": "finding_dbaf38232e9298df", + "category": "missing_validation_evidence", + "severity": "medium", + "artifact_ids": [ + "artifact_0501ca124ed65f0c" + ], + "evidence_ids": [ + "evidence_e199d2bbe1351c3b" + ], + "title": "JWT `legacy_access_jwt` verification has no issuer evidence", + "description": "JWT verification evidence does not include an expected issuer check.", + "suggested_fix": "Require an expected issuer when verifying JWTs.", + "reviewer_question": "Should this service reject tokens with an unexpected issuer?" + }, + { + "id": "finding_f4a7e65729dbbfbd", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_0501ca124ed65f0c" + ], + "evidence_ids": [ + "evidence_3d20bc7d9c1ee3c2", + "evidence_d9f3ca1a5b645e72", + "evidence_0137a9a1c5e9adb6", + "evidence_e199d2bbe1351c3b", + "evidence_95a08416ba76dc4e", + "evidence_aa4c099b052917bb", + "evidence_40ada113ad4ede86", + "evidence_b82ffcde06e94810", + "evidence_2d742ec24e4a7048" + ], + "title": "JWT `legacy_access_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + }, + { + "id": "finding_5a562da4e436373c", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_5ab48324a39c720a" + ], + "evidence_ids": [ + "evidence_f02b22268faf0d3f", + "evidence_a1e1544ec64e0957", + "evidence_0ab760ae7e24a689", + "evidence_fec3b86f55948e8d", + "evidence_1b688919407d804f", + "evidence_fc5c6aaa1812035c", + "evidence_2c7d70c35fd4b8ad", + "evidence_d206780c19923731", + "evidence_1d56066840a264c2", + "evidence_170f3794e1490ccc" + ], + "title": "JWT `access_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + }, + { + "id": "finding_3279b41414008638", + "category": "framework_default_assumed", + "severity": "low", + "artifact_ids": [ + "artifact_5ab48324a39c720a" + ], + "evidence_ids": [ + "evidence_2c7d70c35fd4b8ad" + ], + "title": "JWT `access_jwt` expiry enforcement relies on library defaults", + "description": "JWT validation appears to rely on the library default for expiration enforcement.", + "suggested_fix": "Make expiration enforcement explicit or document the library version and default.", + "reviewer_question": "Which JWT library version and settings determine expiration enforcement here?" + } + ] +} \ No newline at end of file diff --git a/tests/integration/snapshots/generic-ts-jwt-validation.json b/tests/integration/snapshots/generic-ts-jwt-validation.json new file mode 100644 index 0000000..9893b9e --- /dev/null +++ b/tests/integration/snapshots/generic-ts-jwt-validation.json @@ -0,0 +1,1874 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "auth.ts", + "language": "type_script", + "artifacts": [ + { + "id": "artifact_59b937e65bbea018", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.ts", + "line": 17, + "column": 10 + }, + { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + { + "path": "auth.ts", + "line": 40, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_08cd1591819587b4", + "evidence_0dcaee30a4dba499", + "evidence_0f5a9f0d5b2b4dc0", + "evidence_48517d1eba13bd74", + "evidence_5fe4ec412b8292a8", + "evidence_7e70705f466b78bd", + "evidence_7f684ad11256f8c0", + "evidence_a16bbcfe379030e8", + "evidence_e2260e9f2965c63f", + "evidence_f014933fd3dda9ba", + "evidence_fc13667fe57d80c7" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_142607e448a7c55a", + "evidence_2c55d5235636e020", + "evidence_2f1f7bb096bf5b6b", + "evidence_5794655114427a01", + "evidence_5ff7f5334ab5b971", + "evidence_c8db640fa216830b", + "evidence_eb240a6458b51f7d", + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb", + "evidence_ffb17cdf5e5e8b20" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_7f761cb194aa88ef" + ], + "introspect": [ + "evidence_1c994198cf6dc30c" + ] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "decode_without_verify, issue, validate", + "evidence_ids": [ + "evidence_1c994198cf6dc30c", + "evidence_eb240a6458b51f7d", + "evidence_fc13667fe57d80c7" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_7f684ad11256f8c0", + "evidence_ffb17cdf5e5e8b20" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_08cd1591819587b4", + "evidence_2c55d5235636e020" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_7f761cb194aa88ef" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "missing", + "value": "decode_without_verify, verified", + "evidence_ids": [ + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "decode_without_verify, jsonwebtoken.verify default", + "evidence_ids": [ + "evidence_5794655114427a01", + "evidence_c8db640fa216830b" + ], + "confidence": "high" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "userId", + "evidence_ids": [ + "evidence_0f5a9f0d5b2b4dc0" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_a16bbcfe379030e8" + ], + "confidence": "high" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_48517d1eba13bd74" + ], + "confidence": "high" + }, + "scopes": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0dcaee30a4dba499" + ], + "confidence": "high" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_e2260e9f2965c63f" + ], + "confidence": "high" + }, + "email_verified": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_f014933fd3dda9ba" + ], + "confidence": "high" + }, + "auth_method": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_7e70705f466b78bd" + ], + "confidence": "high" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_08cd1591819587b4", + "evidence_2c55d5235636e020" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + }, + "tenant": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_a16bbcfe379030e8" + ], + "confidence": "high" + }, + "scope": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0dcaee30a4dba499" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_e6e873017238673a", + "artifact_type": "access_jwt", + "display_name": "legacy_access_jwt", + "locations": [ + { + "path": "auth.ts", + "line": 36, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_34b40fb747ca2a6e", + "evidence_3544c272b486ee48", + "evidence_49fd2c2d6edf3ffa", + "evidence_5b152f496cd87501", + "evidence_7d0978c8a0dc497a", + "evidence_8f9a6b8553ad8d39", + "evidence_c262b6b3fe4e626d" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_c262b6b3fe4e626d" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_34b40fb747ca2a6e" + ], + "confidence": "high" + }, + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_8f9a6b8553ad8d39" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_49fd2c2d6edf3ffa" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "ignoreExpiration: true", + "evidence_ids": [ + "evidence_5b152f496cd87501" + ], + "confidence": "high" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_8f9a6b8553ad8d39" + ], + "confidence": "high" + }, + "provider": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_0f5a9f0d5b2b4dc0", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 9, + "column": 10 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a16bbcfe379030e8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 10, + "column": 16 + }, + "detector_id": "jwt.attribute.tenant_id", + "confidence": "high", + "excerpt": "tenant ID claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_48517d1eba13bd74", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 11, + "column": 12 + }, + "detector_id": "jwt.attribute.roles", + "confidence": "high", + "excerpt": "roles claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0dcaee30a4dba499", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 12, + "column": 12 + }, + "detector_id": "jwt.attribute.scopes", + "confidence": "high", + "excerpt": "scopes claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e2260e9f2965c63f", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 13, + "column": 12 + }, + "detector_id": "jwt.attribute.email", + "confidence": "high", + "excerpt": "email claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f014933fd3dda9ba", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 14, + "column": 20 + }, + "detector_id": "jwt.attribute.email_verified", + "confidence": "high", + "excerpt": "email verified claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e70705f466b78bd", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 15, + "column": 10 + }, + "detector_id": "jwt.attribute.auth_method", + "confidence": "high", + "excerpt": "auth method claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_fc13667fe57d80c7", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 17, + "column": 10 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "jsonwebtoken.sign issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7f684ad11256f8c0", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 19, + "column": 5 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_08cd1591819587b4", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 21, + "column": 15 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5fe4ec412b8292a8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 22, + "column": 17 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7f761cb194aa88ef", + "lifecycle_stage": "expire", + "location": { + "path": "auth.ts", + "line": 23, + "column": 18 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "\"15m\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c8db640fa216830b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jsonwebtoken.verify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ef5a75f64cb806d4", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_eb240a6458b51f7d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ffb17cdf5e5e8b20", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2c55d5235636e020", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 30, + "column": 13 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2f1f7bb096bf5b6b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 30, + "column": 13 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_142607e448a7c55a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 31, + "column": 15 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5ff7f5334ab5b971", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 31, + "column": 15 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8f9a6b8553ad8d39", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5b152f496cd87501", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "JWT expiry enforcement is disabled with ignoreExpiration: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7d0978c8a0dc497a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49fd2c2d6edf3ffa", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c262b6b3fe4e626d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_34b40fb747ca2a6e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3544c272b486ee48", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 60 + }, + "detector_id": "jwt.option.ignore_expiration", + "confidence": "high", + "excerpt": "true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5794655114427a01", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "jsonwebtoken.decode does not enforce JWT expiration", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f60f8290316ad4bb", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.decode decodes JWTs without signature verification", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1c994198cf6dc30c", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.decode_without_verify", + "confidence": "high", + "excerpt": "jsonwebtoken.decode decode_without_verify call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_59b937e65bbea018", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "auth.ts", + "line": 17, + "column": 10 + }, + { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + { + "path": "auth.ts", + "line": 40, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_08cd1591819587b4", + "evidence_0dcaee30a4dba499", + "evidence_0f5a9f0d5b2b4dc0", + "evidence_48517d1eba13bd74", + "evidence_5fe4ec412b8292a8", + "evidence_7e70705f466b78bd", + "evidence_7f684ad11256f8c0", + "evidence_a16bbcfe379030e8", + "evidence_e2260e9f2965c63f", + "evidence_f014933fd3dda9ba", + "evidence_fc13667fe57d80c7" + ], + "store": [], + "transmit": [], + "validate": [ + "evidence_142607e448a7c55a", + "evidence_2c55d5235636e020", + "evidence_2f1f7bb096bf5b6b", + "evidence_5794655114427a01", + "evidence_5ff7f5334ab5b971", + "evidence_c8db640fa216830b", + "evidence_eb240a6458b51f7d", + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb", + "evidence_ffb17cdf5e5e8b20" + ], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_7f761cb194aa88ef" + ], + "introspect": [ + "evidence_1c994198cf6dc30c" + ] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "decode_without_verify, issue, validate", + "evidence_ids": [ + "evidence_1c994198cf6dc30c", + "evidence_eb240a6458b51f7d", + "evidence_fc13667fe57d80c7" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_7f684ad11256f8c0", + "evidence_ffb17cdf5e5e8b20" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_08cd1591819587b4", + "evidence_2c55d5235636e020" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_7f761cb194aa88ef" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "missing", + "value": "decode_without_verify, verified", + "evidence_ids": [ + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "decode_without_verify, jsonwebtoken.verify default", + "evidence_ids": [ + "evidence_5794655114427a01", + "evidence_c8db640fa216830b" + ], + "confidence": "high" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "userId", + "evidence_ids": [ + "evidence_0f5a9f0d5b2b4dc0" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_a16bbcfe379030e8" + ], + "confidence": "high" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_48517d1eba13bd74" + ], + "confidence": "high" + }, + "scopes": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0dcaee30a4dba499" + ], + "confidence": "high" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_e2260e9f2965c63f" + ], + "confidence": "high" + }, + "email_verified": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_f014933fd3dda9ba" + ], + "confidence": "high" + }, + "auth_method": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_7e70705f466b78bd" + ], + "confidence": "high" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "ISSUER", + "evidence_ids": [ + "evidence_08cd1591819587b4", + "evidence_2c55d5235636e020" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + }, + "tenant": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_a16bbcfe379030e8" + ], + "confidence": "high" + }, + "scope": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_0dcaee30a4dba499" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "AUDIENCE", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_5fe4ec412b8292a8" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_e6e873017238673a", + "artifact_type": "access_jwt", + "display_name": "legacy_access_jwt", + "locations": [ + { + "path": "auth.ts", + "line": 36, + "column": 10 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_34b40fb747ca2a6e", + "evidence_3544c272b486ee48", + "evidence_49fd2c2d6edf3ffa", + "evidence_5b152f496cd87501", + "evidence_7d0978c8a0dc497a", + "evidence_8f9a6b8553ad8d39", + "evidence_c262b6b3fe4e626d" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jsonwebtoken" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_c262b6b3fe4e626d" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "JWT_SECRET", + "evidence_ids": [ + "evidence_34b40fb747ca2a6e" + ], + "confidence": "high" + }, + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_8f9a6b8553ad8d39" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_49fd2c2d6edf3ffa" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "missing", + "value": "ignoreExpiration: true", + "evidence_ids": [ + "evidence_5b152f496cd87501" + ], + "confidence": "high" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + }, + "audience": { + "state": "missing", + "evidence_ids": [ + "evidence_8f9a6b8553ad8d39" + ], + "confidence": "high" + }, + "provider": { + "state": "missing", + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "confidence": "high" + } + } + } + ], + "evidence": [ + { + "id": "evidence_0f5a9f0d5b2b4dc0", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 9, + "column": 10 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a16bbcfe379030e8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 10, + "column": 16 + }, + "detector_id": "jwt.attribute.tenant_id", + "confidence": "high", + "excerpt": "tenant ID claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_48517d1eba13bd74", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 11, + "column": 12 + }, + "detector_id": "jwt.attribute.roles", + "confidence": "high", + "excerpt": "roles claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_0dcaee30a4dba499", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 12, + "column": 12 + }, + "detector_id": "jwt.attribute.scopes", + "confidence": "high", + "excerpt": "scopes claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e2260e9f2965c63f", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 13, + "column": 12 + }, + "detector_id": "jwt.attribute.email", + "confidence": "high", + "excerpt": "email claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f014933fd3dda9ba", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 14, + "column": 20 + }, + "detector_id": "jwt.attribute.email_verified", + "confidence": "high", + "excerpt": "email verified claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7e70705f466b78bd", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 15, + "column": 10 + }, + "detector_id": "jwt.attribute.auth_method", + "confidence": "high", + "excerpt": "auth method claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_fc13667fe57d80c7", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 17, + "column": 10 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "jsonwebtoken.sign issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7f684ad11256f8c0", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 19, + "column": 5 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_08cd1591819587b4", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 21, + "column": 15 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5fe4ec412b8292a8", + "lifecycle_stage": "issue", + "location": { + "path": "auth.ts", + "line": 22, + "column": 17 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7f761cb194aa88ef", + "lifecycle_stage": "expire", + "location": { + "path": "auth.ts", + "line": 23, + "column": 18 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": "\"15m\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c8db640fa216830b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jsonwebtoken.verify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_ef5a75f64cb806d4", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_eb240a6458b51f7d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ffb17cdf5e5e8b20", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 29, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2c55d5235636e020", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 30, + "column": 13 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "ISSUER", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2f1f7bb096bf5b6b", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 30, + "column": 13 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_142607e448a7c55a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 31, + "column": 15 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "AUDIENCE", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5ff7f5334ab5b971", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 31, + "column": 15 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_8f9a6b8553ad8d39", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5b152f496cd87501", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "JWT expiry enforcement is disabled with ignoreExpiration: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_7d0978c8a0dc497a", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49fd2c2d6edf3ffa", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.verify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_c262b6b3fe4e626d", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 10 + }, + "detector_id": "jwt.validate", + "confidence": "high", + "excerpt": "jsonwebtoken.verify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_34b40fb747ca2a6e", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 28 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3544c272b486ee48", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 36, + "column": 60 + }, + "detector_id": "jwt.option.ignore_expiration", + "confidence": "high", + "excerpt": "true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5794655114427a01", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "high", + "excerpt": "jsonwebtoken.decode does not enforce JWT expiration", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f60f8290316ad4bb", + "lifecycle_stage": "validate", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jsonwebtoken.decode decodes JWTs without signature verification", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1c994198cf6dc30c", + "lifecycle_stage": "introspect", + "location": { + "path": "auth.ts", + "line": 40, + "column": 10 + }, + "detector_id": "jwt.decode_without_verify", + "confidence": "high", + "excerpt": "jsonwebtoken.decode decode_without_verify call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_8cc5c35588ac1ebf", + "artifact_ids": [ + "artifact_59b937e65bbea018" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_08cd1591819587b4", + "evidence_0dcaee30a4dba499", + "evidence_0f5a9f0d5b2b4dc0", + "evidence_48517d1eba13bd74", + "evidence_5fe4ec412b8292a8", + "evidence_7e70705f466b78bd", + "evidence_7f684ad11256f8c0", + "evidence_a16bbcfe379030e8", + "evidence_e2260e9f2965c63f", + "evidence_f014933fd3dda9ba", + "evidence_fc13667fe57d80c7" + ] + }, + { + "stage": "validate", + "evidence_ids": [ + "evidence_142607e448a7c55a", + "evidence_2c55d5235636e020", + "evidence_2f1f7bb096bf5b6b", + "evidence_5794655114427a01", + "evidence_5ff7f5334ab5b971", + "evidence_c8db640fa216830b", + "evidence_eb240a6458b51f7d", + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb", + "evidence_ffb17cdf5e5e8b20" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_7f761cb194aa88ef" + ] + }, + { + "stage": "introspect", + "evidence_ids": [ + "evidence_1c994198cf6dc30c" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `access_jwt`?" + }, + { + "id": "lifecycle_path_1ffd533199877bad", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_34b40fb747ca2a6e", + "evidence_3544c272b486ee48", + "evidence_49fd2c2d6edf3ffa", + "evidence_5b152f496cd87501", + "evidence_7d0978c8a0dc497a", + "evidence_8f9a6b8553ad8d39", + "evidence_c262b6b3fe4e626d" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + } + ], + "findings": [ + { + "id": "finding_f1fcf1961b0774db", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_59b937e65bbea018" + ], + "evidence_ids": [ + "evidence_5794655114427a01", + "evidence_c8db640fa216830b" + ], + "title": "JWT `access_jwt` expiry enforcement is disabled or absent", + "description": "Evidence shows this JWT validation path does not enforce expiration.", + "suggested_fix": "Require expiration enforcement when validating JWTs.", + "reviewer_question": "Can expired tokens be accepted on this path?" + }, + { + "id": "finding_ba3477b3bf615bdd", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_59b937e65bbea018" + ], + "evidence_ids": [ + "evidence_ef5a75f64cb806d4", + "evidence_f60f8290316ad4bb" + ], + "title": "JWT `access_jwt` is decoded or parsed without signature verification", + "description": "Evidence shows this JWT path does not verify signatures before reading claims.", + "suggested_fix": "Use a verification API with the expected issuer, audience, and signing key before trusting claims.", + "reviewer_question": "Is this decoded JWT used only for non-security-sensitive introspection?" + }, + { + "id": "finding_eb282b5477ced485", + "category": "high_confidence_misconfiguration", + "severity": "high", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "evidence_ids": [ + "evidence_5b152f496cd87501" + ], + "title": "JWT `legacy_access_jwt` expiry enforcement is disabled or absent", + "description": "Evidence shows this JWT validation path does not enforce expiration.", + "suggested_fix": "Require expiration enforcement when validating JWTs.", + "reviewer_question": "Can expired tokens be accepted on this path?" + }, + { + "id": "finding_83e5da31912ff3fb", + "category": "missing_validation_evidence", + "severity": "medium", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "evidence_ids": [ + "evidence_7d0978c8a0dc497a" + ], + "title": "JWT `legacy_access_jwt` verification has no issuer evidence", + "description": "JWT verification evidence does not include an expected issuer check.", + "suggested_fix": "Require an expected issuer when verifying JWTs.", + "reviewer_question": "Should this service reject tokens with an unexpected issuer?" + }, + { + "id": "finding_90843c170feb8bb3", + "category": "missing_validation_evidence", + "severity": "medium", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "evidence_ids": [ + "evidence_8f9a6b8553ad8d39" + ], + "title": "JWT `legacy_access_jwt` verification has no audience evidence", + "description": "JWT verification evidence does not include an expected audience check.", + "suggested_fix": "Require an expected audience when verifying JWTs.", + "reviewer_question": "Should this service reject tokens with an unexpected audience?" + }, + { + "id": "finding_8697abef77df826d", + "category": "framework_default_assumed", + "severity": "medium", + "artifact_ids": [ + "artifact_59b937e65bbea018" + ], + "evidence_ids": [ + "evidence_eb240a6458b51f7d", + "evidence_ffb17cdf5e5e8b20", + "evidence_2c55d5235636e020", + "evidence_142607e448a7c55a", + "evidence_ef5a75f64cb806d4", + "evidence_c8db640fa216830b", + "evidence_5ff7f5334ab5b971", + "evidence_2f1f7bb096bf5b6b", + "evidence_f60f8290316ad4bb", + "evidence_5794655114427a01" + ], + "title": "JWT `access_jwt` 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.", + "suggested_fix": "Pass an explicit safe algorithms allow-list when verifying JWTs.", + "reviewer_question": "Which library version and configuration define the accepted JWT algorithms here?" + }, + { + "id": "finding_ac53ce93423700e5", + "category": "framework_default_assumed", + "severity": "medium", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "evidence_ids": [ + "evidence_c262b6b3fe4e626d", + "evidence_34b40fb747ca2a6e", + "evidence_7d0978c8a0dc497a", + "evidence_8f9a6b8553ad8d39", + "evidence_49fd2c2d6edf3ffa", + "evidence_5b152f496cd87501", + "evidence_3544c272b486ee48" + ], + "title": "JWT `legacy_access_jwt` 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.", + "suggested_fix": "Pass an explicit safe algorithms allow-list when verifying JWTs.", + "reviewer_question": "Which library version and configuration define the accepted JWT algorithms here?" + }, + { + "id": "finding_46679a7571b675df", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_59b937e65bbea018" + ], + "evidence_ids": [ + "evidence_eb240a6458b51f7d", + "evidence_ffb17cdf5e5e8b20", + "evidence_2c55d5235636e020", + "evidence_142607e448a7c55a", + "evidence_ef5a75f64cb806d4", + "evidence_c8db640fa216830b", + "evidence_5ff7f5334ab5b971", + "evidence_2f1f7bb096bf5b6b", + "evidence_f60f8290316ad4bb", + "evidence_5794655114427a01" + ], + "title": "JWT `access_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + }, + { + "id": "finding_526caf8537b4fffb", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_e6e873017238673a" + ], + "evidence_ids": [ + "evidence_c262b6b3fe4e626d", + "evidence_34b40fb747ca2a6e", + "evidence_7d0978c8a0dc497a", + "evidence_8f9a6b8553ad8d39", + "evidence_49fd2c2d6edf3ffa", + "evidence_5b152f496cd87501", + "evidence_3544c272b486ee48" + ], + "title": "JWT `legacy_access_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + } + ] +} \ No newline at end of file diff --git a/tests/integration/snapshots/nextjs-route-handler-auth.json b/tests/integration/snapshots/nextjs-route-handler-auth.json new file mode 100644 index 0000000..def5029 --- /dev/null +++ b/tests/integration/snapshots/nextjs-route-handler-auth.json @@ -0,0 +1,2903 @@ +{ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 2, + "files_scanned": 2, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [ + { + "path": "expected.json", + "language": "json", + "artifacts": [], + "evidence": [], + "diagnostics": [], + "skipped_reason": null + }, + { + "path": "route.ts", + "language": "type_script", + "artifacts": [ + { + "id": "artifact_c561c5272914ef33", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "route.ts", + "line": 9, + "column": 27 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_1ae3c2d8c685d023", + "evidence_4f85e5391e615c12", + "evidence_84d8e843a7543cf6", + "evidence_a5a7973d4a01f4ee", + "evidence_cb0dbdb2c29a56f3", + "evidence_f8838d112e6014ed" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_6466ba2341dbc9e0" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jose" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue", + "evidence_ids": [ + "evidence_f8838d112e6014ed" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_84d8e843a7543cf6" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "secret", + "evidence_ids": [ + "evidence_4f85e5391e615c12" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_a5a7973d4a01f4ee" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_6466ba2341dbc9e0" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "expiry_enforcement": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_1ae3c2d8c685d023" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email_verified": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_a5a7973d4a01f4ee" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_2b583bc212a31cbe", + "artifact_type": "unknown", + "display_name": "access", + "locations": [ + { + "path": "route.ts", + "line": 16, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_2b583bc212a31cbe", + "evidence_4ab137a7f2af45a3", + "evidence_b4b9c525de641b2f" + ], + "transmit": [ + "evidence_3400b316c2e50e96", + "evidence_5a4a39e105614f12", + "evidence_84f5450bf03616bb", + "evidence_a3a5795ce43fb0cc" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_2423accf73d7423d", + "evidence_8c77344dc14fc373" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b4b9c525de641b2f" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_5a4a39e105614f12" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_3400b316c2e50e96" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_2423accf73d7423d" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_8c77344dc14fc373" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_a3a5795ce43fb0cc" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_84f5450bf03616bb" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ac9b16b63e4ddaff", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "route.ts", + "line": 26, + "column": 9 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [ + "evidence_ed8bc77a05bef8d8" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_25ceae69a975e8d7", + "artifact_type": "opaque_bearer_token", + "display_name": "authorization_bearer", + "locations": [ + { + "path": "route.ts", + "line": 26, + "column": 17 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [ + "evidence_1b8f65a6befb2a09" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_8fe7fee7047a739e", + "artifact_type": "unknown", + "display_name": null, + "locations": [ + { + "path": "route.ts", + "line": 31, + "column": 9 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_07678534faf82472", + "evidence_2013faf58dccfe53", + "evidence_293c767a927400d0", + "evidence_9de57872b188a699", + "evidence_cd1e3db180eb769e", + "evidence_e72ec5fd2af8b8d8", + "evidence_e91b8a1a43174135", + "evidence_f542f7654997dc33" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "jose" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_f542f7654997dc33" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "secret", + "evidence_ids": [ + "evidence_e72ec5fd2af8b8d8" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_293c767a927400d0" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_07678534faf82472" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "jose.jwtVerify default", + "evidence_ids": [ + "evidence_e91b8a1a43174135" + ], + "confidence": "low" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_293c767a927400d0" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ca5f4ce9912eb5a8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "route.ts", + "line": 39, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_3b945e587d0c4aa3" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_dba70235c9632644", + "artifact_type": "unknown", + "display_name": "refresh", + "locations": [ + { + "path": "route.ts", + "line": 40, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_55c92f0ee774b087", + "evidence_994e0b098effac25", + "evidence_dba70235c9632644" + ], + "transmit": [ + "evidence_21d525f460bad55a", + "evidence_3c94a8d36f45549f", + "evidence_6a38024f599f1b26", + "evidence_a2d82c226f347fe6" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_274c001f3d27af39", + "evidence_6ea79ef075fc6f97" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_994e0b098effac25" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_6a38024f599f1b26" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "strict", + "evidence_ids": [ + "evidence_a2d82c226f347fe6" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_274c001f3d27af39" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_6ea79ef075fc6f97" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_21d525f460bad55a" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_3c94a8d36f45549f" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_905c4aeebdc48a72", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "route.ts", + "line": 48, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_49305a6c8f4a13e2" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_aff5b3708155b98d", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "route.ts", + "line": 48, + "column": 8 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_4117a704272b083d" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_51b218b779323fcd", + "artifact_type": "unknown", + "display_name": "access", + "locations": [ + { + "path": "route.ts", + "line": 49, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_de2617515f383fb9" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_a82d1ab486d9dd80", + "artifact_type": "unknown", + "display_name": "refresh", + "locations": [ + { + "path": "route.ts", + "line": 50, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_272e8be9bb923e24" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ] + } + ], + "evidence": [ + { + "id": "evidence_84d8e843a7543cf6", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": ".setProtectedHeader({ alg: \"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cb0dbdb2c29a56f3", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": ".setAudience(audience", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a5a7973d4a01f4ee", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": ".setIssuer(issuer", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4f85e5391e615c12", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1ae3c2d8c685d023", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f8838d112e6014ed", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "jose.SignJWT.sign issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6466ba2341dbc9e0", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": ".setExpirationTime(\"15m\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4ab137a7f2af45a3", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2b583bc212a31cbe", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "\n cookies().set(\"access\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_84f5450bf03616bb", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a3a5795ce43fb0cc", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for nextjs", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_8c77344dc14fc373", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2423accf73d7423d", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b4b9c525de641b2f", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 17, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5a4a39e105614f12", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 18, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3400b316c2e50e96", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 19, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ed8bc77a05bef8d8", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 26, + "column": 9 + }, + "detector_id": "bearer.transmit", + "confidence": "high", + "excerpt": "token = request.headers.get(\"[REDACTED]\")?.replace(\"[REDACTED]\", \"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1b8f65a6befb2a09", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 26, + "column": 17 + }, + "detector_id": "bearer.read.inbound", + "confidence": "high", + "excerpt": "request.headers.get(\"[REDACTED]\")?.replace(\"[REDACTED]\", \"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e91b8a1a43174135", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jose.jwtVerify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_07678534faf82472", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jose.jwtVerify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f542f7654997dc33", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.validate", + "confidence": "medium", + "excerpt": "jose.jwtVerify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e72ec5fd2af8b8d8", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 26 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_293c767a927400d0", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 32, + "column": 5 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2013faf58dccfe53", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 32, + "column": 5 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cd1e3db180eb769e", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 33, + "column": 5 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9de57872b188a699", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 33, + "column": 5 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3b945e587d0c4aa3", + "lifecycle_stage": "refresh", + "location": { + "path": "route.ts", + "line": 39, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "medium", + "excerpt": "export async function PATCH() {\n cookies().set(\"[REDACTED]\", \"[REDACTED]\", {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n return Response.json({ refreshed: true });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_55c92f0ee774b087", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_dba70235c9632644", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "export async function PATCH() {\n cookies().set(\"refresh\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3c94a8d36f45549f", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_21d525f460bad55a", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for nextjs", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6ea79ef075fc6f97", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_274c001f3d27af39", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_994e0b098effac25", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 41, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6a38024f599f1b26", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 42, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a2d82c226f347fe6", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 43, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"strict\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49305a6c8f4a13e2", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 48, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "medium", + "excerpt": "export async function DELETE() {\n cookies().delete(\"[REDACTED]\");\n cookies().delete(\"[REDACTED]\");\n return new Response(null, { status: 204 });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_4117a704272b083d", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 48, + "column": 8 + }, + "detector_id": "logout.handler", + "confidence": "medium", + "excerpt": "async function DELETE() {\n cookies().delete(\"[REDACTED]\");\n cookies().delete(\"[REDACTED]\");\n return new Response(null, { status: 204 });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_de2617515f383fb9", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 49, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "cookies().delete(\"access\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_272e8be9bb923e24", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 50, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "cookies().delete(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + } + ], + "diagnostics": [], + "skipped_reason": null + } + ], + "artifacts": [ + { + "id": "artifact_c561c5272914ef33", + "artifact_type": "access_jwt", + "display_name": "access_jwt", + "locations": [ + { + "path": "route.ts", + "line": 9, + "column": 27 + } + ], + "lifecycle_evidence": { + "issue": [ + "evidence_1ae3c2d8c685d023", + "evidence_4f85e5391e615c12", + "evidence_84d8e843a7543cf6", + "evidence_a5a7973d4a01f4ee", + "evidence_cb0dbdb2c29a56f3", + "evidence_f8838d112e6014ed" + ], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_6466ba2341dbc9e0" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "jose" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "issue", + "evidence_ids": [ + "evidence_f8838d112e6014ed" + ], + "confidence": "high" + }, + "algorithm": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_84d8e843a7543cf6" + ], + "confidence": "high" + }, + "key_reference": { + "state": "present", + "value": "secret", + "evidence_ids": [ + "evidence_4f85e5391e615c12" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_a5a7973d4a01f4ee" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + }, + "expiration": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_6466ba2341dbc9e0" + ], + "confidence": "high" + }, + "signature_verification": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "expiry_enforcement": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "identity_claims": { + "subject": { + "state": "present", + "value": "[literal]", + "evidence_ids": [ + "evidence_1ae3c2d8c685d023" + ], + "confidence": "high" + }, + "user_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "tenant_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "org_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "workspace_id": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "roles": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "scopes": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "groups": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "email_verified": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_method": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "auth_class": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + } + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_a5a7973d4a01f4ee" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cb0dbdb2c29a56f3" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_2b583bc212a31cbe", + "artifact_type": "unknown", + "display_name": "access", + "locations": [ + { + "path": "route.ts", + "line": 16, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_2b583bc212a31cbe", + "evidence_4ab137a7f2af45a3", + "evidence_b4b9c525de641b2f" + ], + "transmit": [ + "evidence_3400b316c2e50e96", + "evidence_5a4a39e105614f12", + "evidence_84f5450bf03616bb", + "evidence_a3a5795ce43fb0cc" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_2423accf73d7423d", + "evidence_8c77344dc14fc373" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_b4b9c525de641b2f" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_5a4a39e105614f12" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "lax", + "evidence_ids": [ + "evidence_3400b316c2e50e96" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_2423accf73d7423d" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_8c77344dc14fc373" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_a3a5795ce43fb0cc" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_84f5450bf03616bb" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ac9b16b63e4ddaff", + "artifact_type": "unknown_token", + "display_name": "unknown_token", + "locations": [ + { + "path": "route.ts", + "line": 26, + "column": 9 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [ + "evidence_ed8bc77a05bef8d8" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_25ceae69a975e8d7", + "artifact_type": "opaque_bearer_token", + "display_name": "authorization_bearer", + "locations": [ + { + "path": "route.ts", + "line": 26, + "column": 17 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [ + "evidence_1b8f65a6befb2a09" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_8fe7fee7047a739e", + "artifact_type": "unknown", + "display_name": null, + "locations": [ + { + "path": "route.ts", + "line": 31, + "column": 9 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [ + "evidence_07678534faf82472", + "evidence_2013faf58dccfe53", + "evidence_293c767a927400d0", + "evidence_9de57872b188a699", + "evidence_cd1e3db180eb769e", + "evidence_e72ec5fd2af8b8d8", + "evidence_e91b8a1a43174135", + "evidence_f542f7654997dc33" + ], + "refresh": [], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "jose" + ], + "jwt_attributes": { + "operation": { + "state": "present", + "value": "validate", + "evidence_ids": [ + "evidence_f542f7654997dc33" + ], + "confidence": "high" + }, + "algorithm": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "key_reference": { + "state": "present", + "value": "secret", + "evidence_ids": [ + "evidence_e72ec5fd2af8b8d8" + ], + "confidence": "high" + }, + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_293c767a927400d0" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + }, + "expiration": { + "state": "unknown", + "evidence_ids": [], + "confidence": "low" + }, + "signature_verification": { + "state": "present", + "value": "verified", + "evidence_ids": [ + "evidence_07678534faf82472" + ], + "confidence": "high" + }, + "expiry_enforcement": { + "state": "framework_default", + "value": "jose.jwtVerify default", + "evidence_ids": [ + "evidence_e91b8a1a43174135" + ], + "confidence": "low" + } + }, + "token_boundary_attributes": { + "issuer": { + "state": "present", + "value": "issuer", + "evidence_ids": [ + "evidence_293c767a927400d0" + ], + "confidence": "high" + }, + "audience": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + }, + "trust_boundary": { + "state": "present", + "value": "audience", + "evidence_ids": [ + "evidence_cd1e3db180eb769e" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_ca5f4ce9912eb5a8", + "artifact_type": "unknown", + "display_name": "refresh_token", + "locations": [ + { + "path": "route.ts", + "line": 39, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [ + "evidence_3b945e587d0c4aa3" + ], + "revoke": [], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_dba70235c9632644", + "artifact_type": "unknown", + "display_name": "refresh", + "locations": [ + { + "path": "route.ts", + "line": 40, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [ + "evidence_55c92f0ee774b087", + "evidence_994e0b098effac25", + "evidence_dba70235c9632644" + ], + "transmit": [ + "evidence_21d525f460bad55a", + "evidence_3c94a8d36f45549f", + "evidence_6a38024f599f1b26", + "evidence_a2d82c226f347fe6" + ], + "validate": [], + "refresh": [], + "revoke": [], + "expire": [ + "evidence_274c001f3d27af39", + "evidence_6ea79ef075fc6f97" + ], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ], + "cookie_attributes": { + "http_only": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_994e0b098effac25" + ], + "confidence": "high" + }, + "secure": { + "state": "present", + "value": "true", + "evidence_ids": [ + "evidence_6a38024f599f1b26" + ], + "confidence": "high" + }, + "same_site": { + "state": "present", + "value": "strict", + "evidence_ids": [ + "evidence_a2d82c226f347fe6" + ], + "confidence": "high" + }, + "max_age": { + "state": "missing", + "evidence_ids": [ + "evidence_274c001f3d27af39" + ], + "confidence": "high" + }, + "expires": { + "state": "missing", + "evidence_ids": [ + "evidence_6ea79ef075fc6f97" + ], + "confidence": "high" + }, + "path": { + "state": "framework_default", + "value": "/", + "evidence_ids": [ + "evidence_21d525f460bad55a" + ], + "confidence": "low" + }, + "domain": { + "state": "missing", + "evidence_ids": [ + "evidence_3c94a8d36f45549f" + ], + "confidence": "high" + } + } + }, + { + "id": "artifact_905c4aeebdc48a72", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "route.ts", + "line": 48, + "column": 1 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_49305a6c8f4a13e2" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_aff5b3708155b98d", + "artifact_type": "unknown", + "display_name": "logout", + "locations": [ + { + "path": "route.ts", + "line": 48, + "column": 8 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_4117a704272b083d" + ], + "expire": [], + "introspect": [] + }, + "confidence": "medium", + "framework_hints": [ + "javascript" + ] + }, + { + "id": "artifact_51b218b779323fcd", + "artifact_type": "unknown", + "display_name": "access", + "locations": [ + { + "path": "route.ts", + "line": 49, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_de2617515f383fb9" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ] + }, + { + "id": "artifact_a82d1ab486d9dd80", + "artifact_type": "unknown", + "display_name": "refresh", + "locations": [ + { + "path": "route.ts", + "line": 50, + "column": 3 + } + ], + "lifecycle_evidence": { + "issue": [], + "store": [], + "transmit": [], + "validate": [], + "refresh": [], + "revoke": [ + "evidence_272e8be9bb923e24" + ], + "expire": [], + "introspect": [] + }, + "confidence": "high", + "framework_hints": [ + "nextjs" + ] + } + ], + "evidence": [ + { + "id": "evidence_84d8e843a7543cf6", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.algorithm", + "confidence": "high", + "excerpt": ".setProtectedHeader({ alg: \"HS256\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cb0dbdb2c29a56f3", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": ".setAudience(audience", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a5a7973d4a01f4ee", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": ".setIssuer(issuer", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4f85e5391e615c12", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1ae3c2d8c685d023", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.subject", + "confidence": "high", + "excerpt": "subject claim is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f8838d112e6014ed", + "lifecycle_stage": "issue", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.issue", + "confidence": "high", + "excerpt": "jose.SignJWT.sign issue call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6466ba2341dbc9e0", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 9, + "column": 27 + }, + "detector_id": "jwt.attribute.expiration", + "confidence": "high", + "excerpt": ".setExpirationTime(\"15m\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_4ab137a7f2af45a3", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2b583bc212a31cbe", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "\n cookies().set(\"access\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_84f5450bf03616bb", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a3a5795ce43fb0cc", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for nextjs", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_8c77344dc14fc373", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2423accf73d7423d", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 16, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_b4b9c525de641b2f", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 17, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_5a4a39e105614f12", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 18, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3400b316c2e50e96", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 19, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"lax\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_ed8bc77a05bef8d8", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 26, + "column": 9 + }, + "detector_id": "bearer.transmit", + "confidence": "high", + "excerpt": "token = request.headers.get(\"[REDACTED]\")?.replace(\"[REDACTED]\", \"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_1b8f65a6befb2a09", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 26, + "column": 17 + }, + "detector_id": "bearer.read.inbound", + "confidence": "high", + "excerpt": "request.headers.get(\"[REDACTED]\")?.replace(\"[REDACTED]\", \"[REDACTED]\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e91b8a1a43174135", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.expiry_enforcement", + "confidence": "low", + "excerpt": "jose.jwtVerify enforces exp by library default", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_07678534faf82472", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.attribute.signature_verification", + "confidence": "high", + "excerpt": "jose.jwtVerify verifies JWT signatures", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_f542f7654997dc33", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 9 + }, + "detector_id": "jwt.validate", + "confidence": "medium", + "excerpt": "jose.jwtVerify validate call detected with token and key arguments redacted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_e72ec5fd2af8b8d8", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 31, + "column": 26 + }, + "detector_id": "jwt.attribute.key_reference", + "confidence": "high", + "excerpt": "JWT key reference is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_293c767a927400d0", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 32, + "column": 5 + }, + "detector_id": "jwt.attribute.issuer", + "confidence": "high", + "excerpt": "issuer", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_2013faf58dccfe53", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 32, + "column": 5 + }, + "detector_id": "jwt.option.issuer", + "confidence": "high", + "excerpt": "JWT issuer option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_cd1e3db180eb769e", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 33, + "column": 5 + }, + "detector_id": "jwt.attribute.audience", + "confidence": "high", + "excerpt": "audience", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_9de57872b188a699", + "lifecycle_stage": "validate", + "location": { + "path": "route.ts", + "line": 33, + "column": 5 + }, + "detector_id": "jwt.option.audience", + "confidence": "high", + "excerpt": "JWT audience option is present", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3b945e587d0c4aa3", + "lifecycle_stage": "refresh", + "location": { + "path": "route.ts", + "line": 39, + "column": 1 + }, + "detector_id": "refresh.handler", + "confidence": "medium", + "excerpt": "export async function PATCH() {\n cookies().set(\"[REDACTED]\", \"[REDACTED]\", {\n httpOnly: true,\n secure: true,\n sameSite: \"[REDACTED]\",\n });\n return Response.json({ refreshed: true });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_55c92f0ee774b087", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.name_prefix", + "confidence": "high", + "excerpt": "Cookie name prefix: none", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_dba70235c9632644", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.set", + "confidence": "high", + "excerpt": "export async function PATCH() {\n cookies().set(\"refresh\", [REDACTED], {\n httpOnly: true,", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_3c94a8d36f45549f", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.domain", + "confidence": "high", + "excerpt": "Domain is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_21d525f460bad55a", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.path", + "confidence": "low", + "excerpt": "Path defaults to / for nextjs", + "dynamic": false, + "framework_default": true + }, + { + "id": "evidence_6ea79ef075fc6f97", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.expires", + "confidence": "high", + "excerpt": "Expires is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_274c001f3d27af39", + "lifecycle_stage": "expire", + "location": { + "path": "route.ts", + "line": 40, + "column": 3 + }, + "detector_id": "cookie.attribute.max_age", + "confidence": "high", + "excerpt": "Max-Age is omitted", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_994e0b098effac25", + "lifecycle_stage": "store", + "location": { + "path": "route.ts", + "line": 41, + "column": 15 + }, + "detector_id": "cookie.attribute.http_only", + "confidence": "high", + "excerpt": "HttpOnly: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_6a38024f599f1b26", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 42, + "column": 13 + }, + "detector_id": "cookie.attribute.secure", + "confidence": "high", + "excerpt": "Secure: true", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_a2d82c226f347fe6", + "lifecycle_stage": "transmit", + "location": { + "path": "route.ts", + "line": 43, + "column": 15 + }, + "detector_id": "cookie.attribute.same_site", + "confidence": "high", + "excerpt": "SameSite: \"strict\"", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_49305a6c8f4a13e2", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 48, + "column": 1 + }, + "detector_id": "logout.handler", + "confidence": "medium", + "excerpt": "export async function DELETE() {\n cookies().delete(\"[REDACTED]\");\n cookies().delete(\"[REDACTED]\");\n return new Response(null, { status: 204 });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_4117a704272b083d", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 48, + "column": 8 + }, + "detector_id": "logout.handler", + "confidence": "medium", + "excerpt": "async function DELETE() {\n cookies().delete(\"[REDACTED]\");\n cookies().delete(\"[REDACTED]\");\n return new Response(null, { status: 204 });\n}", + "dynamic": true, + "framework_default": false + }, + { + "id": "evidence_de2617515f383fb9", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 49, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "cookies().delete(\"access\")", + "dynamic": false, + "framework_default": false + }, + { + "id": "evidence_272e8be9bb923e24", + "lifecycle_stage": "revoke", + "location": { + "path": "route.ts", + "line": 50, + "column": 3 + }, + "detector_id": "logout.cookie_clear", + "confidence": "high", + "excerpt": "cookies().delete(\"[REDACTED]\")", + "dynamic": false, + "framework_default": false + } + ], + "lifecycle_paths": [ + { + "id": "lifecycle_path_c72f7ffb255f10ab", + "artifact_ids": [ + "artifact_25ceae69a975e8d7" + ], + "stages": [ + { + "stage": "transmit", + "evidence_ids": [ + "evidence_1b8f65a6befb2a09" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_7e0e973b70bff3b6", + "artifact_ids": [ + "artifact_2b583bc212a31cbe", + "artifact_51b218b779323fcd" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_2b583bc212a31cbe", + "evidence_4ab137a7f2af45a3", + "evidence_b4b9c525de641b2f" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_3400b316c2e50e96", + "evidence_5a4a39e105614f12", + "evidence_84f5450bf03616bb", + "evidence_a3a5795ce43fb0cc" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_de2617515f383fb9" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_2423accf73d7423d", + "evidence_8c77344dc14fc373" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `access`?" + }, + { + "id": "lifecycle_path_aedffd4a2335eeae", + "artifact_ids": [ + "artifact_8fe7fee7047a739e" + ], + "stages": [ + { + "stage": "validate", + "evidence_ids": [ + "evidence_07678534faf82472", + "evidence_2013faf58dccfe53", + "evidence_293c767a927400d0", + "evidence_9de57872b188a699", + "evidence_cd1e3db180eb769e", + "evidence_e72ec5fd2af8b8d8", + "evidence_e91b8a1a43174135", + "evidence_f542f7654997dc33" + ] + } + ], + "confidence": "low", + "dynamic": false, + "reviewer_question": "Which framework version and deployment settings determine lifecycle behavior for `this artifact`?" + }, + { + "id": "lifecycle_path_be40a5c3f5bfce4b", + "artifact_ids": [ + "artifact_905c4aeebdc48a72", + "artifact_aff5b3708155b98d" + ], + "stages": [ + { + "stage": "revoke", + "evidence_ids": [ + "evidence_4117a704272b083d", + "evidence_49305a6c8f4a13e2" + ] + } + ], + "confidence": "medium", + "dynamic": true, + "reviewer_question": "Which production code path determines the effective lifecycle behavior for `logout`?" + }, + { + "id": "lifecycle_path_a63cfe46a8dbbfc4", + "artifact_ids": [ + "artifact_a82d1ab486d9dd80", + "artifact_ca5f4ce9912eb5a8", + "artifact_dba70235c9632644" + ], + "stages": [ + { + "stage": "store", + "evidence_ids": [ + "evidence_55c92f0ee774b087", + "evidence_994e0b098effac25", + "evidence_dba70235c9632644" + ] + }, + { + "stage": "transmit", + "evidence_ids": [ + "evidence_21d525f460bad55a", + "evidence_3c94a8d36f45549f", + "evidence_6a38024f599f1b26", + "evidence_a2d82c226f347fe6" + ] + }, + { + "stage": "refresh", + "evidence_ids": [ + "evidence_3b945e587d0c4aa3" + ] + }, + { + "stage": "revoke", + "evidence_ids": [ + "evidence_272e8be9bb923e24" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_274c001f3d27af39", + "evidence_6ea79ef075fc6f97" + ] + } + ], + "confidence": "low", + "dynamic": true, + "reviewer_question": "Which production code path determines the effective lifecycle behavior for `refresh_token`?" + }, + { + "id": "lifecycle_path_5f83f744675da556", + "artifact_ids": [ + "artifact_ac9b16b63e4ddaff" + ], + "stages": [ + { + "stage": "transmit", + "evidence_ids": [ + "evidence_ed8bc77a05bef8d8" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + }, + { + "id": "lifecycle_path_83c27d68e25040bc", + "artifact_ids": [ + "artifact_c561c5272914ef33" + ], + "stages": [ + { + "stage": "issue", + "evidence_ids": [ + "evidence_1ae3c2d8c685d023", + "evidence_4f85e5391e615c12", + "evidence_84d8e843a7543cf6", + "evidence_a5a7973d4a01f4ee", + "evidence_cb0dbdb2c29a56f3", + "evidence_f8838d112e6014ed" + ] + }, + { + "stage": "expire", + "evidence_ids": [ + "evidence_6466ba2341dbc9e0" + ] + } + ], + "confidence": "high", + "dynamic": false, + "reviewer_question": null + } + ], + "findings": [ + { + "id": "finding_5c8cd00bb6e07a71", + "category": "missing_validation_evidence", + "severity": "medium", + "artifact_ids": [ + "artifact_25ceae69a975e8d7" + ], + "evidence_ids": [ + "evidence_1b8f65a6befb2a09" + ], + "title": "Inbound token `authorization_bearer` is read without linked validation evidence", + "description": "Inbound bearer/API-key evidence was detected, but no local validation, lookup, or compare evidence was linked for the same token artifact.", + "suggested_fix": "Add or identify source-bound validation evidence before the token is trusted.", + "reviewer_question": "Where is this inbound token checked before it authorizes access?" + }, + { + "id": "finding_b8bc1e32e1309f2e", + "category": "lifecycle_gap", + "severity": "medium", + "artifact_ids": [ + "artifact_c561c5272914ef33" + ], + "evidence_ids": [ + "evidence_1ae3c2d8c685d023", + "evidence_4117a704272b083d", + "evidence_49305a6c8f4a13e2", + "evidence_4f85e5391e615c12", + "evidence_84d8e843a7543cf6", + "evidence_a5a7973d4a01f4ee", + "evidence_cb0dbdb2c29a56f3", + "evidence_f8838d112e6014ed" + ], + "title": "JWT `access_jwt` has logout evidence without linked denylist evidence", + "description": "A logout handler and access-JWT lifecycle evidence were detected in linked source context, but no source-bound denylist, blocklist, or token revocation-store insertion evidence was linked for the same logout flow.", + "suggested_fix": "Insert the JWT identifier into a denylist/blocklist or revoke-store on logout, or document the intentional short-TTL stateless model for reviewer confirmation.", + "reviewer_question": "Where does logout revoke or denylist outstanding `access_jwt` tokens?" + }, + { + "id": "finding_46da9e746d8d6c2c", + "category": "lifecycle_gap", + "severity": "medium", + "artifact_ids": [ + "artifact_c561c5272914ef33" + ], + "evidence_ids": [ + "evidence_1ae3c2d8c685d023", + "evidence_4f85e5391e615c12", + "evidence_84d8e843a7543cf6", + "evidence_a5a7973d4a01f4ee", + "evidence_cb0dbdb2c29a56f3", + "evidence_f8838d112e6014ed" + ], + "title": "JWT `access_jwt` is issued without linked validation evidence", + "description": "JWT issue evidence was linked into a lifecycle path, but no validation evidence was linked for the same artifact.", + "suggested_fix": "Add or identify verification evidence that validates this token before claims are trusted.", + "reviewer_question": "Where is this issued JWT validated before use?" + }, + { + "id": "finding_ffb71adf93936615", + "category": "lifecycle_gap", + "severity": "medium", + "artifact_ids": [ + "artifact_ca5f4ce9912eb5a8" + ], + "evidence_ids": [ + "evidence_3b945e587d0c4aa3", + "evidence_4117a704272b083d", + "evidence_49305a6c8f4a13e2" + ], + "title": "Refresh token `refresh_token` has logout evidence without family revocation", + "description": "Logout and refresh-token lifecycle evidence were detected in linked source context, but no source-bound user-scoped or refresh-family revocation evidence was linked for the logout flow.", + "suggested_fix": "Revoke the user's refresh-token family, delete user-scoped refresh-token records, or remove the refresh-family cache key during logout.", + "reviewer_question": "Where does logout revoke every refresh token in the `refresh_token` family or for the current user?" + }, + { + "id": "finding_2a692dcbf3a18ab6", + "category": "missing_validation_evidence", + "severity": "low", + "artifact_ids": [ + "artifact_8fe7fee7047a739e" + ], + "evidence_ids": [ + "evidence_f542f7654997dc33", + "evidence_e72ec5fd2af8b8d8", + "evidence_293c767a927400d0", + "evidence_cd1e3db180eb769e", + "evidence_07678534faf82472", + "evidence_e91b8a1a43174135", + "evidence_9de57872b188a699", + "evidence_2013faf58dccfe53" + ], + "title": "JWT `unknown_jwt` verification has no not-before validation evidence", + "description": "JWT verification evidence does not show `nbf` / not-before claim enforcement.", + "suggested_fix": "Require or enforce `nbf` validation where issuer policy uses not-before claims.", + "reviewer_question": "Do issued tokens for this path use `nbf`, and is it enforced in production?" + }, + { + "id": "finding_a328bbb5ff785088", + "category": "lifecycle_gap", + "severity": "low", + "artifact_ids": [ + "artifact_2b583bc212a31cbe" + ], + "evidence_ids": [ + "evidence_de2617515f383fb9" + ], + "title": "Cookie `access` is cleared on logout without linked server-side revocation", + "description": "Logout evidence clears a client-side cookie, but no linked server-side session, token, or provider revocation evidence was found for the same lifecycle path.", + "suggested_fix": "Invalidate the server-side session or refresh token in addition to deleting the browser cookie.", + "reviewer_question": "Where is the server-side session or token behind `access` revoked during logout?" + }, + { + "id": "finding_2673ca01a7369322", + "category": "lifecycle_gap", + "severity": "low", + "artifact_ids": [ + "artifact_ca5f4ce9912eb5a8" + ], + "evidence_ids": [ + "evidence_272e8be9bb923e24" + ], + "title": "Cookie `refresh_token` is cleared on logout without linked server-side revocation", + "description": "Logout evidence clears a client-side cookie, but no linked server-side session, token, or provider revocation evidence was found for the same lifecycle path.", + "suggested_fix": "Invalidate the server-side session or refresh token in addition to deleting the browser cookie.", + "reviewer_question": "Where is the server-side session or token behind `refresh_token` revoked during logout?" + }, + { + "id": "finding_b6da82e39f1b36e8", + "category": "dynamic_review_required", + "severity": "low", + "artifact_ids": [ + "artifact_ac9b16b63e4ddaff" + ], + "evidence_ids": [ + "evidence_ed8bc77a05bef8d8" + ], + "title": "Token `unknown_token` has provider-managed or dynamic handling", + "description": "Bearer/API-key behavior appears dynamic, provider-managed, config-driven, or server-to-server only, so the static evidence should be reviewed before treating lifecycle controls as deterministic.", + "suggested_fix": "Document the provider, wrapper, or server-side policy for issuance, storage, validation, scope, expiry, rotation, and revocation.", + "reviewer_question": "Which runtime configuration or provider settings govern this token lifecycle?" + }, + { + "id": "finding_b7a7e921d36f9cf5", + "category": "dynamic_review_required", + "severity": "low", + "artifact_ids": [ + "artifact_ca5f4ce9912eb5a8" + ], + "evidence_ids": [ + "evidence_3b945e587d0c4aa3" + ], + "title": "Token `refresh_token` has dynamic refresh behavior without linked revocation evidence", + "description": "Refresh lifecycle evidence appears provider-managed or dynamic, and no deterministic source-bound revoke or rotation evidence was linked for the same artifact.", + "suggested_fix": "Confirm the provider or runtime refresh policy rotates or revokes previous refresh tokens.", + "reviewer_question": "Which provider setting or runtime path revokes previous refresh tokens for `refresh_token`?" + }, + { + "id": "finding_a2856514b9437d76", + "category": "framework_default_assumed", + "severity": "low", + "artifact_ids": [ + "artifact_8fe7fee7047a739e" + ], + "evidence_ids": [ + "evidence_e91b8a1a43174135" + ], + "title": "JWT `unknown_jwt` expiry enforcement relies on library defaults", + "description": "JWT validation appears to rely on the library default for expiration enforcement.", + "suggested_fix": "Make expiration enforcement explicit or document the library version and default.", + "reviewer_question": "Which JWT library version and settings determine expiration enforcement here?" + } + ] +} \ No newline at end of file From 2d2196cc7ecb8eea6b714ea70bb905ebb0b2f7e5 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:32:11 -0500 Subject: [PATCH 36/41] Fix #93: P4.7 CLI exit-code matrix tests --- CHANGELOG.md | 1 + .../sessionscope-cli/tests/cli_exit_matrix.rs | 260 ++++++++++++++++++ tests/README.md | 8 + 3 files changed, 269 insertions(+) create mode 100644 crates/sessionscope-cli/tests/cli_exit_matrix.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df913c..6a634e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and SessionScope uses semantic versioning as described in - Added `password_change_global_revocation_absent_review` lifecycle coverage for password-change handlers that lack linked global session invalidation, refresh-family revocation, or token-version bump evidence. - Added clean-baseline false-positive fixtures across Express, Next.js, FastAPI, Django, generic JS/TS, and generic Python to guard every v0.2 P1-P4 check ID. - Added hand-rolled JSON report snapshot tests for representative Express, Next.js, FastAPI, Django, generic JS, generic TS, and generic Python fixtures. +- Added Rust integration-test coverage for the documented CLI advisory/enforce exit-code policy matrix. - 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-cli/tests/cli_exit_matrix.rs b/crates/sessionscope-cli/tests/cli_exit_matrix.rs new file mode 100644 index 0000000..152414e --- /dev/null +++ b/crates/sessionscope-cli/tests/cli_exit_matrix.rs @@ -0,0 +1,260 @@ +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +use serde_json::json; + +#[derive(Clone, Copy)] +struct FindingSpec { + id: &'static str, + severity: &'static str, + category: &'static str, +} + +const HIGH_LIFECYCLE: FindingSpec = FindingSpec { + id: "finding_high_lifecycle", + severity: "high", + category: "lifecycle_gap", +}; +const MEDIUM_MISSING: FindingSpec = FindingSpec { + id: "finding_medium_missing", + severity: "medium", + category: "missing_validation_evidence", +}; +const LOW_DYNAMIC: FindingSpec = FindingSpec { + id: "finding_low_dynamic", + severity: "low", + category: "dynamic_review_required", +}; +const INFO_FRAMEWORK: FindingSpec = FindingSpec { + id: "finding_info_framework", + severity: "info", + category: "framework_default_assumed", +}; + +#[test] +fn documented_exit_code_policy_matrix() { + let cases = [ + MatrixCase { + name: "advisory mode ignores findings", + findings: &[HIGH_LIFECYCLE], + args: &["--mode", "advisory"], + expect_success: true, + }, + MatrixCase { + name: "enforce default blocks high findings", + findings: &[HIGH_LIFECYCLE], + args: &["--mode", "enforce"], + expect_success: false, + }, + MatrixCase { + name: "enforce default allows lower severities", + findings: &[MEDIUM_MISSING, LOW_DYNAMIC, INFO_FRAMEWORK], + args: &["--mode", "enforce"], + expect_success: true, + }, + MatrixCase { + name: "fail severity medium blocks medium", + findings: &[MEDIUM_MISSING], + args: &["--mode", "enforce", "--fail-severity", "medium"], + expect_success: false, + }, + MatrixCase { + name: "fail severity low blocks low", + findings: &[LOW_DYNAMIC], + args: &["--mode", "enforce", "--fail-severity", "low"], + expect_success: false, + }, + MatrixCase { + name: "fail severity info blocks info", + findings: &[INFO_FRAMEWORK], + args: &["--mode", "enforce", "--fail-severity", "info"], + expect_success: false, + }, + MatrixCase { + name: "category filter excludes nonmatching category", + findings: &[HIGH_LIFECYCLE], + args: &[ + "--mode", + "enforce", + "--fail-category", + "missing_validation_evidence", + ], + expect_success: true, + }, + MatrixCase { + name: "category filter accepts comma-separated categories", + findings: &[INFO_FRAMEWORK], + args: &[ + "--mode", + "enforce", + "--fail-severity", + "info", + "--fail-category", + "dynamic_review_required,framework_default_assumed", + ], + expect_success: false, + }, + MatrixCase { + name: "empty category filter behaves like no category filter", + findings: &[HIGH_LIFECYCLE], + args: &["--mode", "enforce", "--fail-category", ""], + expect_success: false, + }, + MatrixCase { + name: "include finding id blocks below threshold", + findings: &[LOW_DYNAMIC], + args: &[ + "--mode", + "enforce", + "--include-finding-id", + "finding_low_dynamic", + ], + expect_success: false, + }, + MatrixCase { + name: "exclude finding id wins over include", + findings: &[HIGH_LIFECYCLE], + args: &[ + "--mode", + "enforce", + "--include-finding-id", + "finding_high_lifecycle", + "--exclude-finding-id", + "finding_high_lifecycle", + ], + expect_success: true, + }, + ]; + + for case in cases { + run_matrix_case(case); + } +} + +#[test] +fn baseline_suppression_and_include_precedence_are_documented() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let report_path = temp.path().join("report.json"); + let baseline_path = temp.path().join("baseline.json"); + write_report(&report_path, &[HIGH_LIFECYCLE]); + write_report(&baseline_path, &[HIGH_LIFECYCLE]); + + let suppressed = run_sessionscope_in( + temp.path(), + &[ + "evaluate", + report_path.to_str().expect("path should be UTF-8"), + "--no-policy-config", + "--mode", + "enforce", + "--baseline", + baseline_path.to_str().expect("path should be UTF-8"), + ], + ); + assert!( + suppressed.status.success(), + "baseline should suppress matching high finding: {}", + String::from_utf8_lossy(&suppressed.stderr) + ); + + let included = run_sessionscope_in( + temp.path(), + &[ + "evaluate", + report_path.to_str().expect("path should be UTF-8"), + "--no-policy-config", + "--mode", + "enforce", + "--baseline", + baseline_path.to_str().expect("path should be UTF-8"), + "--include-finding-id", + "finding_high_lifecycle", + ], + ); + assert!( + !included.status.success(), + "include-finding-id should block unless excluded, even when baseline matches" + ); +} + +struct MatrixCase { + name: &'static str, + findings: &'static [FindingSpec], + args: &'static [&'static str], + expect_success: bool, +} + +fn run_matrix_case(case: MatrixCase) { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let report_path = temp.path().join("report.json"); + write_report(&report_path, case.findings); + + let mut args = vec![ + "evaluate".to_string(), + report_path + .to_str() + .expect("path should be UTF-8") + .to_string(), + "--no-policy-config".to_string(), + ]; + args.extend(case.args.iter().map(|arg| arg.to_string())); + let borrowed = args.iter().map(String::as_str).collect::>(); + let output = run_sessionscope_in(temp.path(), &borrowed); + + assert_eq!( + output.status.success(), + case.expect_success, + "{}: stdout={} stderr={}", + case.name, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn write_report(path: &Path, findings: &[FindingSpec]) { + let findings = findings + .iter() + .map(|finding| { + json!({ + "id": finding.id, + "category": finding.category, + "severity": finding.severity, + "artifact_ids": [], + "evidence_ids": [], + "title": format!("{} {}", finding.severity, finding.category), + "description": "policy matrix fixture", + "suggested_fix": null, + "reviewer_question": null + }) + }) + .collect::>(); + let report = json!({ + "schema_version": "0.5.0", + "summary": { + "files_discovered": 0, + "files_scanned": 0, + "files_skipped": 0, + "diagnostics": [], + "worker_panic_count": 0 + }, + "files": [], + "artifacts": [], + "evidence": [], + "lifecycle_paths": [], + "findings": findings + }); + fs::write( + path, + serde_json::to_string_pretty(&report).expect("report serializes"), + ) + .expect("report should be written"); +} + +fn run_sessionscope_in(cwd: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_sessionscope")) + .current_dir(cwd) + .args(args) + .output() + .expect("failed to run sessionscope") +} diff --git a/tests/README.md b/tests/README.md index 81e8966..4f17c2e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -21,3 +21,11 @@ them after intentional report-output changes with: SESSIONSCOPE_UPDATE_JSON_SNAPSHOTS=1 cargo test -p sessionscope-testing --test json_snapshots ``` +## CLI exit-code matrix + +`crates/sessionscope-cli/tests/cli_exit_matrix.rs` is the Rust integration-test +equivalent of the documented exit-code shell matrix. It evaluates synthetic JSON +reports through the real `sessionscope evaluate` binary path and covers advisory +mode, enforce thresholds, category filters, include/exclude finding IDs, and +baseline suppression/precedence. + From c498fef6b808871cf6c64869890b3be707e54d56 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:33:13 -0500 Subject: [PATCH 37/41] Fix #94: P4.8 Consolidated category-audit decision --- CHANGELOG.md | 1 + docs/DESIGN_DECISIONS.md | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a634e1..a8925f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and SessionScope uses semantic versioning as described in - Added clean-baseline false-positive fixtures across Express, Next.js, FastAPI, Django, generic JS/TS, and generic Python to guard every v0.2 P1-P4 check ID. - Added hand-rolled JSON report snapshot tests for representative Express, Next.js, FastAPI, Django, generic JS, generic TS, and generic Python fixtures. - Added Rust integration-test coverage for the documented CLI advisory/enforce exit-code policy matrix. +- Documented the consolidated v0.2 category audit decision: all P1-P4 checks map to existing finding categories, with no schema or SARIF rule change. - 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/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 7762117..646ffca 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -288,3 +288,73 @@ Rules: lifecycle management remain separate. - Missing or malformed baseline files are configuration errors whenever a baseline is explicitly supplied. + +## SS-DEC-011: v0.2 P1-P4 Check Category Consolidation + +The v0.2 edge-case hardening checks fit the existing five finding categories. +No new finding category or SARIF rule ID is added in this round, and the scan +report schema remains `0.5.0`. + +Explicit audit decisions: + +- **P2.3 `jwt_alg_confusion_signal`:** no new + `cryptographic_trust_violation` category. Literal mixed algorithm families + remain `high_confidence_misconfiguration`; key-family ambiguity remains + `dynamic_review_required`. +- **P2.4 JWT header-trust checks:** no new `cryptographic_trust_violation` + category. Header-driven `jku`, `x5u`, and embedded-JWK trust remains + `dynamic_review_required` because source evidence cannot prove live URL, + certificate, JWKS, or provider allow-list behavior. +- **P3.1 OAuth artifact type:** keep the dedicated `oauth_auth_code_flow` + artifact type. Reusing a token artifact would blur flow-construction evidence + with issued-token lifecycle evidence. + +Category mapping for new v0.2 checks: + +| Check ID | Category mapping | +| --- | --- | +| `cookie_host_prefix_path_violation` | `high_confidence_misconfiguration` for literal violations; `dynamic_review_required` for dynamic Path evidence | +| `cookie_host_prefix_domain_violation` | `high_confidence_misconfiguration` for literal Domain evidence; `dynamic_review_required` for dynamic Domain evidence | +| `cookie_host_prefix_secure_violation` | `high_confidence_misconfiguration` for missing/false literal Secure; `dynamic_review_required` for dynamic/default Secure evidence | +| `cookie_secure_prefix_secure_violation` | `high_confidence_misconfiguration` for missing/false literal Secure; `dynamic_review_required` for dynamic/default Secure evidence | +| `cookie_samesite_none_without_secure` | `high_confidence_misconfiguration` | +| `cookie_samesite_none_dynamic_secure` | `dynamic_review_required` | +| `cookie_samesite_none_default_secure` | `framework_default_assumed` | +| `cookie_samesite_none_cross_site_review` | `dynamic_review_required` | +| `cookie_partitioned_review` | `dynamic_review_required` | +| `cookie_domain_leak_review` | `dynamic_review_required` | +| `cookie_conflicting_writes_review` | `dynamic_review_required` | +| `jwt_alg_none_accepted` | `high_confidence_misconfiguration` for literal `none`; `framework_default_assumed` for default-sensitive library behavior | +| `jwt_alg_confusion_signal` | `high_confidence_misconfiguration` for deterministic mixed algorithm families; `dynamic_review_required` for key-family ambiguity | +| `jwt_jku_header_trust` | `dynamic_review_required` | +| `jwt_x5u_header_trust` | `dynamic_review_required` | +| `jwt_embedded_jwk_trust` | `dynamic_review_required` | +| `jwt_nbf_missing` | `missing_validation_evidence` | +| `jwt_clock_skew_review` | `dynamic_review_required` | +| `jwt_kid_unvalidated_review` | `missing_validation_evidence` | +| `oauth_pkce_missing_review` | `dynamic_review_required` | +| `oauth_state_missing` | `missing_validation_evidence` | +| `oauth_state_static_review` | `dynamic_review_required` | +| `oauth_state_unverified_review` | `missing_validation_evidence` | +| `oidc_nonce_missing` | `missing_validation_evidence` | +| `oidc_nonce_unverified_review` | `missing_validation_evidence` | +| `oauth_redirect_uri_wildcard_review` | `dynamic_review_required` | +| `token_in_local_storage` | `high_confidence_misconfiguration` | +| `token_in_session_storage` | `high_confidence_misconfiguration` | +| `token_in_url_path_or_fragment` | `high_confidence_misconfiguration` | +| `client_secret_in_browser_code` | `dynamic_review_required` | +| `jwt_denylist_absent_on_logout_review` | `lifecycle_gap` | +| `refresh_family_revocation_absent_on_logout_review` | `lifecycle_gap` | +| `sliding_expiry_without_rotation_review` | `lifecycle_gap` | +| `password_change_global_revocation_absent_review` | `lifecycle_gap` | + +Rules: + +- Prefer existing category semantics over adding near-duplicate category names. +- Keep cryptographic trust evidence in the category that describes the static + certainty: deterministic unsafe configuration, missing validation evidence, or + dynamic review. +- Keep SARIF stable by mapping findings through the existing category rule IDs. +- Revisit a dedicated cryptographic-trust category only if future checks cannot + be accurately expressed as deterministic misconfiguration, missing validation, + lifecycle gap, dynamic review, or framework default evidence. From 59cbe547b91e55653b907be977777497cd71c426 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:34:16 -0500 Subject: [PATCH 38/41] Fix #95: P4.9 Final docs pass --- CHANGELOG.md | 1 + README.md | 3 ++- docs/COVERAGE_MATRIX.md | 2 +- docs/ROADMAP.md | 30 ++++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8925f9..c5a52ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and SessionScope uses semantic versioning as described in - Added hand-rolled JSON report snapshot tests for representative Express, Next.js, FastAPI, Django, generic JS, generic TS, and generic Python fixtures. - Added Rust integration-test coverage for the documented CLI advisory/enforce exit-code policy matrix. - Documented the consolidated v0.2 category audit decision: all P1-P4 checks map to existing finding categories, with no schema or SARIF rule change. +- Completed the v0.2 edge-case hardening documentation pass across the changelog, README status, roadmap, and coverage matrix. - 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/README.md b/README.md index 9d71350..fa4c785 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,8 @@ Config precedence is: ## Project status - **Release target:** v0.1.0 first packaged release through GitHub Releases. Release packaging, versioning, and installation workflow are tracked in #28. -- **Complete:** v0.1 foundation, v0.2 cookie audit, v0.3 JWT validation, v0.4 lifecycle mapping, v0.5 expanded token handling, v0.6 framework/provider coverage, v0.7 CI SARIF/enforcement, v0.8 reviewer workflows. +- **Complete:** v0.1 foundation plus the v0.2 depth-first edge-case hardening round for cookie rules, JWT crypto-trust, OAuth/OIDC flow integrity, client storage hygiene, lifecycle gaps, false-positive fixtures, JSON snapshots, and CLI exit-code tests. +- **Deferred:** v0.3+ breadth expansion for new languages and frameworks; see [docs/ROADMAP.md](docs/ROADMAP.md). - **Schema:** JSON contract v0.5.0. - **Rust:** edition 2024. MSRV is 1.95. - **Platforms:** Linux, macOS, and Windows are covered by CI where workflow support exists. diff --git a/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index a3ddebb..a6c16e7 100644 --- a/docs/COVERAGE_MATRIX.md +++ b/docs/COVERAGE_MATRIX.md @@ -1,6 +1,6 @@ # Coverage Matrix -This matrix is the per-check source of truth for what SessionScope can find on a scanned project. SessionScope is offline-only and evidence-bound: **supported** means deterministic source patterns are recognized, **review-required** means dynamic or framework-default behavior is surfaced for reviewer confirmation, and **not covered** means the stack or pattern is intentionally out of scope for this release round. +This matrix is the per-check source of truth for what SessionScope can find on a scanned project. To decide whether a check fires on your stack, find the check ID, then read across the Languages, Frameworks, Libraries/SDKs, and Triggering APIs columns. SessionScope is offline-only and evidence-bound: **supported** means deterministic source patterns are recognized, **review-required** means dynamic or framework-default behavior is surfaced for reviewer confirmation, and **not covered** means the stack or pattern is intentionally out of scope for this release round. Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.md) and provider/library notes remain in [PROVIDER_LIBRARY_COVERAGE.md](PROVIDER_LIBRARY_COVERAGE.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5fcd936..86ba8b7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,3 +74,33 @@ cookies, claims, logout, and refresh while keeping findings evidence-bound. - Framework and provider coverage: #18 and #27 feed all four capability areas, with umbrella capability documentation completed in #40. - Focused command aliases: #39 exposes capability-oriented entry points while preserving the shared scan/config/reporting pipeline. - Stable CLI release: #28 tracks release packaging, versioning, installation workflow, and final readiness; #41 tracks this folded capability model without creating duplicate v1.1-v1.4 milestone tracks. + +## v0.2 edge-case hardening status + +The v0.2 depth-first edge-case hardening round is complete across all four +phases: + +- **P1 cookie prefix/attribute rules:** `__Host-` / `__Secure-`, + `SameSite=None` + Secure, Partitioned cookie review, broad non-session + Domain review, and conflicting same-handler cookie writes. +- **P2 JWT crypto-trust:** `alg:none`, algorithm-confusion signals, + `jku`/`x5u`/embedded-JWK header-trust review, missing `nbf`, broad + clock-skew review, and unvalidated `kid` review. +- **P3 OAuth/OIDC and client storage:** PKCE, `state`, OIDC `nonce`, wildcard + redirect URI review, browser storage token findings, URL path/fragment token + findings, browser-path client secret review, and OAuth redaction expansion. +- **P4 lifecycle and test hygiene:** JWT denylist-on-logout review, + refresh-family revocation-on-logout review, sliding-expiry-without-rotation + review, password-change global revocation review, clean-baseline + false-positive fixtures, JSON report snapshots, CLI exit-code matrix tests, + and the consolidated category audit. + +The consolidated category decision keeps the existing five finding categories +and does not require a schema or SARIF rule bump. + +## Deferred to v0.3+ + +New language and framework breadth is intentionally deferred. The next breadth +round may consider Flask, Tornado, Sanic, Starlette, NestJS, Koa, Fastify, Hapi, +Remix, Hono, SvelteKit, Go, Ruby/Rails, Java/Spring, .NET, PHP, python-jose, +authlib JWT validation paths, and deeper runtime/provider policy integration. From 9a2fabd753a83a6eea57a5661619b5dee1a16d39 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 09:41:50 -0500 Subject: [PATCH 39/41] Address milestone 14 review findings --- .../sessionscope-classifier/src/lifecycle.rs | 53 ++++++++++++++++++- crates/sessionscope-cli/tests/cli.rs | 42 +++++++++++++++ .../src/sessions/mod.rs | 4 +- crates/sessionscope-testing/src/fixtures.rs | 36 +++++++++++++ .../expected.json | 9 ++++ .../views.py | 6 +++ 6 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 fixtures/django/password-change-current-session-only/expected.json create mode 100644 fixtures/django/password-change-current-session-only/views.py diff --git a/crates/sessionscope-classifier/src/lifecycle.rs b/crates/sessionscope-classifier/src/lifecycle.rs index 381ff87..8fdc030 100644 --- a/crates/sessionscope-classifier/src/lifecycle.rs +++ b/crates/sessionscope-classifier/src/lifecycle.rs @@ -1158,7 +1158,6 @@ fn has_linked_password_change_global_revoke( fn is_password_change_global_revoke_evidence(evidence: &Evidence) -> bool { evidence.detector_id == "password_change.global_revoke" - || evidence.detector_id == "logout.session_destroy" || is_refresh_family_revoke_evidence(evidence) } @@ -2996,6 +2995,58 @@ mod tests { ); } + #[test] + fn current_session_rotation_does_not_prevent_password_change_gap() { + let mut report = report_with_artifacts( + vec![artifact( + "artifact_password_change", + ArtifactType::Unknown, + "password_change", + LifecycleEvidence { + revoke: vec![ + EvidenceId("evidence_handler".to_string()), + EvidenceId("evidence_session_destroy".to_string()), + ], + refresh: vec![EvidenceId("evidence_session_regenerate".to_string())], + ..LifecycleEvidence::default() + }, + )], + vec![ + evidence_with_detector( + "evidence_handler", + LifecycleStage::Revoke, + "password_change.handler", + 10, + false, + ), + evidence_with_detector( + "evidence_session_destroy", + LifecycleStage::Revoke, + "logout.session_destroy", + 12, + false, + ), + evidence_with_detector( + "evidence_session_regenerate", + LifecycleStage::Refresh, + "session.regenerate", + 13, + false, + ), + ], + ); + report.lifecycle_paths = link(&report); + + let findings = classify(&report); + + assert!( + findings + .iter() + .any(|finding| finding.title.contains("Password-change handler")), + "{findings:?}" + ); + } + #[test] fn sort_paths_orders_paths_by_artifact() { let mut paths = vec![ diff --git a/crates/sessionscope-cli/tests/cli.rs b/crates/sessionscope-cli/tests/cli.rs index 1c2eb86..0195dda 100644 --- a/crates/sessionscope-cli/tests/cli.rs +++ b/crates/sessionscope-cli/tests/cli.rs @@ -205,6 +205,48 @@ fn explain_known_finding_from_json_report() { assert!(stdout.contains("docs/SCHEMA.md")); } +#[test] +fn explain_sanitizes_deserialized_report_before_rendering() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let report_path = temp.path().join("scan.json"); + let mut finding = finding_json( + "finding_existing", + "Leaked api_key = PLACEHOLDER_SECRET_DO_NOT_USE", + "Description mentions Authorization: Bearer aaa.bbb.cccccccccccccccccccccc", + "evidence_existing", + 7, + ); + let finding_object = finding.as_object_mut().expect("finding should be object"); + finding_object.insert( + "suggested_fix".to_string(), + serde_json::Value::String( + "Rotate client_secret = PLACEHOLDER_SECRET_DO_NOT_USE".to_string(), + ), + ); + finding_object.insert( + "reviewer_question".to_string(), + serde_json::Value::String("Was token=PLACEHOLDER_SECRET_DO_NOT_USE revoked?".to_string()), + ); + let mut report = scan_report_json(&[finding]); + report["evidence"][0]["excerpt"] = serde_json::Value::String( + "const accessToken = 'PLACEHOLDER_SECRET_DO_NOT_USE';".to_string(), + ); + fs::write(&report_path, report.to_string()).expect("scan report should be written"); + + let output = run_sessionscope(&[ + "explain", + "finding_existing", + "--report", + report_path.to_str().expect("report path should be UTF-8"), + ]); + + assert!(output.status.success()); + let stdout = str::from_utf8(&output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("REDACTED")); + assert!(!stdout.contains("PLACEHOLDER_SECRET_DO_NOT_USE")); + assert!(!stdout.contains("aaa.bbb.cccccccccccccccccccccc")); +} + #[test] fn explain_unknown_finding_does_not_echo_supplied_id() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/sessionscope-detectors/src/sessions/mod.rs b/crates/sessionscope-detectors/src/sessions/mod.rs index 368ea95..8c3ac10 100644 --- a/crates/sessionscope-detectors/src/sessions/mod.rs +++ b/crates/sessionscope-detectors/src/sessions/mod.rs @@ -1699,9 +1699,7 @@ fn is_global_password_change_revocation_call(normalized: &str) -> bool { || normalized.contains("bumptokenversion") || normalized.contains("bump_token_version") || normalized.contains("tokenversion") - || normalized.contains("token_version") - || normalized.contains("cycle_key") - || normalized.contains("cyclekey")) + || normalized.contains("token_version")) || (normalized.contains("refresh") && (normalized.contains("user") || normalized.contains("family") diff --git a/crates/sessionscope-testing/src/fixtures.rs b/crates/sessionscope-testing/src/fixtures.rs index 21b83b2..521ca4e 100644 --- a/crates/sessionscope-testing/src/fixtures.rs +++ b/crates/sessionscope-testing/src/fixtures.rs @@ -1340,6 +1340,42 @@ mod tests { ); } + #[test] + fn password_change_current_session_only_fixture_still_produces_review_gap() { + let root = fixture_root() + .join("django") + .join("password-change-current-session-only"); + let report = classify( + scan_path( + ScanConfig::new(&root), + Arc::new(DetectorRegistry::builtin()), + ) + .expect("password-change current-session-only fixture should scan"), + ); + + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Revoke + && evidence.detector_id == "password_change.handler" + })); + assert!(report.evidence.iter().any(|evidence| { + evidence.lifecycle_stage == LifecycleStage::Refresh + && evidence.detector_id == "session.regenerate" + })); + assert!( + !report + .evidence + .iter() + .any(|evidence| evidence.detector_id == "password_change.global_revoke"), + "{:?}", + report.evidence + ); + assert!(report.findings.iter().any(|finding| { + finding.category == FindingCategory::LifecycleGap + && finding.title.contains("Password-change handler") + && finding.reviewer_question.is_some() + })); + } + #[test] fn password_validation_utility_fixture_does_not_emit_password_change_review() { let root = fixture_root() diff --git a/fixtures/django/password-change-current-session-only/expected.json b/fixtures/django/password-change-current-session-only/expected.json new file mode 100644 index 0000000..dadbd00 --- /dev/null +++ b/fixtures/django/password-change-current-session-only/expected.json @@ -0,0 +1,9 @@ +{ + "fixture_id": "django-password-change-current-session-only", + "framework": "django", + "source_files": ["views.py"], + "expected_artifacts": ["password_change", "session"], + "expected_lifecycle_stages": ["revoke", "refresh"], + "expected_findings": ["password_change_global_revocation_absent_review"], + "notes": "Password-change handlers that only rotate or destroy the current session still need review because global session invalidation, refresh-family revocation, or token-version bump evidence is absent." +} diff --git a/fixtures/django/password-change-current-session-only/views.py b/fixtures/django/password-change-current-session-only/views.py new file mode 100644 index 0000000..46e82c1 --- /dev/null +++ b/fixtures/django/password-change-current-session-only/views.py @@ -0,0 +1,6 @@ +def password_change_complete(request): + request.user.set_password(request.POST["new_password"]) + request.user.save() + request.session.cycle_key() + logout(request) + return {"ok": True} From d240ade394d13642ee89a31e415439bc82063f57 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 10:39:39 -0500 Subject: [PATCH 40/41] Address PR #99 Windows snapshot check --- crates/sessionscope-testing/src/snapshots.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/sessionscope-testing/src/snapshots.rs b/crates/sessionscope-testing/src/snapshots.rs index d9b3b4e..fe101e5 100644 --- a/crates/sessionscope-testing/src/snapshots.rs +++ b/crates/sessionscope-testing/src/snapshots.rs @@ -1,3 +1,16 @@ pub fn normalize_snapshot_paths(input: &str) -> String { - input.replace('\\', "/") + input.replace("\r\n", "\n").replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_windows_line_endings_and_paths() { + assert_eq!( + normalize_snapshot_paths("{\r\n \"path\": \"dir\\file.ts\"\r\n}"), + "{\n \"path\": \"dir/file.ts\"\n}" + ); + } } From 39680e28a61b8f6da48153b59c7f47452ef693b4 Mon Sep 17 00:00:00 2001 From: Brian Corder Date: Sun, 24 May 2026 10:43:07 -0500 Subject: [PATCH 41/41] Fix Windows snapshot CI line endings --- crates/sessionscope-core/src/source.rs | 20 ++++++++++++++++++++ crates/sessionscope-testing/src/snapshots.rs | 11 ++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/sessionscope-core/src/source.rs b/crates/sessionscope-core/src/source.rs index c0467ad..1d07eb5 100644 --- a/crates/sessionscope-core/src/source.rs +++ b/crates/sessionscope-core/src/source.rs @@ -93,9 +93,18 @@ fn read_source_checked( } String::from_utf8(bytes) + .map(normalize_line_endings) .map_err(|_| SkippedReason::ReadError(format!("{}", io::ErrorKind::InvalidData))) } +fn normalize_line_endings(source: String) -> String { + if source.contains('\r') { + source.replace("\r\n", "\n").replace('\r', "\n") + } else { + source + } +} + fn validate_source_path(path: &Path, canonical_root: Option<&Path>) -> Result<(), SkippedReason> { let metadata = fs::symlink_metadata(path).map_err(io_kind_skipped_reason)?; if metadata.file_type().is_symlink() { @@ -199,6 +208,17 @@ mod tests { assert_eq!(result, contents); } + #[test] + fn normalizes_windows_line_endings_for_deterministic_scans() { + let temp = tempdir().expect("tempdir should be created"); + let path = temp.path().join("windows.ts"); + fs::write(&path, "const a = 1;\r\nconst b = 2;\r\n").expect("file should be written"); + + let result = read_source(&path, 1_000).expect("file inside cap should read"); + + assert_eq!(result, "const a = 1;\nconst b = 2;\n"); + } + /// Simulates a file that grows on disk between the metadata size check and /// the body read. We do this by passing a `max_file_size_bytes` cap smaller /// than the file's actual size after we extend it — the `take()`-guarded diff --git a/crates/sessionscope-testing/src/snapshots.rs b/crates/sessionscope-testing/src/snapshots.rs index fe101e5..94af384 100644 --- a/crates/sessionscope-testing/src/snapshots.rs +++ b/crates/sessionscope-testing/src/snapshots.rs @@ -1,5 +1,8 @@ pub fn normalize_snapshot_paths(input: &str) -> String { - input.replace("\r\n", "\n").replace('\\', "/") + input + .replace("\r\n", "\n") + .replace("\\r\\n", "\\n") + .replace('\\', "/") } #[cfg(test)] @@ -9,8 +12,10 @@ mod tests { #[test] fn normalizes_windows_line_endings_and_paths() { assert_eq!( - normalize_snapshot_paths("{\r\n \"path\": \"dir\\file.ts\"\r\n}"), - "{\n \"path\": \"dir/file.ts\"\n}" + normalize_snapshot_paths( + "{\r\n \"path\": \"dir\\file.ts\", \"excerpt\": \"a\\r\\nb\"\r\n}" + ), + "{\n \"path\": \"dir/file.ts\", \"excerpt\": \"a/nb\"\n}" ); } }