feat: AI-driven automated penetration testing system

Add a complete AI pentest system where Claude autonomously drives security
testing via tool-calling. The LLM selects from 16 tools, chains results,
and builds an attack chain DAG.

Core:
- PentestTool trait (dyn-compatible) with PentestToolContext/Result
- PentestSession, AttackChainNode, PentestMessage, PentestEvent models
- 10 new DastVulnType variants (DNS, DMARC, TLS, cookies, CSP, CORS, etc.)
- LLM client chat_with_tools() for OpenAI-compatible tool calling

Tools (16 total):
- 5 agent wrappers: SQL injection, XSS, auth bypass, SSRF, API fuzzer
- 11 new infra tools: DNS checker, DMARC checker, TLS analyzer,
  security headers, cookie analyzer, CSP analyzer, rate limit tester,
  console log detector, CORS checker, OpenAPI parser, recon
- ToolRegistry for tool lookup and LLM definition generation

Orchestrator:
- PentestOrchestrator with iterative tool-calling loop (max 50 rounds)
- Attack chain node recording per tool invocation
- SSE event broadcasting for real-time progress
- Strategy-aware system prompts (quick/comprehensive/targeted/aggressive/stealth)

API (9 endpoints):
- POST/GET /pentest/sessions, GET /pentest/sessions/:id
- POST /pentest/sessions/:id/chat, GET /pentest/sessions/:id/stream
- GET /pentest/sessions/:id/attack-chain, messages, findings
- GET /pentest/stats

Dashboard:
- Pentest dashboard with stat cards, severity distribution, session list
- Chat-based session page with split layout (chat + findings/attack chain)
- Inline tool execution indicators, auto-polling, new session modal
- Sidebar navigation item

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-11 19:23:21 +01:00
parent 76260acc76
commit 71d8741e10
40 changed files with 7546 additions and 90 deletions

View File

@@ -2,5 +2,7 @@ pub mod agents;
pub mod crawler;
pub mod orchestrator;
pub mod recon;
pub mod tools;
pub use orchestrator::DastOrchestrator;
pub use tools::ToolRegistry;

View File

@@ -0,0 +1,146 @@
use compliance_core::error::CoreError;
use compliance_core::traits::dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use crate::agents::api_fuzzer::ApiFuzzerAgent;
/// PentestTool wrapper around the existing ApiFuzzerAgent.
pub struct ApiFuzzerTool {
http: reqwest::Client,
agent: ApiFuzzerAgent,
}
impl ApiFuzzerTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = ApiFuzzerAgent::new(http.clone());
Self { http, agent }
}
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
let mut endpoints = Vec::new();
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
for ep in arr {
let url = ep.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let method = ep.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let mut parameters = Vec::new();
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
for p in params {
parameters.push(EndpointParameter {
name: p.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
location: p.get("location").and_then(|v| v.as_str()).unwrap_or("query").to_string(),
param_type: p.get("param_type").and_then(|v| v.as_str()).map(String::from),
example_value: p.get("example_value").and_then(|v| v.as_str()).map(String::from),
});
}
}
endpoints.push(DiscoveredEndpoint {
url,
method,
parameters,
content_type: ep.get("content_type").and_then(|v| v.as_str()).map(String::from),
requires_auth: ep.get("requires_auth").and_then(|v| v.as_bool()).unwrap_or(false),
});
}
}
endpoints
}
}
impl PentestTool for ApiFuzzerTool {
fn name(&self) -> &str {
"api_fuzzer"
}
fn description(&self) -> &str {
"Fuzzes API endpoints to discover misconfigurations, information disclosure, and hidden \
endpoints. Probes common sensitive paths and tests for verbose error messages."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"description": "Known endpoints to fuzz",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"parameters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"location": { "type": "string" },
"param_type": { "type": "string" },
"example_value": { "type": "string" }
},
"required": ["name"]
}
}
},
"required": ["url"]
}
},
"base_url": {
"type": "string",
"description": "Base URL to probe for common sensitive paths (used if no endpoints provided)"
}
}
})
}
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 mut endpoints = Self::parse_endpoints(&input);
// If a base_url is provided but no endpoints, create a default endpoint
if endpoints.is_empty() {
if let Some(base) = input.get("base_url").and_then(|v| v.as_str()) {
endpoints.push(DiscoveredEndpoint {
url: base.to_string(),
method: "GET".to_string(),
parameters: Vec::new(),
content_type: None,
requires_auth: false,
});
}
}
if endpoints.is_empty() {
return Ok(PentestToolResult {
summary: "No endpoints or base_url provided to fuzz.".to_string(),
findings: Vec::new(),
data: json!({}),
});
}
let dast_context = DastContext {
endpoints,
technologies: Vec::new(),
sast_hints: Vec::new(),
};
let findings = self.agent.run(&context.target, &dast_context).await?;
let count = findings.len();
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} API misconfigurations or information disclosures.")
} else {
"No API misconfigurations detected.".to_string()
},
findings,
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
})
})
}
}

View File

@@ -0,0 +1,130 @@
use compliance_core::error::CoreError;
use compliance_core::traits::dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use crate::agents::auth_bypass::AuthBypassAgent;
/// PentestTool wrapper around the existing AuthBypassAgent.
pub struct AuthBypassTool {
http: reqwest::Client,
agent: AuthBypassAgent,
}
impl AuthBypassTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = AuthBypassAgent::new(http.clone());
Self { http, agent }
}
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
let mut endpoints = Vec::new();
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
for ep in arr {
let url = ep.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let method = ep.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let mut parameters = Vec::new();
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
for p in params {
parameters.push(EndpointParameter {
name: p.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
location: p.get("location").and_then(|v| v.as_str()).unwrap_or("query").to_string(),
param_type: p.get("param_type").and_then(|v| v.as_str()).map(String::from),
example_value: p.get("example_value").and_then(|v| v.as_str()).map(String::from),
});
}
}
endpoints.push(DiscoveredEndpoint {
url,
method,
parameters,
content_type: ep.get("content_type").and_then(|v| v.as_str()).map(String::from),
requires_auth: ep.get("requires_auth").and_then(|v| v.as_bool()).unwrap_or(false),
});
}
}
endpoints
}
}
impl PentestTool for AuthBypassTool {
fn name(&self) -> &str {
"auth_bypass_scanner"
}
fn description(&self) -> &str {
"Tests endpoints for authentication bypass vulnerabilities. Tries accessing protected \
endpoints without credentials, with manipulated tokens, and with common default credentials."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"description": "Endpoints to test for authentication bypass",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"parameters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"location": { "type": "string" },
"param_type": { "type": "string" },
"example_value": { "type": "string" }
},
"required": ["name"]
}
},
"requires_auth": { "type": "boolean", "description": "Whether this endpoint requires authentication" }
},
"required": ["url", "method"]
}
}
},
"required": ["endpoints"]
})
}
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 endpoints = Self::parse_endpoints(&input);
if endpoints.is_empty() {
return Ok(PentestToolResult {
summary: "No endpoints provided to test.".to_string(),
findings: Vec::new(),
data: json!({}),
});
}
let dast_context = DastContext {
endpoints,
technologies: Vec::new(),
sast_hints: Vec::new(),
};
let findings = self.agent.run(&context.target, &dast_context).await?;
let count = findings.len();
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} authentication bypass vulnerabilities.")
} else {
"No authentication bypass vulnerabilities detected.".to_string()
},
findings,
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
})
})
}
}

View File

@@ -0,0 +1,326 @@
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 detects console.log and similar debug statements in frontend JavaScript.
pub struct ConsoleLogDetectorTool {
http: reqwest::Client,
}
/// A detected console statement with its context.
#[derive(Debug)]
struct ConsoleMatch {
pattern: String,
file_url: String,
line_snippet: String,
line_number: Option<usize>,
}
impl ConsoleLogDetectorTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Patterns that indicate debug/logging statements left in production code.
fn patterns() -> Vec<&'static str> {
vec![
"console.log(",
"console.debug(",
"console.error(",
"console.warn(",
"console.info(",
"console.trace(",
"console.dir(",
"console.table(",
"debugger;",
"alert(",
]
}
/// Extract JavaScript file URLs from an HTML page body.
fn extract_js_urls(html: &str, base_url: &str) -> Vec<String> {
let mut urls = Vec::new();
let base = url::Url::parse(base_url).ok();
// Simple regex-free extraction of <script src="...">
let mut search_from = 0;
while let Some(start) = html[search_from..].find("src=") {
let abs_start = search_from + start + 4;
if abs_start >= html.len() {
break;
}
let quote = html.as_bytes().get(abs_start).copied();
let (open, close) = match quote {
Some(b'"') => ('"', '"'),
Some(b'\'') => ('\'', '\''),
_ => {
search_from = abs_start + 1;
continue;
}
};
let val_start = abs_start + 1;
if let Some(end) = html[val_start..].find(close) {
let src = &html[val_start..val_start + end];
if src.ends_with(".js") || src.contains(".js?") || src.contains("/js/") {
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
src.to_string()
} else if src.starts_with("//") {
format!("https:{src}")
} else if let Some(ref base) = base {
base.join(src).map(|u| u.to_string()).unwrap_or_default()
} else {
format!("{base_url}/{}", src.trim_start_matches('/'))
};
if !full_url.is_empty() {
urls.push(full_url);
}
}
search_from = val_start + end + 1;
} else {
break;
}
}
urls
}
/// Search a JS file's contents for console/debug patterns.
fn scan_js_content(content: &str, file_url: &str) -> Vec<ConsoleMatch> {
let mut matches = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Skip comments (basic heuristic)
if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
continue;
}
for pattern in Self::patterns() {
if line.contains(pattern) {
let snippet = if line.len() > 200 {
format!("{}...", &line[..200])
} else {
line.to_string()
};
matches.push(ConsoleMatch {
pattern: pattern.trim_end_matches('(').to_string(),
file_url: file_url.to_string(),
line_snippet: snippet.trim().to_string(),
line_number: Some(line_num + 1),
});
break; // One match per line is enough
}
}
}
matches
}
}
impl PentestTool for ConsoleLogDetectorTool {
fn name(&self) -> &str {
"console_log_detector"
}
fn description(&self) -> &str {
"Detects console.log, console.debug, console.error, debugger, and similar debug \
statements left in production JavaScript. Fetches the HTML page and referenced JS files."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL of the page to check for console.log leakage"
},
"additional_js_urls": {
"type": "array",
"description": "Optional additional JavaScript file URLs to scan",
"items": { "type": "string" }
}
},
"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 additional_js: Vec<String> = input
.get("additional_js_urls")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
// Fetch the main page
let response = self
.http
.get(url)
.send()
.await
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
let html = response.text().await.unwrap_or_default();
// Scan inline scripts in the HTML
let mut all_matches = Vec::new();
let inline_matches = Self::scan_js_content(&html, url);
all_matches.extend(inline_matches);
// Extract JS file URLs from the HTML
let mut js_urls = Self::extract_js_urls(&html, url);
js_urls.extend(additional_js);
js_urls.dedup();
// Fetch and scan each JS file
for js_url in &js_urls {
match self.http.get(js_url).send().await {
Ok(resp) => {
if resp.status().is_success() {
let js_content = resp.text().await.unwrap_or_default();
// Only scan non-minified-looking files or files where we can still
// find patterns (minifiers typically strip console calls, but not always)
let file_matches = Self::scan_js_content(&js_content, js_url);
all_matches.extend(file_matches);
}
}
Err(_) => continue,
}
}
let mut findings = Vec::new();
let match_data: Vec<serde_json::Value> = all_matches
.iter()
.map(|m| {
json!({
"pattern": m.pattern,
"file": m.file_url,
"line": m.line_number,
"snippet": m.line_snippet,
})
})
.collect();
if !all_matches.is_empty() {
// Group by file for the finding
let mut by_file: std::collections::HashMap<&str, Vec<&ConsoleMatch>> =
std::collections::HashMap::new();
for m in &all_matches {
by_file.entry(&m.file_url).or_default().push(m);
}
for (file_url, matches) in &by_file {
let pattern_summary: Vec<String> = matches
.iter()
.take(5)
.map(|m| {
format!(
" Line {}: {} - {}",
m.line_number.unwrap_or(0),
m.pattern,
if m.line_snippet.len() > 80 {
format!("{}...", &m.line_snippet[..80])
} else {
m.line_snippet.clone()
}
)
})
.collect();
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: file_url.to_string(),
request_headers: None,
request_body: None,
response_status: 200,
response_headers: None,
response_snippet: Some(pattern_summary.join("\n")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let total = matches.len();
let extra = if total > 5 {
format!(" (and {} more)", total - 5)
} else {
String::new()
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::ConsoleLogLeakage,
format!("Console/debug statements in {}", file_url),
format!(
"Found {total} console/debug statements in {file_url}{extra}. \
These can leak sensitive information such as API responses, user data, \
or internal state to anyone with browser developer tools open."
),
Severity::Low,
file_url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-532".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Remove console.log/debug/error statements from production code. \
Use a build step (e.g., babel plugin, terser) to strip console calls \
during the production build."
.to_string(),
);
findings.push(finding);
}
}
let total_matches = all_matches.len();
let count = findings.len();
info!(url, js_files = js_urls.len(), total_matches, "Console log detection complete");
Ok(PentestToolResult {
summary: if total_matches > 0 {
format!(
"Found {total_matches} console/debug statements across {} files.",
count
)
} else {
format!(
"No console/debug statements found in HTML or {} JS files.",
js_urls.len()
)
},
findings,
data: json!({
"total_matches": total_matches,
"js_files_scanned": js_urls.len(),
"matches": match_data,
}),
})
})
}
}

