""" TLS/SSL Security Scanner. This module checks TLS/SSL configuration and certificate validity. """ import logging import socket import ssl from datetime import datetime, timezone from typing import Any, Dict, Optional from urllib.parse import urlparse from .base import ( BaseScanner, ScannerResult, ScannerStatus, IssueData, MetricData, ) logger = logging.getLogger(__name__) class TLSScanner(BaseScanner): """ Scanner for TLS/SSL certificate and configuration. Checks: - Certificate validity - Certificate expiration - HTTPS availability - HTTP to HTTPS redirect """ name = "tls_check" def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) self.timeout = self.config.get('timeout', 10) def run(self, url: str) -> ScannerResult: """ Run TLS/SSL analysis on the URL. Args: url: The URL to analyze Returns: ScannerResult with TLS findings """ self.logger.info(f"Starting TLS scan for {url}") try: parsed = urlparse(url) hostname = parsed.netloc.split(':')[0] port = parsed.port or (443 if parsed.scheme == 'https' else 80) issues = [] metrics = [] raw_data = {} # Check if site is HTTPS if parsed.scheme == 'http': # Check if HTTPS is available https_available, https_result = self._check_https_available(hostname) raw_data['https_available'] = https_available raw_data['https_check'] = https_result if https_available: issues.append(IssueData( category='tls', severity='high', title='Site accessed over HTTP but HTTPS is available', description=( 'The site was accessed over unencrypted HTTP, but HTTPS ' 'appears to be available. All traffic should use HTTPS.' ), tool='tls_check', affected_url=url, remediation=( 'Redirect all HTTP traffic to HTTPS using a 301 redirect. ' 'Implement HSTS to prevent future HTTP access.' ) )) else: issues.append(IssueData( category='tls', severity='critical', title='Site does not support HTTPS', description=( 'The site does not appear to have HTTPS configured. ' 'All data transmitted is unencrypted and vulnerable to interception.' ), tool='tls_check', affected_url=url, remediation=( 'Configure TLS/SSL for your server. Obtain a certificate from ' "Let's Encrypt (free) or a commercial CA." ) )) metrics.append(MetricData( name='tls_enabled', display_name='TLS Enabled', value=0.0, unit='score', source='tls_check' )) return ScannerResult( scanner_name=self.name, status=ScannerStatus.SUCCESS, issues=issues, metrics=metrics, raw_data=raw_data ) # For HTTPS URLs, check certificate cert_info = self._get_certificate_info(hostname, port) raw_data['certificate'] = cert_info if cert_info.get('error'): issues.append(IssueData( category='tls', severity='critical', title='Certificate validation failed', description=f"SSL certificate error: {cert_info['error']}", tool='tls_check', affected_url=url, remediation=( 'Ensure your SSL certificate is valid, not expired, ' 'and properly configured for your domain.' ) )) metrics.append(MetricData( name='certificate_valid', display_name='Certificate Valid', value=0.0, unit='score', source='tls_check' )) else: # Certificate is valid metrics.append(MetricData( name='certificate_valid', display_name='Certificate Valid', value=1.0, unit='score', source='tls_check' )) metrics.append(MetricData( name='tls_enabled', display_name='TLS Enabled', value=1.0, unit='score', source='tls_check' )) # Check expiration if cert_info.get('expires'): try: expires = datetime.strptime( cert_info['expires'], '%b %d %H:%M:%S %Y %Z' ) expires = expires.replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) days_until_expiry = (expires - now).days metrics.append(MetricData( name='certificate_days_until_expiry', display_name='Days Until Certificate Expiry', value=float(days_until_expiry), unit='count', source='tls_check' )) if days_until_expiry <= 0: issues.append(IssueData( category='tls', severity='critical', title='SSL certificate has expired', description=( f"The SSL certificate expired on {cert_info['expires']}. " "Users will see security warnings." ), tool='tls_check', affected_url=url, remediation='Renew your SSL certificate immediately.' )) elif days_until_expiry <= 7: issues.append(IssueData( category='tls', severity='high', title='SSL certificate expiring very soon', description=( f"The SSL certificate will expire in {days_until_expiry} days " f"(on {cert_info['expires']}). Renew immediately." ), tool='tls_check', affected_url=url, remediation='Renew your SSL certificate before it expires.' )) elif days_until_expiry <= 30: issues.append(IssueData( category='tls', severity='medium', title='SSL certificate expiring soon', description=( f"The SSL certificate will expire in {days_until_expiry} days " f"(on {cert_info['expires']}). Plan for renewal." ), tool='tls_check', affected_url=url, remediation=( 'Renew your SSL certificate before expiration. ' "Consider using auto-renewal with Let's Encrypt." ) )) except Exception as e: self.logger.warning(f"Could not parse certificate expiry: {e}") # Check certificate subject matches hostname if cert_info.get('subject'): subject_cn = dict(x[0] for x in cert_info['subject']).get('commonName', '') san = cert_info.get('subjectAltName', []) san_names = [name for type_, name in san if type_ == 'DNS'] hostname_matched = self._hostname_matches_cert( hostname, subject_cn, san_names ) if not hostname_matched: issues.append(IssueData( category='tls', severity='high', title='Certificate hostname mismatch', description=( f"The SSL certificate is for '{subject_cn}' but " f"the site is accessed as '{hostname}'." ), tool='tls_check', affected_url=url, remediation=( 'Obtain a certificate that includes your domain name, ' 'or add it to the Subject Alternative Names (SAN).' ) )) # Check for HTTP to HTTPS redirect if parsed.scheme == 'https': redirect_info = self._check_http_redirect(hostname) raw_data['http_redirect'] = redirect_info if not redirect_info.get('redirects_to_https'): issues.append(IssueData( category='tls', severity='medium', title='No HTTP to HTTPS redirect', description=( 'The site does not redirect HTTP requests to HTTPS. ' 'Users accessing via HTTP will use an insecure connection.' ), tool='tls_check', affected_url=f"http://{hostname}", remediation=( 'Configure your server to redirect all HTTP (port 80) ' 'requests to HTTPS (port 443) with a 301 redirect.' ) )) self.logger.info(f"TLS scan complete: {len(issues)} issues") return ScannerResult( scanner_name=self.name, status=ScannerStatus.SUCCESS, issues=issues, metrics=metrics, raw_data=raw_data ) except Exception as e: return self._create_error_result(e) def _check_https_available(self, hostname: str) -> tuple: """Check if HTTPS is available for the hostname.""" try: context = ssl.create_default_context() with socket.create_connection((hostname, 443), timeout=self.timeout) as sock: with context.wrap_socket(sock, server_hostname=hostname) as ssock: return True, {'available': True, 'protocol': ssock.version()} except ssl.SSLError as e: return True, {'available': True, 'error': str(e)} except Exception as e: return False, {'available': False, 'error': str(e)} def _get_certificate_info(self, hostname: str, port: int = 443) -> Dict: """Get SSL certificate information.""" try: context = ssl.create_default_context() with socket.create_connection((hostname, port), timeout=self.timeout) as sock: with context.wrap_socket(sock, server_hostname=hostname) as ssock: cert = ssock.getpeercert() return { 'subject': cert.get('subject'), 'issuer': cert.get('issuer'), 'version': cert.get('version'), 'serialNumber': cert.get('serialNumber'), 'notBefore': cert.get('notBefore'), 'expires': cert.get('notAfter'), 'subjectAltName': cert.get('subjectAltName', []), 'protocol': ssock.version(), 'cipher': ssock.cipher(), } except ssl.SSLCertVerificationError as e: return {'error': f"Certificate verification failed: {e.verify_message}"} except ssl.SSLError as e: return {'error': f"SSL error: {str(e)}"} except socket.timeout: return {'error': "Connection timed out"} except Exception as e: return {'error': str(e)} def _hostname_matches_cert( self, hostname: str, cn: str, san_names: list ) -> bool: """Check if hostname matches certificate CN or SAN.""" all_names = [cn] + san_names for name in all_names: if name == hostname: return True # Handle wildcard certificates if name.startswith('*.'): domain = name[2:] if hostname.endswith(domain): # Ensure wildcard only matches one level prefix = hostname[:-len(domain)-1] if '.' not in prefix: return True return False def _check_http_redirect(self, hostname: str) -> Dict: """Check if HTTP redirects to HTTPS.""" import httpx try: with httpx.Client( timeout=self.timeout, follow_redirects=False ) as client: response = client.get(f"http://{hostname}") if response.status_code in (301, 302, 303, 307, 308): location = response.headers.get('location', '') redirects_to_https = location.startswith('https://') return { 'redirects_to_https': redirects_to_https, 'status_code': response.status_code, 'location': location, } else: return { 'redirects_to_https': False, 'status_code': response.status_code, } except Exception as e: return { 'redirects_to_https': False, 'error': str(e), }