feat: AI-driven automated penetration testing (#12)
Some checks failed
CI / Clippy (push) Failing after 1m51s
CI / Security Audit (push) Successful in 2m1s
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Format (push) Failing after 42s
CI / Deploy MCP (push) Has been skipped
Some checks failed
CI / Clippy (push) Failing after 1m51s
CI / Security Audit (push) Successful in 2m1s
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Format (push) Failing after 42s
CI / Deploy MCP (push) Has been skipped
This commit was merged in pull request #12.
This commit is contained in:
401
compliance-dast/src/tools/cookie_analyzer.rs
Normal file
401
compliance-dast/src/tools/cookie_analyzer.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
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,
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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.into_iter())
|
||||
.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(),
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user