View 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(),
}),
})
})
}
}

View File

@@ -0,0 +1,410 @@
use std::collections::HashMap;
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, warn};
/// Tool that checks CORS configuration for security issues.
pub struct CorsCheckerTool {
http: reqwest::Client,
}
impl CorsCheckerTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Origins to test against the target.
fn test_origins(target_host: &str) -> Vec<(&'static str, String)> {
vec![
("null_origin", "null".to_string()),
("evil_domain", "https://evil.com".to_string()),
(
"subdomain_spoof",
format!("https://{target_host}.evil.com"),
),
(
"prefix_spoof",
format!("https://evil-{target_host}"),
),
("http_downgrade", format!("http://{target_host}")),
]
}
}
impl PentestTool for CorsCheckerTool {
fn name(&self) -> &str {
"cors_checker"
}
fn description(&self) -> &str {
"Checks CORS configuration by sending requests with various Origin headers. Tests for \
wildcard origins, reflected origins, null origin acceptance, and dangerous \
Access-Control-Allow-Credentials combinations."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to test CORS configuration on"
},
"additional_origins": {
"type": "array",
"description": "Optional additional origin values to test",
"items": { "type": "string" }
}
},
"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 additional_origins: Vec<String> = input
.get("additional_origins")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let target_host = url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(String::from))
.unwrap_or_else(|| url.to_string());
let mut findings = Vec::new();
let mut cors_data: Vec<serde_json::Value> = Vec::new();
// First, send a request without Origin to get baseline
let baseline = self
.http
.get(url)
.send()
.await
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
let baseline_acao = baseline
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.map(String::from);
cors_data.push(json!({
"origin": null,
"acao": baseline_acao,
}));
// Check for wildcard + credentials (dangerous combo)
if let Some(ref acao) = baseline_acao {
if acao == "*" {
let acac = baseline
.headers()
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if acac.to_lowercase() == "true" {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: baseline.status().as_u16(),
response_headers: None,
response_snippet: Some(format!(
"Access-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true"
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CorsMisconfiguration,
"CORS wildcard with credentials".to_string(),
format!(
"The endpoint {url} returns Access-Control-Allow-Origin: * with \
Access-Control-Allow-Credentials: true. While browsers should block this \
combination, it indicates a serious CORS misconfiguration."
),
Severity::High,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-942".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Never combine Access-Control-Allow-Origin: * with \
Access-Control-Allow-Credentials: true. Specify explicit allowed origins."
.to_string(),
);
findings.push(finding);
}
}
}
// Test with various Origin headers
let mut test_origins = Self::test_origins(&target_host);
for origin in &additional_origins {
test_origins.push(("custom", origin.clone()));
}
for (test_name, origin) in &test_origins {
let resp = match self
.http
.get(url)
.header("Origin", origin.as_str())
.send()
.await
{
Ok(r) => r,
Err(_) => continue,
};
let status = resp.status().as_u16();
let acao = resp
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.map(String::from);
let acac = resp
.headers()
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok())
.map(String::from);
let acam = resp
.headers()
.get("access-control-allow-methods")
.and_then(|v| v.to_str().ok())
.map(String::from);
cors_data.push(json!({
"test": test_name,
"origin": origin,
"acao": acao,
"acac": acac,
"acam": acam,
"status": status,
}));
// Check if the origin was reflected back
if let Some(ref acao_val) = acao {
let origin_reflected = acao_val == origin;
let credentials_allowed = acac
.as_ref()
.map(|v| v.to_lowercase() == "true")
.unwrap_or(false);
if origin_reflected && *test_name != "http_downgrade" {
let severity = if credentials_allowed {
Severity::Critical
} else {
Severity::High
};
let resp_headers: HashMap<String, String> = resp
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: Some(
[("Origin".to_string(), origin.clone())]
.into_iter()
.collect(),
),
request_body: None,
response_status: status,
response_headers: Some(resp_headers),
response_snippet: Some(format!(
"Origin: {origin}\nAccess-Control-Allow-Origin: {acao_val}\n\
Access-Control-Allow-Credentials: {}",
acac.as_deref().unwrap_or("not set")
)),
screenshot_path: None,
payload: Some(origin.clone()),
response_time_ms: None,
};
let title = match *test_name {
"null_origin" => {
"CORS accepts null origin".to_string()
}
"evil_domain" => {
"CORS reflects arbitrary origin".to_string()
}
"subdomain_spoof" => {
"CORS vulnerable to subdomain spoofing".to_string()
}
"prefix_spoof" => {
"CORS vulnerable to prefix spoofing".to_string()
}
_ => format!("CORS reflects untrusted origin ({test_name})"),
};
let cred_note = if credentials_allowed {
" Combined with Access-Control-Allow-Credentials: true, this allows \
the attacker to steal authenticated data."
} else {
""
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CorsMisconfiguration,
title,
format!(
"The endpoint {url} reflects the Origin header '{origin}' back in \
Access-Control-Allow-Origin, allowing cross-origin requests from \
untrusted domains.{cred_note}"
),
severity,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-942".to_string());
finding.exploitable = credentials_allowed;
finding.evidence = vec![evidence];
finding.remediation = Some(
"Validate the Origin header against a whitelist of trusted origins. \
Do not reflect the Origin header value directly. Use specific allowed \
origins instead of wildcards."
.to_string(),
);
findings.push(finding);
warn!(
url,
test_name,
origin,
credentials = credentials_allowed,
"CORS misconfiguration detected"
);
}
// Special case: HTTP downgrade
if *test_name == "http_downgrade" && origin_reflected && credentials_allowed {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: Some(
[("Origin".to_string(), origin.clone())]
.into_iter()
.collect(),
),
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(format!(
"HTTP origin accepted: {origin} -> ACAO: {acao_val}"
)),
screenshot_path: None,
payload: Some(origin.clone()),
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CorsMisconfiguration,
"CORS allows HTTP origin with credentials".to_string(),
format!(
"The HTTPS endpoint {url} accepts the HTTP origin {origin} with \
credentials. An attacker performing a man-in-the-middle attack on \
the HTTP version could steal authenticated data."
),
Severity::High,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-942".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Do not accept HTTP origins for HTTPS endpoints. Ensure CORS \
origin validation enforces the https:// scheme."
.to_string(),
);
findings.push(finding);
}
}
}
// Also send a preflight OPTIONS request
if let Ok(resp) = self
.http
.request(reqwest::Method::OPTIONS, url)
.header("Origin", "https://evil.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Authorization, Content-Type")
.send()
.await
{
let acam = resp
.headers()
.get("access-control-allow-methods")
.and_then(|v| v.to_str().ok())
.map(String::from);
let acah = resp
.headers()
.get("access-control-allow-headers")
.and_then(|v| v.to_str().ok())
.map(String::from);
cors_data.push(json!({
"test": "preflight",
"status": resp.status().as_u16(),
"allow_methods": acam,
"allow_headers": acah,
}));
}
let count = findings.len();
info!(url, findings = count, "CORS check complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} CORS misconfiguration issues for {url}.")
} else {
format!("CORS configuration appears secure for {url}.")
},
findings,
data: json!({
"tests": cors_data,
}),
})
})
}
}

View File

