use compliance_core::error::CoreError; use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType}; use compliance_core::models::Severity; use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult}; use serde_json::json; use tracing::info; /// Tool that inspects cookies set by a target for security issues. pub struct CookieAnalyzerTool { http: reqwest::Client, } /// Parsed attributes from a Set-Cookie header. #[derive(Debug)] struct ParsedCookie { name: String, #[allow(dead_code)] value: String, secure: bool, http_only: bool, same_site: Option, domain: Option, path: Option, raw: String, } impl CookieAnalyzerTool { pub fn new(http: reqwest::Client) -> Self { Self { http } } /// Parse a Set-Cookie header value into a structured representation. fn parse_set_cookie(header: &str) -> ParsedCookie { let raw = header.to_string(); let parts: Vec<&str> = header.split(';').collect(); let (name, value) = if let Some(kv) = parts.first() { let mut kv_split = kv.splitn(2, '='); let k = kv_split.next().unwrap_or("").trim().to_string(); let v = kv_split.next().unwrap_or("").trim().to_string(); (k, v) } else { (String::new(), String::new()) }; let mut secure = false; let mut http_only = false; let mut same_site = None; let mut domain = None; let mut path = None; for part in parts.iter().skip(1) { let trimmed = part.trim().to_lowercase(); if trimmed == "secure" { secure = true; } else if trimmed == "httponly" { http_only = true; } else if let Some(ss) = trimmed.strip_prefix("samesite=") { same_site = Some(ss.trim().to_string()); } else if let Some(d) = trimmed.strip_prefix("domain=") { domain = Some(d.trim().to_string()); } else if let Some(p) = trimmed.strip_prefix("path=") { path = Some(p.trim().to_string()); } } ParsedCookie { name, value, secure, http_only, same_site, domain, path, raw, } } /// Heuristic: does this cookie name suggest it's a session / auth cookie? fn is_sensitive_cookie(name: &str) -> bool { let lower = name.to_lowercase(); lower.contains("session") || lower.contains("sess") || lower.contains("token") || lower.contains("auth") || lower.contains("jwt") || lower.contains("csrf") || lower.contains("sid") || lower == "connect.sid" || lower == "phpsessid" || lower == "jsessionid" || lower == "asp.net_sessionid" } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_simple_cookie() { let cookie = CookieAnalyzerTool::parse_set_cookie("session_id=abc123"); assert_eq!(cookie.name, "session_id"); assert_eq!(cookie.value, "abc123"); assert!(!cookie.secure); assert!(!cookie.http_only); assert!(cookie.same_site.is_none()); assert!(cookie.domain.is_none()); assert!(cookie.path.is_none()); } #[test] fn parse_cookie_with_all_attributes() { let raw = "token=xyz; Secure; HttpOnly; SameSite=Strict; Domain=.example.com; Path=/api"; let cookie = CookieAnalyzerTool::parse_set_cookie(raw); assert_eq!(cookie.name, "token"); assert_eq!(cookie.value, "xyz"); assert!(cookie.secure); assert!(cookie.http_only); assert_eq!(cookie.same_site.as_deref(), Some("strict")); assert_eq!(cookie.domain.as_deref(), Some(".example.com")); assert_eq!(cookie.path.as_deref(), Some("/api")); assert_eq!(cookie.raw, raw); } #[test] fn parse_cookie_samesite_none() { let cookie = CookieAnalyzerTool::parse_set_cookie("id=1; SameSite=None; Secure"); assert_eq!(cookie.same_site.as_deref(), Some("none")); assert!(cookie.secure); } #[test] fn parse_cookie_with_equals_in_value() { let cookie = CookieAnalyzerTool::parse_set_cookie("data=a=b=c; HttpOnly"); assert_eq!(cookie.name, "data"); assert_eq!(cookie.value, "a=b=c"); assert!(cookie.http_only); } #[test] fn is_sensitive_cookie_known_names() { assert!(CookieAnalyzerTool::is_sensitive_cookie("session_id")); assert!(CookieAnalyzerTool::is_sensitive_cookie("PHPSESSID")); assert!(CookieAnalyzerTool::is_sensitive_cookie("JSESSIONID")); assert!(CookieAnalyzerTool::is_sensitive_cookie("connect.sid")); assert!(CookieAnalyzerTool::is_sensitive_cookie("asp.net_sessionid")); assert!(CookieAnalyzerTool::is_sensitive_cookie("auth_token")); assert!(CookieAnalyzerTool::is_sensitive_cookie("jwt_access")); assert!(CookieAnalyzerTool::is_sensitive_cookie("csrf_token")); assert!(CookieAnalyzerTool::is_sensitive_cookie("my_sess_cookie")); assert!(CookieAnalyzerTool::is_sensitive_cookie("SID")); } #[test] fn is_sensitive_cookie_non_sensitive() { assert!(!CookieAnalyzerTool::is_sensitive_cookie("theme")); assert!(!CookieAnalyzerTool::is_sensitive_cookie("language")); assert!(!CookieAnalyzerTool::is_sensitive_cookie("_ga")); assert!(!CookieAnalyzerTool::is_sensitive_cookie("tracking")); } #[test] fn parse_empty_cookie_header() { let cookie = CookieAnalyzerTool::parse_set_cookie(""); assert_eq!(cookie.name, ""); assert_eq!(cookie.value, ""); } } impl PentestTool for CookieAnalyzerTool { fn name(&self) -> &str { "cookie_analyzer" } fn description(&self) -> &str { "Analyzes cookies set by a target URL. Checks for Secure, HttpOnly, SameSite attributes \ and overly broad Domain/Path settings. Focuses on session and authentication cookies." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "url": { "type": "string", "description": "URL to fetch and analyze cookies from" }, "login_url": { "type": "string", "description": "Optional login URL to also check (may set auth cookies)" } }, "required": ["url"] }) } fn execute<'a>( &'a self, input: serde_json::Value, context: &'a PentestToolContext, ) -> std::pin::Pin< Box> + Send + 'a>, > { Box::pin(async move { let url = input .get("url") .and_then(|v| v.as_str()) .ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?; let login_url = input.get("login_url").and_then(|v| v.as_str()); let target_id = context .target .id .map(|oid| oid.to_hex()) .unwrap_or_else(|| "unknown".to_string()); let mut findings = Vec::new(); let mut cookie_data = Vec::new(); // Collect Set-Cookie headers from the main URL and optional login URL let urls_to_check: Vec<&str> = std::iter::once(url).chain(login_url).collect(); for check_url in &urls_to_check { // Use a client that does NOT follow redirects so we catch cookies on redirect responses let no_redirect_client = reqwest::Client::builder() .danger_accept_invalid_certs(true) .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| CoreError::Dast(format!("Client build error: {e}")))?; let response = match no_redirect_client.get(*check_url).send().await { Ok(r) => r, Err(_e) => { // Try with the main client that follows redirects match self.http.get(*check_url).send().await { Ok(r) => r, Err(_) => continue, } } }; let status = response.status().as_u16(); let set_cookie_headers: Vec = response .headers() .get_all("set-cookie") .iter() .filter_map(|v| v.to_str().ok().map(String::from)) .collect(); for raw_cookie in &set_cookie_headers { let cookie = Self::parse_set_cookie(raw_cookie); let is_sensitive = Self::is_sensitive_cookie(&cookie.name); let is_https = check_url.starts_with("https://"); let cookie_info = json!({ "name": cookie.name, "secure": cookie.secure, "http_only": cookie.http_only, "same_site": cookie.same_site, "domain": cookie.domain, "path": cookie.path, "is_sensitive": is_sensitive, "url": check_url, }); cookie_data.push(cookie_info); // Check: missing Secure flag if !cookie.secure && (is_https || is_sensitive) { let severity = if is_sensitive { Severity::High } else { Severity::Medium }; let evidence = DastEvidence { request_method: "GET".to_string(), request_url: check_url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(cookie.raw.clone()), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CookieSecurity, format!("Cookie '{}' missing Secure flag", cookie.name), format!( "The cookie '{}' does not have the Secure attribute set. \ Without this flag, the cookie can be transmitted over unencrypted HTTP connections.", cookie.name ), severity, check_url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-614".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Add the 'Secure' attribute to the Set-Cookie header to ensure the \ cookie is only sent over HTTPS connections." .to_string(), ); findings.push(finding); } // Check: missing HttpOnly flag on sensitive cookies if !cookie.http_only && is_sensitive { let evidence = DastEvidence { request_method: "GET".to_string(), request_url: check_url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(cookie.raw.clone()), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CookieSecurity, format!("Cookie '{}' missing HttpOnly flag", cookie.name), format!( "The session/auth cookie '{}' does not have the HttpOnly attribute. \ This makes it accessible to JavaScript, increasing the impact of XSS attacks.", cookie.name ), Severity::High, check_url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-1004".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Add the 'HttpOnly' attribute to the Set-Cookie header to prevent \ JavaScript access to the cookie." .to_string(), ); findings.push(finding); } // Check: missing or weak SameSite if is_sensitive { let weak_same_site = match &cookie.same_site { None => true, Some(ss) => ss == "none", }; if weak_same_site { let evidence = DastEvidence { request_method: "GET".to_string(), request_url: check_url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(cookie.raw.clone()), screenshot_path: None, payload: None, response_time_ms: None, }; let desc = if cookie.same_site.is_none() { format!( "The session/auth cookie '{}' does not have a SameSite attribute. \ This may allow cross-site request forgery (CSRF) attacks.", cookie.name ) } else { format!( "The session/auth cookie '{}' has SameSite=None, which allows it \ to be sent in cross-site requests, enabling CSRF attacks.", cookie.name ) }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CookieSecurity, format!("Cookie '{}' missing or weak SameSite", cookie.name), desc, Severity::Medium, check_url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-1275".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Set 'SameSite=Strict' or 'SameSite=Lax' on session/auth cookies \ to prevent cross-site request inclusion." .to_string(), ); findings.push(finding); } } // Check: overly broad domain if let Some(ref domain) = cookie.domain { // A domain starting with a dot applies to all subdomains let dot_domain = domain.starts_with('.'); // Count domain parts - if only 2 parts (e.g., .example.com), it's broad let parts: Vec<&str> = domain.trim_start_matches('.').split('.').collect(); if dot_domain && parts.len() <= 2 && is_sensitive { let evidence = DastEvidence { request_method: "GET".to_string(), request_url: check_url.to_string(), request_headers: None, request_body: None, response_status: status, response_headers: None, response_snippet: Some(cookie.raw.clone()), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::CookieSecurity, format!("Cookie '{}' has overly broad domain", cookie.name), format!( "The cookie '{}' is scoped to domain '{}' which includes all \ subdomains. If any subdomain is compromised, the attacker can \ access this cookie.", cookie.name, domain ), Severity::Low, check_url.to_string(), "GET".to_string(), ); finding.cwe = Some("CWE-1004".to_string()); finding.evidence = vec![evidence]; finding.remediation = Some( "Restrict the cookie domain to the specific subdomain that needs it \ rather than the entire parent domain." .to_string(), ); findings.push(finding); } } } } let count = findings.len(); info!(url, findings = count, "Cookie analysis complete"); Ok(PentestToolResult { summary: if count > 0 { format!("Found {count} cookie security issues.") } else if cookie_data.is_empty() { "No cookies were set by the target.".to_string() } else { "All cookies have proper security attributes.".to_string() }, findings, data: json!({ "cookies": cookie_data, "total_cookies": cookie_data.len(), }), }) }) } }