diff --git a/CHANGELOG.md b/CHANGELOG.md index 5242fb7..7549172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ 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/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..c5c662f --- /dev/null +++ b/crates/sessionscope-classifier/src/client_storage.rs @@ -0,0 +1,193 @@ +use std::collections::BTreeSet; + +use sessionscope_model::{ + Artifact, 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, Evidence, LifecycleEvidence, LifecycleStage, + SCHEMA_VERSION, ScanSummary, SourceLocation, + }; + + use super::*; + + fn report_with(detector_id: &str) -> ScanReport { + let evidence_id = EvidenceId("evidence_storage".to_string()); + let lifecycle_evidence = LifecycleEvidence { + store: vec![evidence_id.clone()], + ..Default::default() + }; + 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}" + ); + } + } + + #[test] + fn document_cookie_write_is_evidence_only_for_p3() { + let findings = classify(&report_with("client_storage.document_cookie.write")); + + assert!(findings.is_empty()); + } +} diff --git a/crates/sessionscope-classifier/src/lib.rs b/crates/sessionscope-classifier/src/lib.rs index 4046d34..4da1566 100644 --- a/crates/sessionscope-classifier/src/lib.rs +++ b/crates/sessionscope-classifier/src/lib.rs @@ -1,7 +1,9 @@ pub mod bearer; +pub mod client_storage; 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 +14,8 @@ 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(client_storage::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/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-classifier/src/oauth_flow.rs b/crates/sessionscope-classifier/src/oauth_flow.rs new file mode 100644 index 0000000..09fd28e --- /dev/null +++ b/crates/sessionscope-classifier/src/oauth_flow.rs @@ -0,0 +1,527 @@ +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)); + 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, + 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, + 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, + 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 lifecycle_evidence = LifecycleEvidence { + issue: evidence.iter().map(|item| item.id.clone()).collect(), + ..Default::default() + }; + + 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) + .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()); + } + + #[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()); + } + + #[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()); + } +} diff --git a/crates/sessionscope-core/src/redaction.rs b/crates/sessionscope-core/src/redaction.rs index 47361fd..35624a6 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") }); @@ -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") @@ -52,13 +56,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|clientSecret|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 +107,7 @@ pub enum RedactionContext { Jwt, Bearer, ApiKey, + OAuth, Generic, } @@ -217,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(); @@ -685,6 +693,56 @@ 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_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_unquoted_camel_case_client_secret() { + let output = redact_sensitive_values("const clientSecret = abcdefghijklmno"); + + assert!(output.contains("[REDACTED]")); + assert!(!output.contains("abcdefghijklmno")); + } + #[test] fn redacts_placeholder_secret_values() { let output = redact_sensitive_values( @@ -709,6 +767,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-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/client_storage/mod.rs b/crates/sessionscope-detectors/src/client_storage/mod.rs new file mode 100644 index 0000000..4913936 --- /dev/null +++ b/crates/sessionscope-detectors/src/client_storage/mod.rs @@ -0,0 +1,463 @@ +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 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") +}); +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|session)\s*=|/(?:access[_-]?token|id[_-]?token|refresh[_-]?token|jwt|bearer)(?:=|/)(?:\$\{|[A-Za-z0-9._~+%-]))"#) + .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") +}); +static CLIENT_SECRET_UNQUOTED_VALUE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?i)(\b(?:client_secret|clientSecret)\b\s*[:=]\s*)([A-Za-z0-9._~+/-]{12,})"#) + .expect("client secret unquoted 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 { + let mut output = STORAGE_VALUE_RE + .replace_all(line, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string(); + output = SENSITIVE_VALUE_RE + .replace_all(&output, format!("${{1}}${{2}}{REDACTION}${{4}}")) + .to_string(); + CLIENT_SECRET_UNQUOTED_VALUE_RE + .replace_all(&output, format!("$1{REDACTION}")) + .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 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 ignores_benign_routes_with_token_shaped_words() { + let output = ClientStorageDetector.detect(&input( + "src/routes.ts", + r#" +router.get("/session", handler) +router.get("/session/callback", handler) +router.get("/auth/callback", handler) +const route = "/bearer" +"#, + )); + + assert!( + output + .evidence + .iter() + .all(|evidence| evidence.detector_id != "client_storage.url_path_or_fragment.token"), + "benign routes should not produce URL token evidence: {:?}", + output.evidence + ); + } + + #[test] + fn keeps_explicit_url_fragment_and_path_token_evidence() { + let output = ClientStorageDetector.detect(&input( + "src/components/Auth.tsx", + r#" +const fragmentUrl = `/callback#access_token=${accessToken}` +const pathUrl = `/access_token/${accessToken}` +"#, + )); + + let count = output + .evidence + .iter() + .filter(|evidence| evidence.detector_id == "client_storage.url_path_or_fragment.token") + .count(); + assert_eq!(count, 2); + } + + #[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") + ); + } + + #[test] + fn redacts_unquoted_client_secret_literals() { + let output = ClientStorageDetector.detect(&input( + "src/components/Auth.tsx", + "const clientSecret = abcdefghijklmno", + )); + + let rendered = format!("{:?}", output.evidence); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("abcdefghijklmno")); + } +} 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 c7e5838..c2428d8 100644 --- a/crates/sessionscope-detectors/src/jwt/mod.rs +++ b/crates/sessionscope-detectors/src/jwt/mod.rs @@ -3168,6 +3168,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..e74656c 100644 --- a/crates/sessionscope-detectors/src/lib.rs +++ b/crates/sessionscope-detectors/src/lib.rs @@ -1,7 +1,9 @@ pub mod bearer; +pub mod client_storage; 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..ea36f2c --- /dev/null +++ b/crates/sessionscope-detectors/src/oauth_flow/mod.rs @@ -0,0 +1,566 @@ +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)(OAuth2Strategy|authorizationUrl|authorization_url|OAuthProvider|OIDCProvider|\b(?:oauth|oidc|openid|authlib)\s*\.\s*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}(===|==|!=|!==)[^\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(|| { + 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)((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") +}); +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") +}); +static OAUTH_QUOTED_LITERAL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(["'`])([^"'`,)]{8,})(["'`])"#) + .expect("oauth quoted literal 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) && !is_import_only_line(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 flow_lines = signals + .iter() + .filter(|signal| signal.detector_id == "oauth.flow.auth_code") + .map(|signal| signal.line) + .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, + "oauth_auth_code_flow", + input.path, + flow_line.to_string().as_str(), + ]); + let mut lifecycle_evidence = LifecycleEvidence::default(); + let mut evidence = Vec::new(); + + 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(&[ + 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(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); + } + output +} + +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 + 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) +} + +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 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()); + 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(); + if STATE_RE.is_match(line) || NONCE_RE.is_match(line) || PKCE_RE.is_match(line) { + output = OAUTH_QUOTED_LITERAL_RE + .replace_all(&output, format!("$1{REDACTION}$3")) + .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 ignores_non_oauth_register_calls() { + let output = OAuthFlowDetector.detect(&input( + r#" +const { register } = useForm(); +register("email"); +router.register("/session", handler); +"#, + )); + + assert!(output.evidence.is_empty()); + assert!(output.artifacts.is_empty()); + } + + #[test] + fn redacts_nested_oauth_literals() { + let output = OAuthFlowDetector.detect(&input( + r#" +const url = client.authorizationUrl({ response_type: 'code', state: makeState("customer-return-state-12345"), nonce: makeNonce('nonce-value-123456'), code_challenge: deriveChallenge(`challenge-value-123456`) }); +"#, + )); + + let rendered = format!("{:?}", output.evidence); + assert!(rendered.contains("[REDACTED]")); + assert!(!rendered.contains("customer-return-state-12345")); + assert!(!rendered.contains("nonce-value-123456")); + assert!(!rendered.contains("challenge-value-123456")); + } + + #[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']" + )); + } + + #[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); + } +} 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..dd37549 100644 --- a/crates/sessionscope-detectors/src/registry.rs +++ b/crates/sessionscope-detectors/src/registry.rs @@ -1,8 +1,10 @@ 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; use crate::query_params::QueryParameterTokenDetector; use crate::reset_tokens::ResetTokenDetector; use crate::sessions::{RefreshTokenLifecycleDetector, SessionLifecycleDetector}; @@ -22,6 +24,8 @@ impl DetectorRegistry { Self::empty() .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)) 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/lib.rs b/crates/sessionscope-reporters/src/lib.rs index 97e8d7d..8d5d657 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,90 @@ 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 a sensitive value" + ); + } + } + } + + #[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")); + } + } } 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/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/docs/COVERAGE_MATRIX.md b/docs/COVERAGE_MATRIX.md index 81c08ad..6c4b20c 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 with explicit values in URL path segments or fragments; query params remain covered separately; `document.cookie` token writes are retained as detector evidence only in P3 | 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/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]`. 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/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 a8b616f..641e582 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,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 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 - Refresh tokens without rotation evidence - Logout without revocation evidence @@ -133,19 +136,6 @@ SessionScope is built around defensive, evidence-bound checks. The current and p - Token accepted from query parameters - Review-required token reuse across services, environments, or trust boundaries -### JWT crypto-trust check catalog - -| Check ID | Severity | Description | -| --- | --- | --- | -| `jwt_alg_none_accepted` | high or medium | Flags explicit `none` algorithm acceptance as high severity, and missing algorithm allow-lists on default-sensitive `jsonwebtoken`/PyJWT paths as medium framework-default review. | -| `jwt_alg_confusion_signal` | high or medium | Flags mixed HMAC/asymmetric algorithm allow-lists, or asks for review when HMAC algorithms are paired with public-key-like material. | -| `jwt_jku_header_trust` | medium | Reviews complete-token verification paths that pass the attacker-controlled `jku` header into key-resolution logic without visible trust constraints. | -| `jwt_x5u_header_trust` | medium | Reviews complete-token verification paths that pass the attacker-controlled `x5u` header into key-resolution logic without visible trust constraints. | -| `jwt_embedded_jwk_trust` | medium | Reviews complete-token verification paths that pass an embedded `jwk` header into key-resolution logic without visible trust constraints. | -| `jwt_nbf_missing` | low | Flags validation paths without source-visible not-before (`nbf`) enforcement, or with not-before checks disabled. | -| `jwt_clock_skew_review` | medium | Reviews dynamic clock tolerance/leeway or static tolerance above 60 seconds. | -| `jwt_kid_unvalidated_review` | medium | Flags `kid` header reads used for key selection without visible allow-list, pinned key, key-map lookup, or JWKS validation evidence. | - ## JSON report shape The canonical schema is documented in [SCHEMA.md](SCHEMA.md). A cookie audit fragment from a real scan looks like this: 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..6135ae2 --- /dev/null +++ b/fixtures/generic-python/authlib-nonce/app.py @@ -0,0 +1,6 @@ +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') +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..9d3424c --- /dev/null +++ b/fixtures/generic-python/authlib-pkce/app.py @@ -0,0 +1,4 @@ +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-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..b1eb40b --- /dev/null +++ b/fixtures/generic-python/authlib-state/app.py @@ -0,0 +1,4 @@ +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') 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." +}