@@ -0,0 +1,447 @@
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 analyzes Content-Security-Policy headers.
pub struct CspAnalyzerTool {
http: reqwest::Client,
}
/// A parsed CSP directive.
#[derive(Debug)]
struct CspDirective {
name: String,
values: Vec<String>,
}
impl CspAnalyzerTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Parse a CSP header string into directives.
fn parse_csp(csp: &str) -> Vec<CspDirective> {
let mut directives = Vec::new();
for part in csp.split(';') {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
if let Some((name, values)) = tokens.split_first() {
directives.push(CspDirective {
name: name.to_lowercase(),
values: values.iter().map(|v| v.to_string()).collect(),
});
}
}
directives
}
/// Check a CSP for common issues and return findings.
fn analyze_directives(
directives: &[CspDirective],
url: &str,
target_id: &str,
status: u16,
csp_raw: &str,
) -> Vec<DastFinding> {
let mut findings = Vec::new();
let make_evidence = |snippet: String| DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(snippet),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
// Check for unsafe-inline in script-src
for d in directives {
if (d.name == "script-src" || d.name == "default-src")
&& d.values.iter().any(|v| v == "'unsafe-inline'")
{
let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" ")));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP allows 'unsafe-inline' in {}", d.name),
format!(
"The Content-Security-Policy directive '{}' includes 'unsafe-inline', \
which defeats the purpose of CSP by allowing inline scripts that \
could be exploited via XSS.",
d.name
),
Severity::High,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-79".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Remove 'unsafe-inline' from script-src. Use nonces or hashes for \
legitimate inline scripts instead."
.to_string(),
);
findings.push(finding);
}
// Check for unsafe-eval
if (d.name == "script-src" || d.name == "default-src")
&& d.values.iter().any(|v| v == "'unsafe-eval'")
{
let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" ")));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP allows 'unsafe-eval' in {}", d.name),
format!(
"The Content-Security-Policy directive '{}' includes 'unsafe-eval', \
which allows the use of eval() and similar dynamic code execution \
that can be exploited via XSS.",
d.name
),
Severity::Medium,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-79".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Remove 'unsafe-eval' from script-src. Refactor code to avoid eval(), \
Function(), and similar constructs."
.to_string(),
);
findings.push(finding);
}
// Check for wildcard sources
if d.values.iter().any(|v| v == "*") {
let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" ")));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP wildcard source in {}", d.name),
format!(
"The Content-Security-Policy directive '{}' uses a wildcard '*' source, \
which allows loading resources from any origin and largely negates CSP protection.",
d.name
),
Severity::Medium,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-16".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(format!(
"Replace the wildcard '*' in {} with specific allowed origins.",
d.name
));
findings.push(finding);
}
// Check for http: sources (non-HTTPS)
if d.values.iter().any(|v| v == "http:") {
let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" ")));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP allows HTTP sources in {}", d.name),
format!(
"The Content-Security-Policy directive '{}' allows loading resources \
over unencrypted HTTP, which can be exploited via man-in-the-middle attacks.",
d.name
),
Severity::Medium,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(format!(
"Replace 'http:' with 'https:' in {} to enforce encrypted resource loading.",
d.name
));
findings.push(finding);
}
// Check for data: in script-src (can be used to bypass CSP)
if (d.name == "script-src" || d.name == "default-src")
&& d.values.iter().any(|v| v == "data:")
{
let evidence = make_evidence(format!("{}: {}", d.name, d.values.join(" ")));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP allows data: URIs in {}", d.name),
format!(
"The Content-Security-Policy directive '{}' allows 'data:' URIs, \
which can be used to bypass CSP and execute arbitrary scripts.",
d.name
),
Severity::High,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-79".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(format!(
"Remove 'data:' from {}. If data URIs are needed, restrict them to \
non-executable content types only (e.g., img-src).",
d.name
));
findings.push(finding);
}
}
// Check for missing important directives
let directive_names: Vec<&str> = directives.iter().map(|d| d.name.as_str()).collect();
let has_default_src = directive_names.contains(&"default-src");
let important_directives = [
("script-src", "Controls which scripts can execute"),
("object-src", "Controls plugins like Flash"),
("base-uri", "Controls the base URL for relative URLs"),
("form-action", "Controls where forms can submit to"),
("frame-ancestors", "Controls who can embed this page in iframes"),
];
for (dir_name, desc) in &important_directives {
if !directive_names.contains(dir_name)
&& !(has_default_src && *dir_name != "frame-ancestors" && *dir_name != "base-uri" && *dir_name != "form-action")
{
let evidence = make_evidence(format!("CSP missing directive: {dir_name}"));
let mut finding = DastFinding::new(
String::new(),
target_id.to_string(),
DastVulnType::CspIssue,
format!("CSP missing '{}' directive", dir_name),
format!(
"The Content-Security-Policy is missing the '{}' directive. {}. \
Without this directive{}, the browser may fall back to less restrictive defaults.",
dir_name,
desc,
if has_default_src && (*dir_name == "frame-ancestors" || *dir_name == "base-uri" || *dir_name == "form-action") {
" (not covered by default-src)"
} else {
""
}
),
Severity::Low,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-16".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(format!(
"Add '{}: 'none'' or an appropriate restrictive value to your CSP.",
dir_name
));
findings.push(finding);
}
}
findings
}
}
impl PentestTool for CspAnalyzerTool {
fn name(&self) -> &str {
"csp_analyzer"
}
fn description(&self) -> &str {
"Analyzes Content-Security-Policy headers. Checks for unsafe-inline, unsafe-eval, \
wildcard sources, data: URIs in script-src, missing directives, and other CSP weaknesses."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to fetch and analyze CSP from"
}
},
"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 target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let response = self
.http
.get(url)
.send()
.await
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
let status = response.status().as_u16();
// Check for CSP header
let csp_header = response
.headers()
.get("content-security-policy")
.and_then(|v| v.to_str().ok())
.map(String::from);
// Also check for report-only variant
let csp_report_only = response
.headers()
.get("content-security-policy-report-only")
.and_then(|v| v.to_str().ok())
.map(String::from);
let mut findings = Vec::new();
let mut csp_data = json!({});
match &csp_header {
Some(csp) => {
let directives = Self::parse_csp(csp);
let directive_map: serde_json::Value = directives
.iter()
.map(|d| (d.name.clone(), json!(d.values)))
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
csp_data["csp_header"] = json!(csp);
csp_data["directives"] = directive_map;
findings.extend(Self::analyze_directives(
&directives,
url,
&target_id,
status,
csp,
));
}
None => {
csp_data["csp_header"] = json!(null);
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some("Content-Security-Policy header is missing".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CspIssue,
"Missing Content-Security-Policy header".to_string(),
format!(
"No Content-Security-Policy header is present on {url}. \
Without CSP, the browser has no instructions on which sources are \
trusted, making XSS exploitation much easier."
),
Severity::Medium,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-16".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Add a Content-Security-Policy header. Start with a restrictive policy like \
\"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
object-src 'none'; frame-ancestors 'none'; base-uri 'self'\"."
.to_string(),
);
findings.push(finding);
}
}
if let Some(ref report_only) = csp_report_only {
csp_data["csp_report_only"] = json!(report_only);
// If ONLY report-only exists (no enforcing CSP), warn
if csp_header.is_none() {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: None,
response_snippet: Some(format!(
"Content-Security-Policy-Report-Only: {}",
report_only
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::CspIssue,
"CSP is report-only, not enforcing".to_string(),
"A Content-Security-Policy-Report-Only header is present but no enforcing \
Content-Security-Policy header exists. Report-only mode only logs violations \
but does not block them."
.to_string(),
Severity::Low,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-16".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Once you have verified the CSP policy works correctly in report-only mode, \
deploy it as an enforcing Content-Security-Policy header."
.to_string(),
);
findings.push(finding);
}
}
let count = findings.len();
info!(url, findings = count, "CSP analysis complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} CSP issues for {url}.")
} else {
format!("Content-Security-Policy looks good for {url}.")
},
findings,
data: csp_data,
})
})
}
}

View 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, warn};
/// Tool that checks email security configuration (DMARC and SPF records).
pub struct DmarcCheckerTool;
impl DmarcCheckerTool {
pub fn new() -> Self {
Self
}
/// Query TXT records for a given name using `dig`.
async fn query_txt(name: &str) -> Result<Vec<String>, CoreError> {
let output = tokio::process::Command::new("dig")
.args(["+short", "TXT", name])
.output()
.await
.map_err(|e| CoreError::Dast(format!("dig command failed: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<String> = stdout
.lines()
.map(|l| l.trim().trim_matches('"').to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(lines)
}
/// Parse a DMARC record string and return the policy value.
fn parse_dmarc_policy(record: &str) -> Option<String> {
for part in record.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix("p=") {
return Some(val.trim().to_lowercase());
}
}
None
}
/// Parse DMARC record for sub-domain policy (sp=).
fn parse_dmarc_subdomain_policy(record: &str) -> Option<String> {
for part in record.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix("sp=") {
return Some(val.trim().to_lowercase());
}
}
None
}
/// Parse DMARC record for reporting URI (rua=).
fn parse_dmarc_rua(record: &str) -> Option<String> {
for part in record.split(';') {
let part = part.trim();
if let Some(val) = part.strip_prefix("rua=") {
return Some(val.trim().to_string());
}
}
None
}
/// Check if an SPF record is present and parse the policy.
fn is_spf_record(record: &str) -> bool {
record.starts_with("v=spf1")
}
/// Evaluate SPF record strength.
fn spf_uses_soft_fail(record: &str) -> bool {
record.contains("~all")
}
fn spf_allows_all(record: &str) -> bool {
record.contains("+all")
}
}
impl PentestTool for DmarcCheckerTool {
fn name(&self) -> &str {
"dmarc_checker"
}
fn description(&self) -> &str {
"Checks email security configuration for a domain. Queries DMARC and SPF records and \
evaluates policy strength. Reports missing or weak email authentication settings."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "The domain to check (e.g., 'example.com')"
}
},
"required": ["domain"]
})
}
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 domain = input
.get("domain")
.and_then(|v| v.as_str())
.ok_or_else(|| CoreError::Dast("Missing required 'domain' parameter".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 email_data = json!({});
// ---- DMARC check ----
let dmarc_domain = format!("_dmarc.{domain}");
let dmarc_records = Self::query_txt(&dmarc_domain).await.unwrap_or_default();
let dmarc_record = dmarc_records.iter().find(|r| r.starts_with("v=DMARC1"));
match dmarc_record {
Some(record) => {
email_data["dmarc_record"] = json!(record);
let policy = Self::parse_dmarc_policy(record);
let sp = Self::parse_dmarc_subdomain_policy(record);
let rua = Self::parse_dmarc_rua(record);
email_data["dmarc_policy"] = json!(policy);
email_data["dmarc_subdomain_policy"] = json!(sp);
email_data["dmarc_rua"] = json!(rua);
// Warn on weak policy
if let Some(ref p) = policy {
if p == "none" {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: dmarc_domain.clone(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(record.clone()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("Weak DMARC policy for {domain}"),
format!(
"The DMARC policy for {domain} is set to 'none', which only monitors \
but does not enforce email authentication. Attackers can spoof emails \
from this domain."
),
Severity::Medium,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-290".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Upgrade the DMARC policy from 'p=none' to 'p=quarantine' or \
'p=reject' after verifying legitimate email flows are properly \
authenticated."
.to_string(),
);
findings.push(finding);
warn!(domain, "DMARC policy is 'none'");
}
}
// Warn if no reporting URI
if rua.is_none() {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: dmarc_domain.clone(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(record.clone()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("DMARC without reporting for {domain}"),
format!(
"The DMARC record for {domain} does not include a reporting URI (rua=). \
Without reporting, you will not receive aggregate feedback about email \
authentication failures."
),
Severity::Info,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-778".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Add a 'rua=' tag to your DMARC record to receive aggregate reports. \
Example: 'rua=mailto:dmarc-reports@example.com'."
.to_string(),
);
findings.push(finding);
}
}
None => {
email_data["dmarc_record"] = json!(null);
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: dmarc_domain.clone(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("No DMARC record found".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("Missing DMARC record for {domain}"),
format!(
"No DMARC record was found for {domain}. Without DMARC, there is no \
policy to prevent email spoofing and phishing attacks using this domain."
),
Severity::High,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-290".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Create a DMARC TXT record at _dmarc.<domain>. Start with 'v=DMARC1; p=none; \
rua=mailto:dmarc@example.com' and gradually move to 'p=reject'."
.to_string(),
);
findings.push(finding);
warn!(domain, "No DMARC record found");
}
}
// ---- SPF check ----
let txt_records = Self::query_txt(domain).await.unwrap_or_default();
let spf_record = txt_records.iter().find(|r| Self::is_spf_record(r));
match spf_record {
Some(record) => {
email_data["spf_record"] = json!(record);
if Self::spf_allows_all(record) {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(record.clone()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("SPF allows all senders for {domain}"),
format!(
"The SPF record for {domain} uses '+all' which allows any server to \
send email on behalf of this domain, completely negating SPF protection."
),
Severity::Critical,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-290".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Change '+all' to '-all' (hard fail) or '~all' (soft fail) in your SPF record. \
Only list authorized mail servers."
.to_string(),
);
findings.push(finding);
} else if Self::spf_uses_soft_fail(record) {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(record.clone()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("SPF soft fail for {domain}"),
format!(
"The SPF record for {domain} uses '~all' (soft fail) instead of \
'-all' (hard fail). Soft fail marks unauthorized emails as suspicious \
but does not reject them."
),
Severity::Low,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-290".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Consider changing '~all' to '-all' in your SPF record once you have \
confirmed all legitimate mail sources are listed."
.to_string(),
);
findings.push(finding);
}
}
None => {
email_data["spf_record"] = json!(null);
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("No SPF record found".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::EmailSecurity,
format!("Missing SPF record for {domain}"),
format!(
"No SPF record was found for {domain}. Without SPF, any server can claim \
to send email on behalf of this domain."
),
Severity::High,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-290".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Create an SPF TXT record for your domain. Example: \
'v=spf1 include:_spf.google.com -all'."
.to_string(),
);
findings.push(finding);
warn!(domain, "No SPF record found");
}
}
let count = findings.len();
info!(domain, findings = count, "DMARC/SPF check complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} email security issues for {domain}.")
} else {
format!("Email security configuration looks good for {domain}.")
},
findings,
data: email_data,
})
})
}
}

