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 Default for DnsCheckerTool { fn default() -> Self { Self::new() } } 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, 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 = 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, Vec), 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> + 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 = 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 = 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> = 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), }) }) } }