All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
478 lines
20 KiB
Rust
478 lines
20 KiB
Rust
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<String>,
|
|
domain: Option<String>,
|
|
path: Option<String>,
|
|
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<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + 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<String> = 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(),
|
|
}),
|
|
})
|
|
})
|
|
}
|
|
}
|