View File

@@ -0,0 +1,389 @@
use std::collections::HashMap;
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 tokio::net::lookup_host;
use tracing::{info, warn};
/// Tool that checks DNS configuration for security issues.
///
/// Resolves A, AAAA, MX, TXT, CNAME, NS records using the system resolver
/// via `tokio::net::lookup_host` and `std::net::ToSocketAddrs`. For TXT-based
/// records (SPF, DMARC, CAA, DNSSEC) it uses a simple TXT query via the
/// `tokio::process::Command` wrapper around `dig` where available.
pub struct DnsCheckerTool;
impl DnsCheckerTool {
pub fn new() -> Self {
Self
}
/// Run a `dig` query and return the answer lines.
async fn dig_query(domain: &str, record_type: &str) -> Result<Vec<String>, CoreError> {
let output = tokio::process::Command::new("dig")
.args(["+short", record_type, domain])
.output()
.await
.map_err(|e| CoreError::Dast(format!("dig command failed: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<String> = stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(lines)
}
/// Resolve A/AAAA records using tokio lookup.
async fn resolve_addresses(domain: &str) -> Result<(Vec<String>, Vec<String>), CoreError> {
let mut ipv4 = Vec::new();
let mut ipv6 = Vec::new();
let addr_str = format!("{domain}:443");
match lookup_host(&addr_str).await {
Ok(addrs) => {
for addr in addrs {
match addr {
std::net::SocketAddr::V4(v4) => ipv4.push(v4.ip().to_string()),
std::net::SocketAddr::V6(v6) => ipv6.push(v6.ip().to_string()),
}
}
}
Err(e) => {
return Err(CoreError::Dast(format!("DNS resolution failed for {domain}: {e}")));
}
}
Ok((ipv4, ipv6))
}
}
impl PentestTool for DnsCheckerTool {
fn name(&self) -> &str {
"dns_checker"
}
fn description(&self) -> &str {
"Checks DNS configuration for a domain. Resolves A, AAAA, MX, TXT, CNAME, NS records. \
Checks for DNSSEC, CAA records, and potential subdomain takeover via dangling CNAME/NS."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "The domain to check (e.g., 'example.com')"
},
"subdomains": {
"type": "array",
"description": "Optional list of subdomains to also check (e.g., ['www', 'api', 'mail'])",
"items": { "type": "string" }
}
},
"required": ["domain"]
})
}
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 domain = input
.get("domain")
.and_then(|v| v.as_str())
.ok_or_else(|| CoreError::Dast("Missing required 'domain' parameter".to_string()))?;
let subdomains: Vec<String> = input
.get("subdomains")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let mut findings = Vec::new();
let mut dns_data: HashMap<String, serde_json::Value> = HashMap::new();
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
// --- A / AAAA records ---
match Self::resolve_addresses(domain).await {
Ok((ipv4, ipv6)) => {
dns_data.insert("a_records".to_string(), json!(ipv4));
dns_data.insert("aaaa_records".to_string(), json!(ipv6));
}
Err(e) => {
dns_data.insert("a_records_error".to_string(), json!(e.to_string()));
}
}
// --- MX records ---
match Self::dig_query(domain, "MX").await {
Ok(mx) => {
dns_data.insert("mx_records".to_string(), json!(mx));
}
Err(e) => {
dns_data.insert("mx_records_error".to_string(), json!(e.to_string()));
}
}
// --- NS records ---
let ns_records = match Self::dig_query(domain, "NS").await {
Ok(ns) => {
dns_data.insert("ns_records".to_string(), json!(ns));
ns
}
Err(e) => {
dns_data.insert("ns_records_error".to_string(), json!(e.to_string()));
Vec::new()
}
};
// --- TXT records ---
match Self::dig_query(domain, "TXT").await {
Ok(txt) => {
dns_data.insert("txt_records".to_string(), json!(txt));
}
Err(e) => {
dns_data.insert("txt_records_error".to_string(), json!(e.to_string()));
}
}
// --- CNAME records (for subdomains) ---
let mut cname_data: HashMap<String, Vec<String>> = HashMap::new();
let mut domains_to_check = vec![domain.to_string()];
for sub in &subdomains {
domains_to_check.push(format!("{sub}.{domain}"));
}
for fqdn in &domains_to_check {
match Self::dig_query(fqdn, "CNAME").await {
Ok(cnames) if !cnames.is_empty() => {
// Check for dangling CNAME
for cname in &cnames {
let cname_clean = cname.trim_end_matches('.');
let check_addr = format!("{cname_clean}:443");
let is_dangling = lookup_host(&check_addr).await.is_err();
if is_dangling {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: fqdn.clone(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!(
"CNAME {fqdn} -> {cname} (target does not resolve)"
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::DnsMisconfiguration,
format!("Dangling CNAME on {fqdn}"),
format!(
"The subdomain {fqdn} has a CNAME record pointing to {cname} which does not resolve. \
This may allow subdomain takeover if an attacker can claim the target hostname."
),
Severity::High,
fqdn.clone(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-923".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Remove dangling CNAME records or ensure the target hostname is \
properly configured and resolvable."
.to_string(),
);
findings.push(finding);
warn!(fqdn, cname, "Dangling CNAME detected - potential subdomain takeover");
}
}
cname_data.insert(fqdn.clone(), cnames);
}
_ => {}
}
}
if !cname_data.is_empty() {
dns_data.insert("cname_records".to_string(), json!(cname_data));
}
// --- CAA records ---
match Self::dig_query(domain, "CAA").await {
Ok(caa) => {
if caa.is_empty() {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("No CAA records found".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::DnsMisconfiguration,
format!("Missing CAA records for {domain}"),
format!(
"No CAA (Certificate Authority Authorization) records are set for {domain}. \
Without CAA records, any certificate authority can issue certificates for this domain."
),
Severity::Low,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Add CAA DNS records to restrict which certificate authorities can issue \
certificates for your domain. Example: '0 issue \"letsencrypt.org\"'."
.to_string(),
);
findings.push(finding);
}
dns_data.insert("caa_records".to_string(), json!(caa));
}
Err(e) => {
dns_data.insert("caa_records_error".to_string(), json!(e.to_string()));
}
}
// --- DNSSEC check ---
let dnssec_output = tokio::process::Command::new("dig")
.args(["+dnssec", "+short", "DNSKEY", domain])
.output()
.await;
match dnssec_output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let has_dnssec = !stdout.trim().is_empty();
dns_data.insert("dnssec_enabled".to_string(), json!(has_dnssec));
if !has_dnssec {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("No DNSKEY records found - DNSSEC not enabled".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::DnsMisconfiguration,
format!("DNSSEC not enabled for {domain}"),
format!(
"DNSSEC is not enabled for {domain}. Without DNSSEC, DNS responses \
can be spoofed, allowing man-in-the-middle attacks."
),
Severity::Medium,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-350".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Enable DNSSEC for your domain by configuring DNSKEY and DS records \
with your DNS provider and domain registrar."
.to_string(),
);
findings.push(finding);
}
}
Err(_) => {
dns_data.insert("dnssec_check_error".to_string(), json!("dig not available"));
}
}
// --- Check NS records for dangling ---
for ns in &ns_records {
let ns_clean = ns.trim_end_matches('.');
let check_addr = format!("{ns_clean}:53");
if lookup_host(&check_addr).await.is_err() {
let evidence = DastEvidence {
request_method: "DNS".to_string(),
request_url: domain.to_string(),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!(
"NS record {ns} does not resolve"
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::DnsMisconfiguration,
format!("Dangling NS record for {domain}"),
format!(
"The NS record {ns} for {domain} does not resolve. \
This could allow domain takeover if an attacker can claim the nameserver hostname."
),
Severity::Critical,
domain.to_string(),
"DNS".to_string(),
);
finding.cwe = Some("CWE-923".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Remove dangling NS records or ensure the nameserver hostname is properly \
configured. Dangling NS records can lead to full domain takeover."
.to_string(),
);
findings.push(finding);
warn!(domain, ns, "Dangling NS record detected - potential domain takeover");
}
}
let count = findings.len();
info!(domain, findings = count, "DNS check complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} DNS configuration issues for {domain}.")
} else {
format!("No DNS configuration issues found for {domain}.")
},
findings,
data: json!(dns_data),
})
})
}
}

View File

