Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
89d4410
Fix #70: P2.1 Detector — JWT verify-options key extraction
bjcorder May 22, 2026
29e366c
Fix #71: P2.2 Classifier — JWT alg:none acceptance
bjcorder May 22, 2026
bd0e318
Fix #72: P2.3 Classifier — JWT algorithm-confusion signal
bjcorder May 22, 2026
ddc731f
Fix #73: P2.4 Classifier — JWT header-trust risks
bjcorder May 22, 2026
1570716
Fix #74: P2.5 Classifier — nbf, clock-skew, kid validation gaps
bjcorder May 22, 2026
68a1e05
Fix #75: P2.6 Fixtures — JWT crypto-trust scenarios
bjcorder May 22, 2026
c82b4aa
Fix #76: P2.7 Docs — P2 check catalog and coverage matrix rows
bjcorder May 22, 2026
96cd421
Fix #73: Address JWT crypto-trust review findings
bjcorder May 22, 2026
bf9b3da
Fix #73: Keep JWT verify helper clippy-clean
bjcorder May 22, 2026
8d16ee3
Fix #77: P3.1 oauth_flow scaffold and registration
bjcorder May 23, 2026
311128d
Fix #85: P3.9 OAuth state nonce and PKCE redaction
bjcorder May 23, 2026
2995073
Fix #78: P3.2 PKCE missing classifier
bjcorder May 23, 2026
52b8c6e
Fix #79: P3.3 OAuth state integrity classifier
bjcorder May 23, 2026
7a149cb
Fix #80: P3.4 OIDC nonce classifier
bjcorder May 23, 2026
853ce6e
Fix #81: P3.5 redirect URI wildcard classifier
bjcorder May 23, 2026
79eb8db
Fix #82: P3.6 client_storage detector registration
bjcorder May 23, 2026
98ffe39
Fix #83: P3.7 client storage hygiene classifier
bjcorder May 23, 2026
1540792
Fix #84: P3.8 OAuth and client storage fixtures
bjcorder May 23, 2026
d564ca6
Fix #86: P3.10 document OAuth flow and storage coverage
bjcorder May 23, 2026
9c222bf
Fix #83: restore client storage test import
bjcorder May 23, 2026
071ef91
Fix #83: scope client storage test import
bjcorder May 23, 2026
867fd6d
Fix #82: narrow client storage URL token evidence
bjcorder May 23, 2026
4814668
Fix #77: address P3 review flow scoping and redaction
bjcorder May 23, 2026
a69a59f
Fix #77: tighten P3 review redaction and flow bounds
bjcorder May 23, 2026
b1ddd2a
Fix #77: resolve focused P3 review blockers
bjcorder May 23, 2026
180b44e
Fix #77: preserve pre-flow OAuth evidence in segment
bjcorder May 23, 2026
d966f7f
Fix #83: keep P3 classifier tests clippy-clean
bjcorder May 23, 2026
6336bb0
Address PR #98 review findings
bjcorder May 24, 2026
7defb93
Potential fix for pull request finding 'CodeQL / Cleartext logging of…
bjcorder May 24, 2026
22b0932
Address PR #98 review findings
bjcorder May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
17 changes: 14 additions & 3 deletions crates/sessionscope-classifier/src/bearer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub fn classify(report: &ScanReport) -> Vec<Finding> {

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));
Expand Down Expand Up @@ -63,7 +63,17 @@ fn token_contexts<'a>(
contexts.into_values().collect()
}

fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec<Finding> {
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<Finding> {
let mut findings = Vec::new();

for evidence in &context.evidence {
Expand All @@ -85,7 +95,7 @@ fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec<Finding> {
"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,
Expand All @@ -101,6 +111,7 @@ fn classify_deterministic_risks(context: &TokenContext<'_>) -> Vec<Finding> {
.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,
Expand Down
193 changes: 193 additions & 0 deletions crates/sessionscope-classifier/src/client_storage.rs
Original file line number Diff line number Diff line change
@@ -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<Finding> {
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<Finding>) -> Vec<Finding> {
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());
}
}
4 changes: 4 additions & 0 deletions crates/sessionscope-classifier/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Expand Down
1 change: 1 addition & 0 deletions crates/sessionscope-classifier/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand Down
Loading
Loading