use crate::auth_context::{AuthSession, Authenticator, LoginCredentials};
use crate::http_client::HttpClient;
use crate::types::{Confidence, ScanConfig, Severity, Vulnerability};
use anyhow::Result;
use std::collections::HashSet;
use std::sync::Arc;
use tracing::{debug, info};
pub struct SessionAnalyzer {
http_client: Arc<HttpClient>,
}
impl SessionAnalyzer {
pub fn new(http_client: Arc<HttpClient>) -> Self {
Self { http_client }
}
pub async fn analyze(
&self,
url: &str,
credentials: Option<&LoginCredentials>,
existing_session: Option<&AuthSession>,
_config: &ScanConfig,
) -> Result<(Vec<Vulnerability>, usize)> {
let mut vulnerabilities = Vec::new();
let mut tests_run = 0;
if !crate::license::is_feature_available("session_analyzer") {
debug!("[Session] Feature requires Professional license or higher");
return Ok((vulnerabilities, tests_run));
}
info!("[Session] Starting deep session analysis");
tests_run += 1;
if let Some(entropy_vulns) = self.test_session_entropy(url).await? {
vulnerabilities.extend(entropy_vulns);
}
tests_run += 1;
if let Some(creds) = credentials {
if let Some(fixation_vulns) = self.test_session_fixation(url, creds).await? {
vulnerabilities.extend(fixation_vulns);
}
}
tests_run += 1;
if let Some(session) = existing_session {
if let Some(logout_vulns) = self.test_logout_invalidation(url, session).await? {
vulnerabilities.extend(logout_vulns);
}
}
tests_run += 1;
if let Some(creds) = credentials {
if let Some(concurrent_vulns) = self.test_concurrent_sessions(url, creds).await? {
vulnerabilities.extend(concurrent_vulns);
}
}
tests_run += 1;
if let Some(session) = existing_session {
if let Some(timeout_vulns) = self.test_session_timeout_config(url, session).await? {
vulnerabilities.extend(timeout_vulns);
}
}
info!(
"[Session] Analysis complete: {} tests, {} vulnerabilities",
tests_run,
vulnerabilities.len()
);
Ok((vulnerabilities, tests_run))
}
async fn test_session_entropy(&self, url: &str) -> Result<Option<Vec<Vulnerability>>> {
info!("[Session] Testing session ID entropy");
let mut session_ids: Vec<String> = Vec::new();
for _ in 0..10 {
if let Ok(response) = self.http_client.get(url).await {
for (key, value) in &response.headers {
if key.to_lowercase() == "set-cookie" {
if let Some(session_id) = Self::extract_session_id(value) {
session_ids.push(session_id);
}
}
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
if session_ids.len() < 5 {
debug!("[Session] Not enough session IDs collected for entropy analysis");
return Ok(None);
}
let analysis = Self::analyze_entropy(&session_ids);
let mut vulnerabilities = Vec::new();
if analysis.estimated_entropy < 64.0 {
vulnerabilities.push(Vulnerability {
id: format!("session-entropy-{}", uuid::Uuid::new_v4()),
vuln_type: "Low Session ID Entropy".to_string(),
severity: if analysis.estimated_entropy < 32.0 { Severity::Critical } else { Severity::High },
confidence: Confidence::High,
category: "Session Management".to_string(),
url: url.to_string(),
parameter: Some("Session ID".to_string()),
payload: "N/A".to_string(),
description: format!(
"Session IDs have low entropy (~{:.1} bits). Recommended: 128+ bits. \
Sample length: {} chars, character set size: {}",
analysis.estimated_entropy,
analysis.avg_length,
analysis.charset_size
),
evidence: Some(format!(
"Collected {} unique sessions. Avg length: {}, Estimated entropy: {:.1} bits",
analysis.unique_count,
analysis.avg_length,
analysis.estimated_entropy
)),
cwe: "CWE-330".to_string(),
cvss: 7.5,
verified: true,
false_positive: false,
remediation: "Use cryptographically secure random number generator with at least 128 bits of entropy for session ID generation.".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
});
}
if analysis.has_sequential_pattern {
vulnerabilities.push(Vulnerability {
id: format!("session-sequential-{}", uuid::Uuid::new_v4()),
vuln_type: "Predictable Session ID Pattern".to_string(),
severity: Severity::Critical,
confidence: Confidence::Medium,
category: "Session Management".to_string(),
url: url.to_string(),
parameter: Some("Session ID".to_string()),
payload: "N/A".to_string(),
description: "Session IDs appear to follow a sequential or predictable pattern".to_string(),
evidence: Some("Sequential numeric components detected in session IDs".to_string()),
cwe: "CWE-330".to_string(),
cvss: 9.1,
verified: true,
false_positive: false,
remediation: "Use truly random session IDs without sequential components. Never use timestamps, counters, or predictable values.".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
});
}
Ok(if vulnerabilities.is_empty() {
None
} else {
Some(vulnerabilities)
})
}
async fn test_session_fixation(
&self,
url: &str,
credentials: &LoginCredentials,
) -> Result<Option<Vec<Vulnerability>>> {
info!("[Session] Testing session fixation");
let pre_login_response = self.http_client.get(url).await?;
let pre_login_session = Self::extract_all_session_cookies(&pre_login_response.headers);
if pre_login_session.is_empty() {
debug!("[Session] No pre-login session found");
return Ok(None);
}
let authenticator = Authenticator::new(30);
let session = authenticator.login(url, credentials).await?;
if !session.is_authenticated {
debug!("[Session] Login failed, can't test session fixation");
return Ok(None);
}
let post_login_session: HashSet<String> = session.cookies.values().cloned().collect();
let pre_login_set: HashSet<String> = pre_login_session.into_iter().collect();
let unchanged: Vec<_> = pre_login_set.intersection(&post_login_session).collect();
if !unchanged.is_empty() {
return Ok(Some(vec![Vulnerability {
id: format!("session-fixation-{}", uuid::Uuid::new_v4()),
vuln_type: "Session Fixation Vulnerability".to_string(),
severity: Severity::High,
confidence: Confidence::High,
category: "Session Management".to_string(),
url: url.to_string(),
parameter: Some("Session ID".to_string()),
payload: "N/A".to_string(),
description: "Session ID is not regenerated after successful authentication, allowing session fixation attacks".to_string(),
evidence: Some(format!("{} session values remained unchanged after login", unchanged.len())),
cwe: "CWE-384".to_string(),
cvss: 8.1,
verified: true,
false_positive: false,
remediation: "Regenerate session ID after successful authentication and privilege level changes. Invalidate the old session completely.".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
}]));
}
Ok(None)
}
async fn test_logout_invalidation(
&self,
url: &str,
session: &AuthSession,
) -> Result<Option<Vec<Vulnerability>>> {
info!("[Session] Testing logout invalidation");
let pre_logout = self.http_client.get_authenticated(url, session).await?;
if pre_logout.status_code == 401 || pre_logout.status_code == 403 {
debug!("[Session] Session not working, can't test logout");
return Ok(None);
}
let logout_urls = vec![
format!("{}/logout", url.trim_end_matches('/')),
format!("{}/signout", url.trim_end_matches('/')),
format!("{}/api/logout", url.trim_end_matches('/')),
format!("{}/api/auth/logout", url.trim_end_matches('/')),
format!("{}/auth/logout", url.trim_end_matches('/')),
];
let mut logged_out = false;
for logout_url in &logout_urls {
if self
.http_client
.get_authenticated(logout_url, session)
.await
.is_ok()
{
logged_out = true;
break;
}
if self
.http_client
.post_authenticated(logout_url, "", session)
.await
.is_ok()
{
logged_out = true;
break;
}
}
if !logged_out {
debug!("[Session] Couldn't find logout endpoint");
return Ok(None);
}
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let post_logout = self.http_client.get_authenticated(url, session).await?;
if post_logout.status_code != 401 && post_logout.status_code != 403 {
let body_lower = post_logout.body.to_lowercase();
if body_lower.contains("dashboard")
|| body_lower.contains("profile")
|| body_lower.contains("welcome")
|| body_lower.contains("\"authenticated\":true")
{
return Ok(Some(vec![Vulnerability {
id: format!("session-logout-{}", uuid::Uuid::new_v4()),
vuln_type: "Session Not Invalidated After Logout".to_string(),
severity: Severity::High,
confidence: Confidence::High,
category: "Session Management".to_string(),
url: url.to_string(),
parameter: Some("Session ID".to_string()),
payload: "N/A".to_string(),
description: "Session token remains valid after logout, allowing continued access".to_string(),
evidence: Some(format!("Session still accessible after logout (status: {})", post_logout.status_code)),
cwe: "CWE-613".to_string(),
cvss: 6.5,
verified: true,
false_positive: false,
remediation: "Invalidate session server-side on logout. Don't rely only on cookie deletion. Implement proper session termination.".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
}]));
}
}
Ok(None)
}
async fn test_concurrent_sessions(
&self,
url: &str,
credentials: &LoginCredentials,
) -> Result<Option<Vec<Vulnerability>>> {
info!("[Session] Testing concurrent sessions");
let authenticator = Authenticator::new(30);
let session1 = authenticator.login(url, credentials).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let session2 = authenticator.login(url, credentials).await?;
if !session1.is_authenticated || !session2.is_authenticated {
debug!("[Session] Couldn't create concurrent sessions");
return Ok(None);
}
let response1 = self.http_client.get_authenticated(url, &session1).await?;
let response2 = self.http_client.get_authenticated(url, &session2).await?;
if response1.status_code != 401 && response2.status_code != 401 {
debug!("[Session] Concurrent sessions allowed (may be by design)");
}
Ok(None)
}
async fn test_session_timeout_config(
&self,
url: &str,
session: &AuthSession,
) -> Result<Option<Vec<Vulnerability>>> {
info!("[Session] Analyzing session timeout configuration");
let mut vulnerabilities = Vec::new();
for (name, _) in &session.cookies {
let name_lower = name.to_lowercase();
if name_lower.contains("session")
|| name_lower.contains("auth")
|| name_lower.contains("token")
{
}
}
if let Some(jwt_str) = session.find_jwt() {
if let Some(jwt) = crate::scanners::jwt_analyzer::DecodedJwt::decode(&jwt_str) {
if let Some(exp) = jwt.payload.get("exp").and_then(|v| v.as_i64()) {
let now = chrono::Utc::now().timestamp();
let hours_until_exp = (exp - now) / 3600;
if hours_until_exp > 24 * 7 {
vulnerabilities.push(Vulnerability {
id: format!("session-longlived-{}", uuid::Uuid::new_v4()),
vuln_type: "Excessively Long Session Lifetime".to_string(),
severity: Severity::Medium,
confidence: Confidence::High,
category: "Session Management".to_string(),
url: url.to_string(),
parameter: Some("Session timeout".to_string()),
payload: format!("exp: {} hours ({} days)", hours_until_exp, hours_until_exp / 24),
description: format!("Session/token expires in {} hours ({} days)", hours_until_exp, hours_until_exp / 24),
evidence: Some(format!("Token expires in {} days", hours_until_exp / 24)),
cwe: "CWE-613".to_string(),
cvss: 4.3,
verified: true,
false_positive: false,
remediation: "Implement shorter session timeouts (4-8 hours for sensitive apps). Use sliding sessions with absolute maximums.".to_string(),
discovered_at: chrono::Utc::now().to_rfc3339(),
ml_confidence: None,
ml_data: None,
});
}
}
}
}
Ok(if vulnerabilities.is_empty() {
None
} else {
Some(vulnerabilities)
})
}
fn extract_session_id(cookie_header: &str) -> Option<String> {
let session_names = [
"session",
"sess",
"sid",
"phpsessid",
"jsessionid",
"aspsessionid",
"connect.sid",
];
for part in cookie_header.split(';') {
let trimmed = part.trim();
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].to_lowercase();
let value = trimmed[eq_pos + 1..].to_string();
for session_name in &session_names {
if name.contains(session_name) {
return Some(value);
}
}
}
}
None
}
fn extract_all_session_cookies(
headers: &std::collections::HashMap<String, String>,
) -> Vec<String> {
let mut sessions = Vec::new();
for (key, value) in headers {
if key.to_lowercase() == "set-cookie" {
if let Some(session) = Self::extract_session_id(value) {
sessions.push(session);
}
}
}
sessions
}
fn analyze_entropy(session_ids: &[String]) -> EntropyAnalysis {
let unique: HashSet<_> = session_ids.iter().collect();
let avg_length =
session_ids.iter().map(|s| s.len()).sum::<usize>() / session_ids.len().max(1);
let all_chars: HashSet<char> = session_ids.iter().flat_map(|s| s.chars()).collect();
let charset_size = all_chars.len();
let estimated_entropy = (avg_length as f64) * (charset_size as f64).log2();
let has_sequential_pattern = Self::detect_sequential_pattern(session_ids);
EntropyAnalysis {
unique_count: unique.len(),
avg_length,
charset_size,
estimated_entropy,
has_sequential_pattern,
}
}
fn detect_sequential_pattern(session_ids: &[String]) -> bool {
if session_ids.len() < 3 {
return false;
}
let numbers: Vec<Option<i64>> = session_ids
.iter()
.map(|s| {
let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
})
.collect();
let valid_numbers: Vec<i64> = numbers.into_iter().flatten().collect();
if valid_numbers.len() >= 3 {
let mut increments = true;
for window in valid_numbers.windows(2) {
if window[1] <= window[0] || window[1] - window[0] > 100 {
increments = false;
break;
}
}
return increments;
}
false
}
}
struct EntropyAnalysis {
unique_count: usize,
avg_length: usize,
charset_size: usize,
estimated_entropy: f64,
has_sequential_pattern: bool,
}
mod uuid {
use rand::Rng;
pub struct Uuid;
impl Uuid {
pub fn new_v4() -> String {
let mut rng = rand::rng();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
rng.random::<u32>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u64>() & 0xffffffffffff
)
}
}
}