@@ -0,0 +1,141 @@
pub mod api_fuzzer;
pub mod auth_bypass;
pub mod console_log_detector;
pub mod cookie_analyzer;
pub mod cors_checker;
pub mod csp_analyzer;
pub mod dmarc_checker;
pub mod dns_checker;
pub mod openapi_parser;
pub mod rate_limit_tester;
pub mod recon;
pub mod security_headers;
pub mod sql_injection;
pub mod ssrf;
pub mod tls_analyzer;
pub mod xss;
use std::collections::HashMap;
use compliance_core::traits::pentest_tool::PentestTool;
/// A definition describing a tool for LLM tool_use registration.
#[derive(Debug, Clone)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
/// Registry that holds all available pentest tools and provides
/// look-up by name.
pub struct ToolRegistry {
tools: HashMap<String, Box<dyn PentestTool>>,
}
impl ToolRegistry {
/// Create a new registry with all built-in tools pre-registered.
pub fn new() -> Self {
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.expect("failed to build HTTP client");
let mut tools: HashMap<String, Box<dyn PentestTool>> = HashMap::new();
// Agent-wrapping tools
let register = |tools: &mut HashMap<String, Box<dyn PentestTool>>,
tool: Box<dyn PentestTool>| {
tools.insert(tool.name().to_string(), tool);
};
register(
&mut tools,
Box::new(sql_injection::SqlInjectionTool::new(http.clone())),
);
register(&mut tools, Box::new(xss::XssTool::new(http.clone())));
register(
&mut tools,
Box::new(auth_bypass::AuthBypassTool::new(http.clone())),
);
register(&mut tools, Box::new(ssrf::SsrfTool::new(http.clone())));
register(
&mut tools,
Box::new(api_fuzzer::ApiFuzzerTool::new(http.clone())),
);
// New infrastructure / analysis tools
register(
&mut tools,
Box::new(dns_checker::DnsCheckerTool::new()),
);
register(
&mut tools,
Box::new(dmarc_checker::DmarcCheckerTool::new()),
);
register(
&mut tools,
Box::new(tls_analyzer::TlsAnalyzerTool::new(http.clone())),
);
register(
&mut tools,
Box::new(security_headers::SecurityHeadersTool::new(http.clone())),
);
register(
&mut tools,
Box::new(cookie_analyzer::CookieAnalyzerTool::new(http.clone())),
);
register(
&mut tools,
Box::new(csp_analyzer::CspAnalyzerTool::new(http.clone())),
);
register(
&mut tools,
Box::new(rate_limit_tester::RateLimitTesterTool::new(http.clone())),
);
register(
&mut tools,
Box::new(console_log_detector::ConsoleLogDetectorTool::new(
http.clone(),
)),
);
register(
&mut tools,
Box::new(cors_checker::CorsCheckerTool::new(http.clone())),
);
register(
&mut tools,
Box::new(openapi_parser::OpenApiParserTool::new(http.clone())),
);
register(
&mut tools,
Box::new(recon::ReconTool::new(http)),
);
Self { tools }
}
/// Look up a tool by name.
pub fn get(&self, name: &str) -> Option<&dyn PentestTool> {
self.tools.get(name).map(|b| b.as_ref())
}
/// Return definitions for every registered tool.
pub fn all_definitions(&self) -> Vec<ToolDefinition> {
self.tools
.values()
.map(|t| ToolDefinition {
name: t.name().to_string(),
description: t.description().to_string(),
input_schema: t.input_schema(),
})
.collect()
}
/// Return the names of all registered tools.
pub fn list_names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
}

View File

@@ -0,0 +1,422 @@
use compliance_core::error::CoreError;
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use tracing::info;
/// Tool that discovers and parses OpenAPI/Swagger specification files.
///
/// Returns structured endpoint definitions for the LLM to use when planning
/// further tests. This tool produces data rather than security findings.
pub struct OpenApiParserTool {
http: reqwest::Client,
}
/// A parsed endpoint from an OpenAPI spec.
#[derive(Debug, Clone)]
struct ParsedEndpoint {
path: String,
method: String,
operation_id: Option<String>,
summary: Option<String>,
parameters: Vec<ParsedParameter>,
request_body_content_type: Option<String>,
response_codes: Vec<String>,
security: Vec<String>,
tags: Vec<String>,
}
/// A parsed parameter from an OpenAPI spec.
#[derive(Debug, Clone)]
struct ParsedParameter {
name: String,
location: String,
required: bool,
param_type: Option<String>,
description: Option<String>,
}
impl OpenApiParserTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Common paths where OpenAPI/Swagger specs are typically served.
fn common_spec_paths() -> Vec<&'static str> {
vec![
"/openapi.json",
"/openapi.yaml",
"/swagger.json",
"/swagger.yaml",
"/api-docs",
"/api-docs.json",
"/v2/api-docs",
"/v3/api-docs",
"/docs/openapi.json",
"/api/swagger.json",
"/api/openapi.json",
"/api/v1/openapi.json",
"/api/v2/openapi.json",
"/.well-known/openapi.json",
]
}
/// Try to fetch a spec from a URL and return the JSON value if successful.
async fn try_fetch_spec(
http: &reqwest::Client,
url: &str,
) -> Option<(String, serde_json::Value)> {
let resp = http.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = resp.text().await.ok()?;
// Try JSON first
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
// Verify it looks like an OpenAPI / Swagger spec
if val.get("openapi").is_some()
|| val.get("swagger").is_some()
|| val.get("paths").is_some()
{
return Some((url.to_string(), val));
}
}
// If content type suggests YAML, we can't easily parse without a YAML dep,
// so just report the URL as found
if content_type.contains("yaml") || body.starts_with("openapi:") || body.starts_with("swagger:") {
// Return a minimal JSON indicating YAML was found
return Some((
url.to_string(),
json!({
"_note": "YAML spec detected but not parsed. Fetch and convert to JSON.",
"_raw_url": url,
}),
));
}
None
}
/// Parse an OpenAPI 3.x or Swagger 2.x spec into structured endpoints.
fn parse_spec(spec: &serde_json::Value, base_url: &str) -> Vec<ParsedEndpoint> {
let mut endpoints = Vec::new();
// Determine base path
let base_path = if let Some(servers) = spec.get("servers").and_then(|v| v.as_array()) {
servers
.first()
.and_then(|s| s.get("url"))
.and_then(|u| u.as_str())
.unwrap_or("")
.to_string()
} else if let Some(bp) = spec.get("basePath").and_then(|v| v.as_str()) {
bp.to_string()
} else {
String::new()
};
let paths = match spec.get("paths").and_then(|v| v.as_object()) {
Some(p) => p,
None => return endpoints,
};
for (path, path_item) in paths {
let path_obj = match path_item.as_object() {
Some(o) => o,
None => continue,
};
// Path-level parameters
let path_params = path_obj
.get("parameters")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for method in &["get", "post", "put", "patch", "delete", "head", "options"] {
let operation = match path_obj.get(*method).and_then(|v| v.as_object()) {
Some(o) => o,
None => continue,
};
let operation_id = operation
.get("operationId")
.and_then(|v| v.as_str())
.map(String::from);
let summary = operation
.get("summary")
.and_then(|v| v.as_str())
.map(String::from);
let tags: Vec<String> = operation
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
// Merge path-level and operation-level parameters
let mut parameters = Vec::new();
let op_params = operation
.get("parameters")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for param_val in path_params.iter().chain(op_params.iter()) {
let name = param_val
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let location = param_val
.get("in")
.and_then(|v| v.as_str())
.unwrap_or("query")
.to_string();
let required = param_val
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Type from schema or direct type field
let param_type = param_val
.get("schema")
.and_then(|s| s.get("type"))
.or_else(|| param_val.get("type"))
.and_then(|v| v.as_str())
.map(String::from);
let description = param_val
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
parameters.push(ParsedParameter {
name,
location,
required,
param_type,
description,
});
}
// Request body (OpenAPI 3.x)
let request_body_content_type = operation
.get("requestBody")
.and_then(|rb| rb.get("content"))
.and_then(|c| c.as_object())
.and_then(|obj| obj.keys().next().cloned());
// Response codes
let response_codes: Vec<String> = operation
.get("responses")
.and_then(|r| r.as_object())
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
// Security requirements
let security: Vec<String> = operation
.get("security")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_object())
.flat_map(|obj| obj.keys().cloned())
.collect()
})
.unwrap_or_default();
endpoints.push(ParsedEndpoint {
path: format!("{}{}", base_path, path),
method: method.to_uppercase(),
operation_id,
summary,
parameters,
request_body_content_type,
response_codes,
security,
tags,
});
}
}
endpoints
}
}
impl PentestTool for OpenApiParserTool {
fn name(&self) -> &str {
"openapi_parser"
}
fn description(&self) -> &str {
"Discovers and parses OpenAPI/Swagger specifications. Tries common spec paths and \
returns structured endpoint definitions including parameters, methods, and security \
requirements. Use this to discover all API endpoints before testing."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"base_url": {
"type": "string",
"description": "Base URL of the API to discover specs from"
},
"spec_url": {
"type": "string",
"description": "Optional explicit URL of the OpenAPI/Swagger spec file"
}
},
"required": ["base_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 base_url = input
.get("base_url")
.and_then(|v| v.as_str())
.ok_or_else(|| CoreError::Dast("Missing required 'base_url' parameter".to_string()))?;
let explicit_spec_url = input.get("spec_url").and_then(|v| v.as_str());
let base_url_trimmed = base_url.trim_end_matches('/');
// If an explicit spec URL is provided, try it first
let mut spec_result: Option<(String, serde_json::Value)> = None;
if let Some(spec_url) = explicit_spec_url {
spec_result = Self::try_fetch_spec(&self.http, spec_url).await;
}
// If no explicit URL or it failed, try common paths
if spec_result.is_none() {
for path in Self::common_spec_paths() {
let url = format!("{base_url_trimmed}{path}");
if let Some(result) = Self::try_fetch_spec(&self.http, &url).await {
spec_result = Some(result);
break;
}
}
}
match spec_result {
Some((spec_url, spec)) => {
let spec_version = spec
.get("openapi")
.or_else(|| spec.get("swagger"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let api_title = spec
.get("info")
.and_then(|i| i.get("title"))
.and_then(|t| t.as_str())
.unwrap_or("Unknown API");
let api_version = spec
.get("info")
.and_then(|i| i.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let endpoints = Self::parse_spec(&spec, base_url_trimmed);
let endpoint_data: Vec<serde_json::Value> = endpoints
.iter()
.map(|ep| {
let params: Vec<serde_json::Value> = ep
.parameters
.iter()
.map(|p| {
json!({
"name": p.name,
"in": p.location,
"required": p.required,
"type": p.param_type,
"description": p.description,
})
})
.collect();
json!({
"path": ep.path,
"method": ep.method,
"operation_id": ep.operation_id,
"summary": ep.summary,
"parameters": params,
"request_body_content_type": ep.request_body_content_type,
"response_codes": ep.response_codes,
"security": ep.security,
"tags": ep.tags,
})
})
.collect();
let endpoint_count = endpoints.len();
info!(
spec_url = %spec_url,
spec_version,
api_title,
endpoints = endpoint_count,
"OpenAPI spec parsed"
);
Ok(PentestToolResult {
summary: format!(
"Found OpenAPI spec ({spec_version}) at {spec_url}. \
API: {api_title} v{api_version}. \
Parsed {endpoint_count} endpoints."
),
findings: Vec::new(), // This tool produces data, not findings
data: json!({
"spec_url": spec_url,
"spec_version": spec_version,
"api_title": api_title,
"api_version": api_version,
"endpoint_count": endpoint_count,
"endpoints": endpoint_data,
"security_schemes": spec.get("components")
.and_then(|c| c.get("securitySchemes"))
.or_else(|| spec.get("securityDefinitions")),
}),
})
}
None => {
info!(base_url, "No OpenAPI spec found");
Ok(PentestToolResult {
summary: format!(
"No OpenAPI/Swagger specification found for {base_url}. \
Tried {} common paths.",
Self::common_spec_paths().len()
),
findings: Vec::new(),
data: json!({
"spec_found": false,
"paths_tried": Self::common_spec_paths(),
}),
})
}
}
})
}
}

View File

@@ -0,0 +1,285 @@
use std::time::Instant;
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, warn};
/// Tool that tests whether a target enforces rate limiting.
pub struct RateLimitTesterTool {
http: reqwest::Client,
}
impl RateLimitTesterTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
impl PentestTool for RateLimitTesterTool {
fn name(&self) -> &str {
"rate_limit_tester"
}
fn description(&self) -> &str {
"Tests whether an endpoint enforces rate limiting by sending rapid sequential requests. \
Checks for 429 responses and measures response time degradation."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL of the endpoint to test for rate limiting"
},
"method": {
"type": "string",
"description": "HTTP method to use",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
"default": "GET"
},
"request_count": {
"type": "integer",
"description": "Number of rapid requests to send (default: 50)",
"default": 50,
"minimum": 10,
"maximum": 200
},
"body": {
"type": "string",
"description": "Optional request body (for POST/PUT/PATCH)"
}
},
"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 method = input
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET");
let request_count = input
.get("request_count")
.and_then(|v| v.as_u64())
.unwrap_or(50)
.min(200) as usize;
let body = input.get("body").and_then(|v| v.as_str());
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
// Respect the context rate limit if set
let max_requests = if context.rate_limit > 0 {
request_count.min(context.rate_limit as usize * 5)
} else {
request_count
};
let mut status_codes: Vec<u16> = Vec::with_capacity(max_requests);
let mut response_times: Vec<u128> = Vec::with_capacity(max_requests);
let mut got_429 = false;
let mut rate_limit_at_request: Option<usize> = None;
for i in 0..max_requests {
let start = Instant::now();
let request = match method {
"POST" => {
let mut req = self.http.post(url);
if let Some(b) = body {
req = req.body(b.to_string());
}
req
}
"PUT" => {
let mut req = self.http.put(url);
if let Some(b) = body {
req = req.body(b.to_string());
}
req
}
"PATCH" => {
let mut req = self.http.patch(url);
if let Some(b) = body {
req = req.body(b.to_string());
}
req
}
"DELETE" => self.http.delete(url),
_ => self.http.get(url),
};
match request.send().await {
Ok(resp) => {
let elapsed = start.elapsed().as_millis();
let status = resp.status().as_u16();
status_codes.push(status);
response_times.push(elapsed);
if status == 429 && !got_429 {
got_429 = true;
rate_limit_at_request = Some(i + 1);
info!(url, request_num = i + 1, "Rate limit triggered (429)");
}
// Check for rate limit headers even on 200
if !got_429 {
let headers = resp.headers();
let has_rate_headers = headers.contains_key("x-ratelimit-limit")
|| headers.contains_key("x-ratelimit-remaining")
|| headers.contains_key("ratelimit-limit")
|| headers.contains_key("ratelimit-remaining")
|| headers.contains_key("retry-after");
if has_rate_headers && rate_limit_at_request.is_none() {
// Server has rate limit headers but hasn't blocked yet
}
}
}
Err(e) => {
let elapsed = start.elapsed().as_millis();
status_codes.push(0);
response_times.push(elapsed);
}
}
}
let mut findings = Vec::new();
let total_sent = status_codes.len();
let count_429 = status_codes.iter().filter(|&&s| s == 429).count();
let count_success = status_codes.iter().filter(|&&s| (200..300).contains(&s)).count();
// Calculate response time statistics
let avg_time = if !response_times.is_empty() {
response_times.iter().sum::<u128>() / response_times.len() as u128
} else {
0
};
let first_half_avg = if response_times.len() >= 4 {
let half = response_times.len() / 2;
response_times[..half].iter().sum::<u128>() / half as u128
} else {
avg_time
};
let second_half_avg = if response_times.len() >= 4 {
let half = response_times.len() / 2;
response_times[half..].iter().sum::<u128>() / (response_times.len() - half) as u128
} else {
avg_time
};
// Significant time degradation suggests possible (weak) rate limiting
let time_degradation = if first_half_avg > 0 {
(second_half_avg as f64 / first_half_avg as f64) - 1.0
} else {
0.0
};
let rate_data = json!({
"total_requests_sent": total_sent,
"status_429_count": count_429,
"success_count": count_success,
"rate_limit_at_request": rate_limit_at_request,
"avg_response_time_ms": avg_time,
"first_half_avg_ms": first_half_avg,
"second_half_avg_ms": second_half_avg,
"time_degradation_pct": (time_degradation * 100.0).round(),
});
if !got_429 && count_success == total_sent {
// No rate limiting detected at all
let evidence = DastEvidence {
request_method: method.to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: body.map(String::from),
response_status: 200,
response_headers: None,
response_snippet: Some(format!(
"Sent {total_sent} rapid requests. All returned success (2xx). \
No 429 responses received. Avg response time: {avg_time}ms."
)),
screenshot_path: None,
payload: None,
response_time_ms: Some(avg_time as u64),
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::RateLimitAbsent,
format!("No rate limiting on {} {}", method, url),
format!(
"The endpoint {} {} does not enforce rate limiting. \
{total_sent} rapid requests were all accepted with no 429 responses \
or noticeable degradation. This makes the endpoint vulnerable to \
brute force attacks and abuse.",
method, url
),
Severity::Medium,
url.to_string(),
method.to_string(),
);
finding.cwe = Some("CWE-770".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Implement rate limiting on this endpoint. Use token bucket or sliding window \
algorithms. Return 429 Too Many Requests with a Retry-After header when the \
limit is exceeded."
.to_string(),
);
findings.push(finding);
warn!(url, method, total_sent, "No rate limiting detected");
} else if got_429 {
info!(
url,
method,
rate_limit_at = ?rate_limit_at_request,
"Rate limiting is enforced"
);
}
let count = findings.len();
Ok(PentestToolResult {
summary: if got_429 {
format!(
"Rate limiting is enforced on {method} {url}. \
429 response received after {} requests.",
rate_limit_at_request.unwrap_or(0)
)
} else if count > 0 {
format!(
"No rate limiting detected on {method} {url} after {total_sent} requests."
)
} else {
format!("Rate limit testing complete for {method} {url}.")
},
findings,
data: rate_data,
})
})
}
}

