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:
401
compliance-dast/src/tools/dmarc_checker.rs
Normal file
401
compliance-dast/src/tools/dmarc_checker.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType};
|
||||
use compliance_core::models::Severity;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
use tracing::{info, 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user