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 { 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 { 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, san_names: Vec, } 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> + 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, }) }) } }