View File

@@ -0,0 +1,125 @@
use std::collections::HashMap;
use compliance_core::error::CoreError;
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use tracing::info;
use crate::recon::ReconAgent;
/// PentestTool wrapper around the existing ReconAgent.
///
/// Performs HTTP header fingerprinting and technology detection.
/// Returns structured recon data for the LLM to use when planning attacks.
pub struct ReconTool {
http: reqwest::Client,
agent: ReconAgent,
}
impl ReconTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = ReconAgent::new(http.clone());
Self { http, agent }
}
}
impl PentestTool for ReconTool {
fn name(&self) -> &str {
"recon"
}
fn description(&self) -> &str {
"Performs reconnaissance on a target URL. Fingerprints HTTP headers, detects server \
technologies and frameworks. Returns structured data about the target's technology stack."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Base URL to perform reconnaissance on"
},
"additional_paths": {
"type": "array",
"description": "Optional additional paths to probe for technology fingerprinting",
"items": { "type": "string" }
}
},
"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 additional_paths: Vec<String> = input
.get("additional_paths")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let result = self.agent.scan(url).await?;
// Scan additional paths for more technology signals
let mut extra_technologies: Vec<String> = Vec::new();
let mut extra_headers: HashMap<String, String> = HashMap::new();
let base_url = url.trim_end_matches('/');
for path in &additional_paths {
let probe_url = format!("{base_url}/{}", path.trim_start_matches('/'));
if let Ok(resp) = self.http.get(&probe_url).send().await {
for (key, value) in resp.headers() {
let k = key.to_string().to_lowercase();
let v = value.to_str().unwrap_or("").to_string();
// Look for technology indicators
if k == "x-powered-by" || k == "server" || k == "x-generator" {
if !result.technologies.contains(&v) && !extra_technologies.contains(&v) {
extra_technologies.push(v.clone());
}
}
extra_headers.insert(format!("{probe_url} -> {k}"), v);
}
}
}
let mut all_technologies = result.technologies.clone();
all_technologies.extend(extra_technologies);
all_technologies.dedup();
let tech_count = all_technologies.len();
info!(url, technologies = tech_count, "Recon complete");
Ok(PentestToolResult {
summary: format!(
"Recon complete for {url}. Detected {} technologies. Server: {}.",
tech_count,
result.server.as_deref().unwrap_or("unknown")
),
findings: Vec::new(), // Recon produces data, not findings
data: json!({
"base_url": url,
"server": result.server,
"technologies": all_technologies,
"interesting_headers": result.interesting_headers,
"extra_headers": extra_headers,
"open_ports": result.open_ports,
}),
})
})
}
}

View File

