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; /// Server-Side Request Forgery (SSRF) testing agent pub struct SsrfAgent { http: reqwest::Client, } impl SsrfAgent { pub fn new(http: reqwest::Client) -> Self { Self { http } } fn payloads(&self) -> Vec<(&str, &str)> { vec![ ("http://127.0.0.1", "localhost IPv4"), ("http://[::1]", "localhost IPv6"), ("http://0.0.0.0", "zero address"), ("http://169.254.169.254/latest/meta-data/", "AWS metadata"), ("http://metadata.google.internal/", "GCP metadata"), ("http://127.0.0.1:22", "SSH port probe"), ("http://127.0.0.1:3306", "MySQL port probe"), ("http://localhost/admin", "localhost admin"), ] } fn internal_indicators(&self) -> Vec<&str> { vec![ "ami-id", "instance-id", "local-hostname", "public-hostname", "iam/security-credentials", "computeMetadata", "OpenSSH", "mysql_native_password", "root:x:0:", "", ] } } impl DastAgent for SsrfAgent { fn name(&self) -> &str { "ssrf" } 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()); // Find endpoints with URL-like parameters for endpoint in &context.endpoints { let url_params: Vec<_> = endpoint .parameters .iter() .filter(|p| { let name_lower = p.name.to_lowercase(); name_lower.contains("url") || name_lower.contains("uri") || name_lower.contains("link") || name_lower.contains("src") || name_lower.contains("redirect") || name_lower.contains("callback") || name_lower.contains("fetch") || name_lower.contains("load") }) .collect(); if url_params.is_empty() { continue; } for param in &url_params { for (payload, technique) in self.payloads() { let request = if endpoint.method == "POST" { self.http .post(&endpoint.url) .form(&[(param.name.as_str(), payload)]) } else { let test_url = format!("{}?{}={}", endpoint.url, param.name, payload); self.http.get(&test_url) }; let response = match request.send().await { Ok(r) => r, Err(_) => continue, }; let status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); // Check for SSRF indicators let body_lower = body.to_lowercase(); let is_vulnerable = self .internal_indicators() .iter() .any(|indicator| body_lower.contains(&indicator.to_lowercase())); if is_vulnerable { let snippet = body.chars().take(500).collect::(); let evidence = DastEvidence { request_method: endpoint.method.clone(), request_url: endpoint.url.clone(), request_headers: None, request_body: Some(format!("{}={}", param.name, payload)), response_status: status, response_headers: None, response_snippet: Some(snippet), screenshot_path: None, payload: Some(payload.to_string()), response_time_ms: None, }; let mut finding = DastFinding::new( String::new(), target_id.clone(), DastVulnType::Ssrf, format!("SSRF ({technique}) via parameter '{}'", param.name), format!( "Server-side request forgery detected in parameter '{}' at {}. \ The application made a request to an internal resource ({}).", param.name, endpoint.url, payload ), Severity::High, endpoint.url.clone(), endpoint.method.clone(), ); finding.parameter = Some(param.name.clone()); finding.exploitable = true; finding.evidence = vec![evidence]; finding.cwe = Some("CWE-918".to_string()); finding.remediation = Some( "Validate and sanitize all user-supplied URLs. \ Use allowlists for permitted domains and block internal IP ranges." .to_string(), ); findings.push(finding); break; } } } } info!(findings = findings.len(), "SSRF scan complete"); Ok(findings) } }