use compliance_core::error::CoreError; use compliance_core::models::dast::{DastEvidence, DastFinding, DastTarget, DastVulnType}; use compliance_core::models::Severity; use compliance_core::traits::dast_agent::{DastAgent, DastContext}; use tracing::info; /// API fuzzing agent that tests for misconfigurations and information disclosure pub struct ApiFuzzerAgent { http: reqwest::Client, } impl ApiFuzzerAgent { pub fn new(http: reqwest::Client) -> Self { Self { http } } /// Common API paths to probe fn discovery_paths(&self) -> Vec<(&str, &str)> { vec![ ("/.env", "Environment file exposure"), ("/.git/config", "Git config exposure"), ("/api/swagger.json", "Swagger spec exposure"), ("/api/openapi.json", "OpenAPI spec exposure"), ("/api-docs", "API documentation exposure"), ("/graphql", "GraphQL endpoint"), ("/debug", "Debug endpoint"), ("/actuator/health", "Spring actuator"), ("/wp-config.php.bak", "WordPress config backup"), ("/.well-known/openid-configuration", "OIDC config"), ("/server-status", "Apache server status"), ("/phpinfo.php", "PHP info exposure"), ("/robots.txt", "Robots.txt"), ("/sitemap.xml", "Sitemap"), ("/.htaccess", "htaccess exposure"), ("/backup.sql", "SQL backup exposure"), ("/api/v1/users", "User enumeration endpoint"), ] } /// Patterns indicating sensitive information disclosure fn sensitive_patterns(&self) -> Vec<&str> { vec![ "password", "api_key", "apikey", "secret", "token", "private_key", "aws_access_key", "jdbc:", "mongodb://", "redis://", "postgresql://", ] } } impl DastAgent for ApiFuzzerAgent { fn name(&self) -> &str { "api_fuzzer" } async fn run( &self, target: &DastTarget, _context: &DastContext, ) -> Result, CoreError> { let mut findings = Vec::new(); let target_id = target .id .map(|oid| oid.to_hex()) .unwrap_or_else(|| "unknown".to_string()); let base = target.base_url.trim_end_matches('/'); // Phase 1: Path discovery for (path, description) in self.discovery_paths() { let url = format!("{base}{path}"); let response = match self.http.get(&url).send().await { Ok(r) => r, Err(_) => continue, }; let status = response.status().as_u16(); if status == 200 { let body = response.text().await.unwrap_or_default(); // Check if it's actually sensitive content (not just a 200 catch-all) let is_sensitive = !body.is_empty() && body.len() > 10 && !body.contains("404") && !body.contains("not found"); if is_sensitive { let snippet = body.chars().take(500).collect::(); // Check for information disclosure let body_lower = body.to_lowercase(); let has_secrets = self .sensitive_patterns() .iter() .any(|p| body_lower.contains(p)); let severity = if has_secrets { Severity::Critical } else if path.contains(".env") || path.contains(".git") || path.contains("backup") { Severity::High } else { Severity::Medium }; let evidence = DastEvidence { request_method: "GET".to_string(), request_url: url.clone(), 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, }; let vuln_type = if has_secrets { DastVulnType::InformationDisclosure } else { DastVulnType::SecurityMisconfiguration }; let mut finding = DastFinding::new( String::new(), target_id.clone(), vuln_type, format!("{description}: {path}"), format!( "Sensitive resource accessible at {url}. {}", if has_secrets { "Response contains potentially sensitive information." } else { "This resource should not be publicly accessible." } ), severity, url, "GET".to_string(), ); finding.exploitable = has_secrets; finding.evidence = vec![evidence]; finding.cwe = Some(if has_secrets { "CWE-200".to_string() } else { "CWE-16".to_string() }); findings.push(finding); } } } // Phase 2: CORS misconfiguration check let cors_finding = self.check_cors(base, &target_id).await; if let Some(f) = cors_finding { findings.push(f); } // Phase 3: Check for verbose error responses let error_url = format!("{base}/nonexistent-path-{}", uuid::Uuid::new_v4()); if let Ok(response) = self.http.get(&error_url).send().await { let body = response.text().await.unwrap_or_default(); let body_lower = body.to_lowercase(); let has_stack_trace = body_lower.contains("traceback") || body_lower.contains("stack trace") || body_lower.contains("at line") || body_lower.contains("exception in") || body_lower.contains("error in") || (body_lower.contains(".py") && body_lower.contains("line")); if has_stack_trace { let snippet = body.chars().take(500).collect::(); let evidence = DastEvidence { request_method: "GET".to_string(), request_url: error_url.clone(), request_headers: None, request_body: None, response_status: 404, response_headers: None, response_snippet: Some(snippet), screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::InformationDisclosure, "Verbose error messages expose stack traces".to_string(), "The application exposes detailed error information including stack traces. \ This can reveal internal paths, framework versions, and code structure." .to_string(), Severity::Low, error_url, "GET".to_string(), ); finding.evidence = vec![evidence]; finding.cwe = Some("CWE-209".to_string()); finding.remediation = Some( "Configure the application to use generic error pages in production. \ Do not expose stack traces or internal error details to end users." .to_string(), ); findings.push(finding); } } info!(findings = findings.len(), "API fuzzing scan complete"); Ok(findings) } } impl ApiFuzzerAgent { async fn check_cors(&self, base_url: &str, target_id: &str) -> Option { let response = self .http .get(base_url) .header("Origin", "https://evil.com") .send() .await .ok()?; let headers = response.headers(); let acao = headers.get("access-control-allow-origin")?.to_str().ok()?; if acao == "*" || acao == "https://evil.com" { let acac = headers .get("access-control-allow-credentials") .and_then(|v| v.to_str().ok()) .unwrap_or("false"); // Wildcard CORS with credentials is the worst case let severity = if acac == "true" { Severity::High } else if acao == "*" { Severity::Medium } else { Severity::Low }; let evidence = DastEvidence { request_method: "GET".to_string(), request_url: base_url.to_string(), request_headers: Some( [("Origin".to_string(), "https://evil.com".to_string())] .into_iter() .collect(), ), request_body: None, response_status: response.status().as_u16(), response_headers: Some( [("Access-Control-Allow-Origin".to_string(), acao.to_string())] .into_iter() .collect(), ), response_snippet: None, screenshot_path: None, payload: None, response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.to_string(), DastVulnType::SecurityMisconfiguration, "CORS misconfiguration allows arbitrary origins".to_string(), format!( "The server responds with Access-Control-Allow-Origin: {acao} \ which may allow cross-origin attacks." ), severity, base_url.to_string(), "GET".to_string(), ); finding.evidence = vec![evidence]; finding.cwe = Some("CWE-942".to_string()); finding.remediation = Some( "Configure CORS to only allow trusted origins. \ Never use wildcard (*) with credentials." .to_string(), ); Some(finding) } else { None } } }