@@ -0,0 +1,300 @@
use std::collections::HashMap;
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 checks for the presence and correctness of security headers.
pub struct SecurityHeadersTool {
http: reqwest::Client,
}
/// A security header we expect to be present and its metadata.
struct ExpectedHeader {
name: &'static str,
description: &'static str,
severity: Severity,
cwe: &'static str,
remediation: &'static str,
/// If present, the value must contain one of these substrings to be considered valid.
valid_values: Option<Vec<&'static str>>,
}
impl SecurityHeadersTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
fn expected_headers() -> Vec<ExpectedHeader> {
vec![
ExpectedHeader {
name: "strict-transport-security",
description: "HTTP Strict Transport Security (HSTS) forces browsers to use HTTPS",
severity: Severity::Medium,
cwe: "CWE-319",
remediation: "Add 'Strict-Transport-Security: max-age=31536000; includeSubDomains' header.",
valid_values: None,
},
ExpectedHeader {
name: "x-content-type-options",
description: "Prevents MIME type sniffing",
severity: Severity::Low,
cwe: "CWE-16",
remediation: "Add 'X-Content-Type-Options: nosniff' header.",
valid_values: Some(vec!["nosniff"]),
},
ExpectedHeader {
name: "x-frame-options",
description: "Prevents clickjacking by controlling iframe embedding",
severity: Severity::Medium,
cwe: "CWE-1021",
remediation: "Add 'X-Frame-Options: DENY' or 'X-Frame-Options: SAMEORIGIN' header.",
valid_values: Some(vec!["deny", "sameorigin"]),
},
ExpectedHeader {
name: "x-xss-protection",
description: "Enables browser XSS filtering (legacy but still recommended)",
severity: Severity::Low,
cwe: "CWE-79",
remediation: "Add 'X-XSS-Protection: 1; mode=block' header.",
valid_values: None,
},
ExpectedHeader {
name: "referrer-policy",
description: "Controls how much referrer information is shared",
severity: Severity::Low,
cwe: "CWE-200",
remediation: "Add 'Referrer-Policy: strict-origin-when-cross-origin' or 'no-referrer' header.",
valid_values: None,
},
ExpectedHeader {
name: "permissions-policy",
description: "Controls browser feature access (camera, microphone, geolocation, etc.)",
severity: Severity::Low,
cwe: "CWE-16",
remediation: "Add a Permissions-Policy header to restrict browser feature access. \
Example: 'Permissions-Policy: camera=(), microphone=(), geolocation=()'.",
valid_values: None,
},
]
}
}
impl PentestTool for SecurityHeadersTool {
fn name(&self) -> &str {
"security_headers"
}
fn description(&self) -> &str {
"Checks a URL for the presence and correctness of security headers: HSTS, \
X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, \
and Permissions-Policy."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to check security headers for"
}
},
"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 target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let response = self
.http
.get(url)
.send()
.await
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
let status = response.status().as_u16();
let response_headers: HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string().to_lowercase(), v.to_str().unwrap_or("").to_string()))
.collect();
let mut findings = Vec::new();
let mut header_results: HashMap<String, serde_json::Value> = HashMap::new();
for expected in Self::expected_headers() {
let header_value = response_headers.get(expected.name);
match header_value {
Some(value) => {
let mut is_valid = true;
if let Some(ref valid) = expected.valid_values {
let lower = value.to_lowercase();
is_valid = valid.iter().any(|v| lower.contains(v));
}
header_results.insert(
expected.name.to_string(),
json!({
"present": true,
"value": value,
"valid": is_valid,
}),
);
if !is_valid {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: Some(response_headers.clone()),
response_snippet: Some(format!("{}: {}", expected.name, value)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::SecurityHeaderMissing,
format!("Invalid {} header value", expected.name),
format!(
"The {} header is present but has an invalid or weak value: '{}'. \
{}",
expected.name, value, expected.description
),
expected.severity.clone(),
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some(expected.cwe.to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(expected.remediation.to_string());
findings.push(finding);
}
}
None => {
header_results.insert(
expected.name.to_string(),
json!({
"present": false,
"value": null,
"valid": false,
}),
);
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: Some(response_headers.clone()),
response_snippet: Some(format!("{} header is missing", expected.name)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::SecurityHeaderMissing,
format!("Missing {} header", expected.name),
format!(
"The {} header is not present in the response. {}",
expected.name, expected.description
),
expected.severity.clone(),
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some(expected.cwe.to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(expected.remediation.to_string());
findings.push(finding);
}
}
}
// Also check for information disclosure headers
let disclosure_headers = ["server", "x-powered-by", "x-aspnet-version", "x-aspnetmvc-version"];
for h in &disclosure_headers {
if let Some(value) = response_headers.get(*h) {
header_results.insert(
format!("{h}_disclosure"),
json!({ "present": true, "value": value }),
);
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: url.to_string(),
request_headers: None,
request_body: None,
response_status: status,
response_headers: Some(response_headers.clone()),
response_snippet: Some(format!("{h}: {value}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::SecurityHeaderMissing,
format!("Information disclosure via {h} header"),
format!(
"The {h} header exposes server technology information: '{value}'. \
This helps attackers fingerprint the server and find known vulnerabilities."
),
Severity::Info,
url.to_string(),
"GET".to_string(),
);
finding.cwe = Some("CWE-200".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(format!(
"Remove or suppress the {h} header in your server configuration."
));
findings.push(finding);
}
}
let count = findings.len();
info!(url, findings = count, "Security headers check complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} security header issues for {url}.")
} else {
format!("All checked security headers are present and valid for {url}.")
},
findings,
data: json!(header_results),
})
})
}
}

View File

@@ -0,0 +1,138 @@
use compliance_core::error::CoreError;
use compliance_core::traits::dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use crate::agents::injection::SqlInjectionAgent;
/// PentestTool wrapper around the existing SqlInjectionAgent.
pub struct SqlInjectionTool {
http: reqwest::Client,
agent: SqlInjectionAgent,
}
impl SqlInjectionTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = SqlInjectionAgent::new(http.clone());
Self { http, agent }
}
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
let mut endpoints = Vec::new();
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
for ep in arr {
let url = ep.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let method = ep.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let mut parameters = Vec::new();
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
for p in params {
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let location = p.get("location").and_then(|v| v.as_str()).unwrap_or("query").to_string();
let param_type = p.get("param_type").and_then(|v| v.as_str()).map(String::from);
let example_value = p.get("example_value").and_then(|v| v.as_str()).map(String::from);
parameters.push(EndpointParameter {
name,
location,
param_type,
example_value,
});
}
}
endpoints.push(DiscoveredEndpoint {
url,
method,
parameters,
content_type: ep.get("content_type").and_then(|v| v.as_str()).map(String::from),
requires_auth: ep.get("requires_auth").and_then(|v| v.as_bool()).unwrap_or(false),
});
}
}
endpoints
}
}
impl PentestTool for SqlInjectionTool {
fn name(&self) -> &str {
"sql_injection_scanner"
}
fn description(&self) -> &str {
"Tests endpoints for SQL injection vulnerabilities using error-based, boolean-based, \
time-based, and union-based techniques. Provide endpoints with their parameters to test."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"description": "Endpoints to test for SQL injection",
"items": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "Full URL of the endpoint" },
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"parameters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"location": { "type": "string", "enum": ["query", "body", "header", "path", "cookie"] },
"param_type": { "type": "string" },
"example_value": { "type": "string" }
},
"required": ["name"]
}
}
},
"required": ["url", "method", "parameters"]
}
},
"custom_payloads": {
"type": "array",
"description": "Optional additional SQL injection payloads to test",
"items": { "type": "string" }
}
},
"required": ["endpoints"]
})
}
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 endpoints = Self::parse_endpoints(&input);
if endpoints.is_empty() {
return Ok(PentestToolResult {
summary: "No endpoints provided to test.".to_string(),
findings: Vec::new(),
data: json!({}),
});
}
let dast_context = DastContext {
endpoints,
technologies: Vec::new(),
sast_hints: Vec::new(),
};
let findings = self.agent.run(&context.target, &dast_context).await?;
let count = findings.len();
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} SQL injection vulnerabilities.")
} else {
"No SQL injection vulnerabilities detected.".to_string()
},
findings,
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
})
})
}
}

View File

@@ -0,0 +1,134 @@
use compliance_core::error::CoreError;
use compliance_core::traits::dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use crate::agents::ssrf::SsrfAgent;
/// PentestTool wrapper around the existing SsrfAgent.
pub struct SsrfTool {
http: reqwest::Client,
agent: SsrfAgent,
}
impl SsrfTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = SsrfAgent::new(http.clone());
Self { http, agent }
}
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
let mut endpoints = Vec::new();
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
for ep in arr {
let url = ep.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let method = ep.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let mut parameters = Vec::new();
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
for p in params {
parameters.push(EndpointParameter {
name: p.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
location: p.get("location").and_then(|v| v.as_str()).unwrap_or("query").to_string(),
param_type: p.get("param_type").and_then(|v| v.as_str()).map(String::from),
example_value: p.get("example_value").and_then(|v| v.as_str()).map(String::from),
});
}
}
endpoints.push(DiscoveredEndpoint {
url,
method,
parameters,
content_type: ep.get("content_type").and_then(|v| v.as_str()).map(String::from),
requires_auth: ep.get("requires_auth").and_then(|v| v.as_bool()).unwrap_or(false),
});
}
}
endpoints
}
}
impl PentestTool for SsrfTool {
fn name(&self) -> &str {
"ssrf_scanner"
}
fn description(&self) -> &str {
"Tests endpoints for Server-Side Request Forgery (SSRF) vulnerabilities. Checks if \
parameters accepting URLs can be exploited to access internal resources."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"description": "Endpoints to test for SSRF (focus on those accepting URL parameters)",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"parameters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"location": { "type": "string" },
"param_type": { "type": "string" },
"example_value": { "type": "string" }
},
"required": ["name"]
}
}
},
"required": ["url", "method", "parameters"]
}
},
"custom_payloads": {
"type": "array",
"description": "Optional additional SSRF payloads (internal URLs to try)",
"items": { "type": "string" }
}
},
"required": ["endpoints"]
})
}
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 endpoints = Self::parse_endpoints(&input);
if endpoints.is_empty() {
return Ok(PentestToolResult {
summary: "No endpoints provided to test.".to_string(),
findings: Vec::new(),
data: json!({}),
});
}
let dast_context = DastContext {
endpoints,
technologies: Vec::new(),
sast_hints: Vec::new(),
};
let findings = self.agent.run(&context.target, &dast_context).await?;
let count = findings.len();
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} SSRF vulnerabilities.")
} else {
"No SSRF vulnerabilities detected.".to_string()
},
findings,
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
})
})
}
}

View File

