Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion crates/sessionscope-classifier/src/session_fixation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ fn framework_for<'a>(artifact: &'a Artifact, stores: &[&FixationRecord<'a>]) ->
.flat_map(|record| record.artifact.framework_hints.iter()),
)
.map(String::as_str)
.find(|hint| matches!(*hint, "express" | "cookie-session" | "django"))
.find(|hint| matches!(*hint, "express" | "cookie-session" | "django" | "nextjs"))
.unwrap_or_else(|| {
artifact
.framework_hints
Expand All @@ -292,6 +292,9 @@ fn suggested_fix_for_framework(framework: &str) -> &'static str {
"django" => {
"Use Django login(request, user), auth_login(request, user), or request.session.cycle_key() in the transition path so session rotation is visible."
}
"nextjs" => {
"In a Next.js App Router route handler, call cookies().delete('session') followed immediately by cookies().set('session', newValue, options) at the authentication or privilege transition so cookie rotation is source-visible."
}
_ => {
"Identify the framework's session rotation primitive and call it during authentication and privilege transitions."
}
Expand Down
40 changes: 38 additions & 2 deletions crates/sessionscope-detectors/src/sessions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,7 @@ fn collect_js_call_signal(node: Node<'_>, source: &str, signals: &mut Vec<Signal
LifecycleStage::Issue,
node,
source,
"express",
js_session_framework_hint(&text),
Confidence::High,
false,
false,
Expand Down Expand Up @@ -747,7 +747,7 @@ fn collect_js_call_signal(node: Node<'_>, source: &str, signals: &mut Vec<Signal
LifecycleStage::Refresh,
node,
source,
"cookie-session",
js_session_reissue_framework(&text),
Confidence::Medium,
true,
false,
Expand All @@ -768,6 +768,31 @@ fn collect_js_call_signal(node: Node<'_>, source: &str, signals: &mut Vec<Signal
));
}

// Next.js App Router login handlers are exported functions (e.g. `export async function
// POST`) that authenticate via helper calls rather than `app.post`/`router.post` route
// strings, so the route-based auth-transition detection above does not observe them, and the
// export-statement handler path keys on login/signin naming that authenticate-style handlers
// miss. When a Next.js `cookies()` session store sits inside an authentication context,
// surface the auth transition from the enclosing handler with the handler's own scope so a
// co-located clear-and-reissue can suppress it. Privilege transitions are already covered by
// the export-statement path; emitting them here too would double-count. Guarded to
// `cookies()` calls so Express `response.cookie` writes do not gain a duplicate signal.
if is_js_session_cookie_store_call(node, source)
&& node_text(node, source).contains("cookies()")
&& is_auth_transition_context(&normalize_symbol(&ancestor_context_text(node, source)))
{
signals.push(session_fixation_signal(
"session.auth_transition",
LifecycleStage::Issue,
node,
source,
"nextjs",
Confidence::High,
false,
false,
));
}

if is_js_logout_route(&text) {
signals.push(signal(
SignalSpec::revoke(
Expand Down Expand Up @@ -1623,6 +1648,17 @@ fn js_cookie_clear_framework(text: &str) -> &'static str {
}
}

fn js_session_reissue_framework(text: &str) -> &'static str {
if text.contains("cookies()")
|| text.contains(".cookies.delete")
|| text.contains(".cookies.set")
{
"nextjs"
} else {
"cookie-session"
}
}

fn is_js_provider_session_config_call(normalized: &str) -> bool {
contains_provider_context(normalized)
&& (normalized.contains("nextauth")
Expand Down
88 changes: 86 additions & 2 deletions crates/sessionscope-testing/src/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub fn fixture_cases() -> io::Result<Vec<FixtureCase>> {
}

pub fn load_expected_fixture(path: &Path) -> io::Result<ExpectedFixture> {
let contents = fs::read_to_string(path)?;
let contents = fs::read_to_string(path)?; // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
serde_json::from_str(&contents)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))
}
Expand All @@ -81,7 +81,7 @@ pub fn fixture_source_text(case: &FixtureCase) -> io::Result<Vec<(String, String
.source_files
.iter()
.map(|source_file| {
let text = fs::read_to_string(case.root.join(source_file))?;
let text = fs::read_to_string(case.root.join(source_file))?; // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path
Ok((source_file.clone(), text))
})
.collect()
Expand Down Expand Up @@ -450,6 +450,10 @@ mod tests {
.join("dependency-auth-lifecycle"),
vec![(Some("access_jwt"), ArtifactType::AccessJwt)],
),
(
fixture_root().join("express").join("jwt-validation"),
vec![(Some("access_jwt"), ArtifactType::AccessJwt)],
),
];

for (root, expected_artifacts) in cases {
Expand Down Expand Up @@ -501,6 +505,42 @@ mod tests {
}));
}

#[test]
fn express_jwt_fixture_classifies_missing_and_decode_evidence() {
let root = fixture_root().join("express").join("jwt-validation");
let report = classify(
scan_path(
ScanConfig::new(&root),
Arc::new(DetectorRegistry::builtin()),
)
.expect("express JWT fixture should scan"),
);

assert!(
report.artifacts.iter().any(|artifact| {
artifact.display_name.as_deref() == Some("access_jwt")
&& artifact.artifact_type == ArtifactType::AccessJwt
}),
"express JWT fixture should expose the issued access_jwt artifact"
);
assert!(report.findings.iter().any(|finding| {
finding.category == FindingCategory::MissingValidationEvidence
&& finding.title.contains("issuer")
}));
assert!(report.findings.iter().any(|finding| {
finding.category == FindingCategory::MissingValidationEvidence
&& finding.title.contains("audience")
}));
assert!(report.findings.iter().any(|finding| {
finding.category == FindingCategory::HighConfidenceMisconfiguration
&& finding.title.contains("without signature verification")
}));
assert!(report.findings.iter().any(|finding| {
finding.category == FindingCategory::HighConfidenceMisconfiguration
&& finding.title.contains("expiry enforcement")
}));
}

#[test]
fn jwt_crypto_trust_fixtures_emit_expected_findings() {
let cases = [
Expand Down Expand Up @@ -652,6 +692,10 @@ mod tests {
fixture_root().join("generic-ts").join("oauth-flow-state"),
["static literal", "without visible verification"].as_slice(),
),
(
fixture_root().join("fastapi").join("oauth-flow"),
["static literal", "without visible verification"].as_slice(),
),
(
fixture_root().join("generic-ts").join("oauth-flow-nonce"),
["no source-visible nonce"].as_slice(),
Expand Down Expand Up @@ -1002,6 +1046,26 @@ mod tests {
]
.as_slice(),
),
(
fixture_root().join("generic-ts").join("sdk-auth0"),
["auth0"].as_slice(),
[
"refresh.provider",
"logout.provider_revoke",
"bearer.dynamic_provider",
]
.as_slice(),
),
(
fixture_root().join("generic-ts").join("sdk-supabase"),
["supabase"].as_slice(),
[
"refresh.provider",
"logout.provider_revoke",
"bearer.dynamic_provider",
]
.as_slice(),
),
];

for (root, provider_hints, detector_ids) in cases {
Expand Down Expand Up @@ -1730,6 +1794,9 @@ mod tests {
fixture_root()
.join("django")
.join("session-fixation-signals"),
fixture_root()
.join("nextjs")
.join("session-fixation-signals"),
];

for root in cases {
Expand Down Expand Up @@ -1776,6 +1843,21 @@ mod tests {
root.display()
);

// For the Next.js fixture, verify the suggested fix contains the
// Next.js-specific clear-and-reissue pattern, proving framework routing.
if root.to_string_lossy().contains("nextjs") {
assert!(
report.findings.iter().any(|finding| {
finding
.suggested_fix
.as_deref()
.unwrap_or("")
.contains("cookies().set")
}),
"nextjs session-fixation findings should suggest cookies().set clear-and-reissue"
);
}

for format in [ReportFormat::Json, ReportFormat::Markdown] {
let rendered = render(&report, format);
assert!(!rendered.contains("password\":"));
Expand All @@ -1793,6 +1875,8 @@ mod tests {
fixture_root()
.join("generic-python")
.join("trust-boundary-token-reuse"),
fixture_root().join("fastapi").join("trust-boundary"),
fixture_root().join("django").join("trust-boundary"),
];

for root in cases {
Expand Down
4 changes: 4 additions & 0 deletions crates/sessionscope-testing/tests/json_snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const SNAPSHOT_CASES: &[SnapshotCase] = &[
name: "django-session-and-reset-flow",
fixture_segments: &["django", "session-and-reset-flow"],
},
SnapshotCase {
name: "nextjs-session-fixation-signals",
fixture_segments: &["nextjs", "session-fixation-signals"],
},
SnapshotCase {
name: "generic-js-jwt-crypto-trust-alg-none",
fixture_segments: &["generic-js", "jwt-crypto-trust-alg-none"],
Expand Down
10 changes: 5 additions & 5 deletions docs/COVERAGE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m
| `cookie_dynamic_path_scope` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **review-required:** dynamic Path option | transmit | `dynamic_review_required` | `dynamic_review_required` | Runtime path scope requires review. |
| `cookie_dynamic_expiry` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **review-required:** dynamic Max-Age or Expires | expire | `dynamic_review_required` | `dynamic_review_required` | Runtime lifetime requires review. |
| `cookie_missing_expiry` | JS/TS, Python | Express, Next.js, FastAPI, Django, generic Python | Cookie APIs | **supported:** session-like/signed cookie with no explicit expiry | expire | `lifecycle_gap` | `lifecycle_gap` | Explicit lifetime evidence is missing. |
| `sliding_expiry_without_rotation_review` | JS/TS, Python | Express, Django/session helpers, generic JS/TS/Python session or refresh helpers | Session middleware, session regeneration, refresh TTL helpers | **review-required:** rolling/sliding/idle TTL or Max-Age extension evidence without linked `session.regenerate`, `cycle_key`, session reissue, or refresh-token rotation; **not covered:** runtime-only framework settings outside scanned source | expire/refresh | `lifecycle_gap` | `lifecycle_gap` | Sliding expiry should be paired with session or refresh-token rotation evidence. |
| `sliding_expiry_without_rotation_review` | JS/TS, Python | Express, generic JS/TS/Python session middleware or refresh helpers | Session middleware (`session.middleware` evidence), refresh expire/store evidence | **review-required:** source-visible session-config or helper code whose evidence excerpt contains rolling/sliding/idle/touch/refreshSessionTtl/extend_session **and** maxage/ttl/expires/expiresat terms, without linked `session.regenerate`, `cycle_key`, session reissue, or refresh-token rotation evidence; **not covered:** Django `SESSION_COOKIE_AGE` in settings.py (this setting does not appear in `session.middleware` evidence) and other runtime-only framework settings outside scanned source | expire/refresh | `lifecycle_gap` | `lifecycle_gap` | Sliding expiry should be paired with session or refresh-token rotation evidence. |
| `cookie_host_prefix_path_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with Path missing or not `/`; **review-required:** dynamic Path | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must set `Path=/`. |
| `cookie_host_prefix_domain_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with any Domain; **review-required:** dynamic Domain | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must omit Domain and remain host-only. |
| `cookie_host_prefix_secure_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Host-` name with missing/false Secure; **review-required:** dynamic/default Secure | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Host-` cookies must be Secure. |
| `cookie_secure_prefix_secure_violation` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **supported:** literal `__Secure-` name with missing/false Secure; **review-required:** dynamic/default Secure | transmit | `high_confidence_misconfiguration` or `dynamic_review_required` | matching finding category | `__Secure-` cookies must be Secure. |
| `cookie_partitioned_review` | JS/TS, Python | Express, Next.js, FastAPI, generic Python; Django when runtime API exposes the option | Cookie APIs and static `Set-Cookie` | **review-required:** `partitioned` / `Partitioned` cookie evidence | transmit | `dynamic_review_required` | `dynamic_review_required` | Partitioned/CHIPS usage depends on embed context SessionScope cannot prove. |
| `cookie_partitioned_review` | JS/TS, Python | Express, Next.js, FastAPI, generic Python; Django `response.set_cookie(...)` in views when the `partitioned` kwarg is source-visible | Cookie APIs and static `Set-Cookie` | **review-required:** source-visible `partitioned=True` kwarg in `response.set_cookie(...)` (Python), `partitioned: true` option in JS/TS cookie APIs, or `Partitioned` in a static `Set-Cookie` header string; **not covered:** Django `settings.py` or Django session engine configuration | transmit | `dynamic_review_required` | `dynamic_review_required` | Partitioned/CHIPS usage depends on embed context SessionScope cannot prove. |
| `cookie_domain_leak_review` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **review-required:** broad Domain on non-session cookies | transmit | `dynamic_review_required` | `dynamic_review_required` | Broad non-session Domain scope needs host-boundary review. |
| `cookie_conflicting_writes_review` | JS/TS, Python | Express, Next.js, FastAPI, Django runtime `set_cookie`, generic Python | Cookie APIs and static `Set-Cookie` | **review-required:** same literal cookie name written multiple times in one handler scope | store | `dynamic_review_required` | `dynamic_review_required` | Last-write-wins behavior requires reviewer confirmation. |
| `jwt_decode_without_verify` | JS/TS, Python | Next.js, FastAPI, Django, generic JS/TS/Python | `jsonwebtoken`, `jose`, `PyJWT` | **supported:** decode/introspection without verification | introspect | `high_confidence_misconfiguration` | `high_confidence_misconfiguration` | JWT content is read without signature verification evidence. |
Expand Down Expand Up @@ -86,8 +86,8 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m
| `trust_boundary_environment_reuse_review` | JS/TS, Python | Generic JS/TS/Python | Environment config | **review-required:** token reused across environments | introspect | `dynamic_review_required` | `dynamic_review_required` | Review cross-environment token reuse. |
| `trust_boundary_provider_scope_review` | JS/TS, Python | Provider fixtures | Auth0, Okta, Cognito, Azure AD, Firebase, Supabase, Clerk, Passport, NextAuth/AuthJS, OAuth/OIDC generic | **review-required:** provider/scope boundary needs review | introspect | `dynamic_review_required` | `dynamic_review_required` | Review provider-managed scope and boundary assumptions. |
| `trust_boundary_metadata_review` | JS/TS, Python | Generic JS/TS/Python | Boundary metadata | **review-required:** boundary metadata present but inconclusive | introspect | `dynamic_review_required` | `dynamic_review_required` | Review token boundary metadata for intended separation. |
| `session_fixation_login_regeneration_review` | JS/TS, Python | Express, Django | Session APIs | **review-required:** login transition without clear regeneration evidence | issue/refresh | `dynamic_review_required` | `dynamic_review_required` | Confirm sessions are regenerated after login. |
| `session_fixation_privilege_regeneration_review` | JS/TS, Python | Express, Django | Session APIs | **review-required:** privilege transition without clear regeneration evidence | issue/refresh | `dynamic_review_required` | `dynamic_review_required` | Confirm sessions are regenerated after privilege elevation. |
| `session_fixation_login_regeneration_review` | JS/TS, Python | Express, Next.js, Django | Session APIs | **review-required:** login transition without clear regeneration evidence | issue/refresh | `dynamic_review_required` | `dynamic_review_required` | Confirm sessions are regenerated after login. |
| `session_fixation_privilege_regeneration_review` | JS/TS, Python | Express, Next.js, Django | Session APIs | **review-required:** privilege transition without clear regeneration evidence | issue/refresh | `dynamic_review_required` | `dynamic_review_required` | Confirm sessions are regenerated after privilege elevation. |

## Provider and framework coverage notes

Expand All @@ -99,4 +99,4 @@ Narrative framework notes remain in [FRAMEWORK_COVERAGE.md](FRAMEWORK_COVERAGE.m

## Not covered in this round

The following are intentional gaps for the v0.2 edge-case-hardening round unless a later issue explicitly changes scope: Flask, Tornado, Sanic, Starlette, NestJS, Koa, Fastify, Hapi, Remix, Hono, SvelteKit, Go, Ruby/Rails, Java/Spring, .NET, PHP, python-jose, authlib JWT validation paths, live provider dashboard policy, live JWKS/OIDC discovery state, custom session-store internals, external dependency-injection containers, and runtime-only deployment configuration.
The following are intentional gaps for the v0.2 edge-case-hardening round unless a later issue explicitly changes scope: Flask, Tornado, Sanic, Starlette, NestJS, Koa, Fastify, Hapi, Remix, Hono, SvelteKit, Go, Ruby/Rails, Java/Spring, .NET, PHP, python-jose (JWT validation), Authlib JWT validation (note: Authlib **OAuth flow** construction and authorization URL code IS covered via the generic-python path), live provider dashboard policy, live JWKS/OIDC discovery state, custom session-store internals, external dependency-injection containers, and runtime-only deployment configuration.
Loading
Loading