refactor: modularize codebase and add 404 unit tests (#13)
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

This commit was merged in pull request #13.
This commit is contained in:
2026-03-13 08:03:45 +00:00
parent acc5b86aa4
commit 3bb690e5bb
89 changed files with 11884 additions and 6046 deletions

View File

@@ -14,6 +14,7 @@ pub struct CookieAnalyzerTool {
#[derive(Debug)]
struct ParsedCookie {
name: String,
#[allow(dead_code)]
value: String,
secure: bool,
http_only: bool,
@@ -92,6 +93,81 @@ impl CookieAnalyzerTool {
}
}
#[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"
@@ -123,96 +199,96 @@ impl PentestTool for CookieAnalyzerTool {
&'a self,
input: serde_json::Value,
context: &'a PentestToolContext,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>> {
) -> 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 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 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 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();
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();
// 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}")))?;
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 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();
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://");
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);
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
};
// 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 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(
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CookieSecurity,
@@ -226,32 +302,32 @@ impl PentestTool for CookieAnalyzerTool {
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 \
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);
}
.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,
};
// 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(
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CookieSecurity,
@@ -265,137 +341,137 @@ impl PentestTool for CookieAnalyzerTool {
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 \
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);
}
.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,
// Check: missing or weak SameSite
if is_sensitive {
let weak_same_site = match &cookie.same_site {
None => true,
Some(ss) => ss == "none",
};
let desc = if cookie.same_site.is_none() {
format!(
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!(
} 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 \
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);
.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,
};
// 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 \
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(
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);
findings.push(finding);
}
}
}
}
}
let count = findings.len();
info!(url, findings = count, "Cookie analysis complete");
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(),
}),
})
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(),
}),
})
})
}
}