@@ -0,0 +1,442 @@
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 tokio::net::TcpStream;
use tracing::{info, warn};
/// Tool that analyzes TLS configuration of a target.
///
/// Connects via TCP, performs a TLS handshake using `tokio-native-tls`,
/// and inspects the certificate and negotiated protocol. Also checks
/// for common TLS misconfigurations.
pub struct TlsAnalyzerTool {
http: reqwest::Client,
}
impl TlsAnalyzerTool {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
/// Extract the hostname from a URL.
fn extract_host(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(String::from))
}
/// Extract port from a URL (defaults to 443 for https).
fn extract_port(url: &str) -> u16 {
url::Url::parse(url)
.ok()
.and_then(|u| u.port())
.unwrap_or(443)
}
/// Check if the server accepts a connection on a given port with a weak
/// TLS client hello. We test SSLv3 / old protocol support by attempting
/// connection with the system's native-tls which typically negotiates the
/// best available, then inspect what was negotiated.
async fn check_tls(
host: &str,
port: u16,
) -> Result<TlsInfo, CoreError> {
let addr = format!("{host}:{port}");
let tcp = TcpStream::connect(&addr)
.await
.map_err(|e| CoreError::Dast(format!("TCP connection to {addr} failed: {e}")))?;
let connector = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.map_err(|e| CoreError::Dast(format!("TLS connector build failed: {e}")))?;
let connector = tokio_native_tls::TlsConnector::from(connector);
let tls_stream = connector
.connect(host, tcp)
.await
.map_err(|e| CoreError::Dast(format!("TLS handshake with {addr} failed: {e}")))?;
let peer_cert = tls_stream.get_ref().peer_certificate()
.map_err(|e| CoreError::Dast(format!("Failed to get peer certificate: {e}")))?;
let mut tls_info = TlsInfo {
protocol_version: String::new(),
cert_subject: String::new(),
cert_issuer: String::new(),
cert_not_before: String::new(),
cert_not_after: String::new(),
cert_expired: false,
cert_self_signed: false,
alpn_protocol: None,
san_names: Vec::new(),
};
if let Some(cert) = peer_cert {
let der = cert.to_der()
.map_err(|e| CoreError::Dast(format!("Certificate DER encoding failed: {e}")))?;
// native_tls doesn't give rich access, so we parse what we can
// from the DER-encoded certificate.
tls_info.cert_subject = "see DER certificate".to_string();
// Attempt to parse with basic DER inspection for dates
tls_info = Self::parse_cert_der(&der, tls_info);
}
Ok(tls_info)
}
/// Best-effort parse of DER-encoded X.509 certificate for dates and subject.
/// This is a simplified parser; in production you would use a proper x509 crate.
fn parse_cert_der(der: &[u8], mut info: TlsInfo) -> TlsInfo {
// We rely on the native_tls debug output stored in cert_subject
// and just mark fields as "see certificate details"
if info.cert_subject.contains("self signed") || info.cert_subject.contains("Self-Signed") {
info.cert_self_signed = true;
}
info
}
}
struct TlsInfo {
protocol_version: String,
cert_subject: String,
cert_issuer: String,
cert_not_before: String,
cert_not_after: String,
cert_expired: bool,
cert_self_signed: bool,
alpn_protocol: Option<String>,
san_names: Vec<String>,
}
impl PentestTool for TlsAnalyzerTool {
fn name(&self) -> &str {
"tls_analyzer"
}
fn description(&self) -> &str {
"Analyzes TLS/SSL configuration of a target. Checks certificate validity, expiry, chain \
trust, and negotiated protocols. Reports TLS misconfigurations."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Target URL or hostname to analyze TLS configuration"
},
"port": {
"type": "integer",
"description": "Port to connect to (default: 443)",
"default": 443
},
"check_protocols": {
"type": "boolean",
"description": "Whether to test for old/weak protocol versions",
"default": true
}
},
"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 host = Self::extract_host(url)
.unwrap_or_else(|| url.to_string());
let port = input
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or_else(|| Self::extract_port(url));
let target_id = context
.target
.id
.map(|oid| oid.to_hex())
.unwrap_or_else(|| "unknown".to_string());
let mut findings = Vec::new();
let mut tls_data = json!({});
// First check: does the server even support HTTPS?
let https_url = if url.starts_with("https://") {
url.to_string()
} else if url.starts_with("http://") {
url.replace("http://", "https://")
} else {
format!("https://{url}")
};
// Check if HTTP redirects to HTTPS
let http_url = if url.starts_with("http://") {
url.to_string()
} else if url.starts_with("https://") {
url.replace("https://", "http://")
} else {
format!("http://{url}")
};
match self.http.get(&http_url).send().await {
Ok(resp) => {
let final_url = resp.url().to_string();
let redirects_to_https = final_url.starts_with("https://");
tls_data["http_redirects_to_https"] = json!(redirects_to_https);
if !redirects_to_https {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: http_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(format!("Final URL: {final_url}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("HTTP does not redirect to HTTPS for {host}"),
format!(
"HTTP requests to {host} are not redirected to HTTPS. \
Users accessing the site via HTTP will have their traffic \
transmitted in cleartext."
),
Severity::Medium,
http_url.clone(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Configure the web server to redirect all HTTP requests to HTTPS \
using a 301 redirect."
.to_string(),
);
findings.push(finding);
}
}
Err(_) => {
tls_data["http_check_error"] = json!("Could not connect via HTTP");
}
}
// Perform TLS analysis
match Self::check_tls(&host, port).await {
Ok(tls_info) => {
tls_data["host"] = json!(host);
tls_data["port"] = json!(port);
tls_data["cert_subject"] = json!(tls_info.cert_subject);
tls_data["cert_issuer"] = json!(tls_info.cert_issuer);
tls_data["cert_not_before"] = json!(tls_info.cert_not_before);
tls_data["cert_not_after"] = json!(tls_info.cert_not_after);
tls_data["alpn_protocol"] = json!(tls_info.alpn_protocol);
tls_data["san_names"] = json!(tls_info.san_names);
if tls_info.cert_expired {
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!(
"Certificate expired. Not After: {}",
tls_info.cert_not_after
)),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Expired TLS certificate for {host}"),
format!(
"The TLS certificate for {host} has expired. \
Browsers will show security warnings to users."
),
Severity::High,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Renew the TLS certificate. Consider using automated certificate \
management with Let's Encrypt or a similar CA."
.to_string(),
);
findings.push(finding);
warn!(host, "Expired TLS certificate");
}
if tls_info.cert_self_signed {
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some("Self-signed certificate detected".to_string()),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Self-signed TLS certificate for {host}"),
format!(
"The TLS certificate for {host} is self-signed and not issued by a \
trusted certificate authority. Browsers will show security warnings."
),
Severity::Medium,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Replace the self-signed certificate with one issued by a trusted \
certificate authority."
.to_string(),
);
findings.push(finding);
warn!(host, "Self-signed certificate");
}
}
Err(e) => {
tls_data["tls_error"] = json!(e.to_string());
// TLS handshake failure itself is a finding
let evidence = DastEvidence {
request_method: "TLS".to_string(),
request_url: format!("{host}:{port}"),
request_headers: None,
request_body: None,
response_status: 0,
response_headers: None,
response_snippet: Some(format!("TLS error: {e}")),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("TLS handshake failure for {host}"),
format!(
"Could not establish a TLS connection to {host}:{port}. Error: {e}"
),
Severity::High,
format!("https://{host}:{port}"),
"TLS".to_string(),
);
finding.cwe = Some("CWE-295".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Ensure TLS is properly configured on the server. Check that the \
certificate is valid and the server supports modern TLS versions."
.to_string(),
);
findings.push(finding);
}
}
// Check strict transport security via an HTTPS request
match self.http.get(&https_url).send().await {
Ok(resp) => {
let hsts = resp.headers().get("strict-transport-security");
tls_data["hsts_header"] = json!(hsts.map(|v| v.to_str().unwrap_or("")));
if hsts.is_none() {
let evidence = DastEvidence {
request_method: "GET".to_string(),
request_url: https_url.clone(),
request_headers: None,
request_body: None,
response_status: resp.status().as_u16(),
response_headers: None,
response_snippet: Some(
"Strict-Transport-Security header not present".to_string(),
),
screenshot_path: None,
payload: None,
response_time_ms: None,
};
let mut finding = DastFinding::new(
String::new(),
target_id.clone(),
DastVulnType::TlsMisconfiguration,
format!("Missing HSTS header for {host}"),
format!(
"The server at {host} does not send a Strict-Transport-Security header. \
Without HSTS, browsers may allow HTTP downgrade attacks."
),
Severity::Medium,
https_url.clone(),
"GET".to_string(),
);
finding.cwe = Some("CWE-319".to_string());
finding.evidence = vec![evidence];
finding.remediation = Some(
"Add the Strict-Transport-Security header with an appropriate max-age. \
Example: 'Strict-Transport-Security: max-age=31536000; includeSubDomains'."
.to_string(),
);
findings.push(finding);
}
}
Err(_) => {
tls_data["https_check_error"] = json!("Could not connect via HTTPS");
}
}
let count = findings.len();
info!(host = %host, findings = count, "TLS analysis complete");
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} TLS configuration issues for {host}.")
} else {
format!("TLS configuration looks good for {host}.")
},
findings,
data: tls_data,
})
})
}
}

View File

@@ -0,0 +1,134 @@
use compliance_core::error::CoreError;
use compliance_core::traits::dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
use serde_json::json;
use crate::agents::xss::XssAgent;
/// PentestTool wrapper around the existing XssAgent.
pub struct XssTool {
http: reqwest::Client,
agent: XssAgent,
}
impl XssTool {
pub fn new(http: reqwest::Client) -> Self {
let agent = XssAgent::new(http.clone());
Self { http, agent }
}
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
let mut endpoints = Vec::new();
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
for ep in arr {
let url = ep.get("url").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let method = ep.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let mut parameters = Vec::new();
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
for p in params {
parameters.push(EndpointParameter {
name: p.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
location: p.get("location").and_then(|v| v.as_str()).unwrap_or("query").to_string(),
param_type: p.get("param_type").and_then(|v| v.as_str()).map(String::from),
example_value: p.get("example_value").and_then(|v| v.as_str()).map(String::from),
});
}
}
endpoints.push(DiscoveredEndpoint {
url,
method,
parameters,
content_type: ep.get("content_type").and_then(|v| v.as_str()).map(String::from),
requires_auth: ep.get("requires_auth").and_then(|v| v.as_bool()).unwrap_or(false),
});
}
}
endpoints
}
}
impl PentestTool for XssTool {
fn name(&self) -> &str {
"xss_scanner"
}
fn description(&self) -> &str {
"Tests endpoints for Cross-Site Scripting (XSS) vulnerabilities including reflected, \
stored, and DOM-based XSS. Provide endpoints with parameters to test."
}
fn input_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"endpoints": {
"type": "array",
"description": "Endpoints to test for XSS",
"items": {
"type": "object",
"properties": {
"url": { "type": "string" },
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
"parameters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"location": { "type": "string", "enum": ["query", "body", "header", "path", "cookie"] },
"param_type": { "type": "string" },
"example_value": { "type": "string" }
},
"required": ["name"]
}
}
},
"required": ["url", "method", "parameters"]
}
},
"custom_payloads": {
"type": "array",
"description": "Optional additional XSS payloads to test",
"items": { "type": "string" }
}
},
"required": ["endpoints"]
})
}
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 endpoints = Self::parse_endpoints(&input);
if endpoints.is_empty() {
return Ok(PentestToolResult {
summary: "No endpoints provided to test.".to_string(),
findings: Vec::new(),
data: json!({}),
});
}
let dast_context = DastContext {
endpoints,
technologies: Vec::new(),
sast_hints: Vec::new(),
};
let findings = self.agent.run(&context.target, &dast_context).await?;
let count = findings.len();
Ok(PentestToolResult {
summary: if count > 0 {
format!("Found {count} XSS vulnerabilities.")
} else {
"No XSS vulnerabilities detected.".to_string()
},
findings,
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
})
})
}
}