import ssl import socket import logging from datetime import datetime, timezone from .base import Finding logger = logging.getLogger(__name__) CHECK_NAME = 'tls_expiry' TIMEOUT = 10 def _get_cert_info(domain: str) -> dict: ctx = ssl.create_default_context() try: with ctx.wrap_socket(socket.create_connection((domain, 443), timeout=TIMEOUT), server_hostname=domain) as s: cert = s.getpeercert() return {'cert': cert, 'error': None} except ssl.SSLCertVerificationError as e: return {'cert': None, 'error': f'SSL verification failed: {e}'} except Exception as e: return {'cert': None, 'error': str(e)} def _days_until_expiry(not_after: str) -> int: expiry = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z').replace(tzinfo=timezone.utc) now = datetime.now(tz=timezone.utc) return (expiry - now).days def _cert_covers_domain(cert: dict, domain: str) -> bool: san_list = [v for t, v in cert.get('subjectAltName', []) if t == 'DNS'] for san in san_list: if san == domain: return True if san.startswith('*.') and domain.endswith(san[1:]): return True if not san_list: cn = dict(x[0] for x in cert.get('subject', [])).get('commonName', '') return cn == domain return False def run(profile) -> list: findings = [] domains = profile.domains or [] if not domains: return [Finding( check_name=CHECK_NAME, severity='info', title='No domains configured for TLS check', detail='Add domains to the scan profile to enable TLS certificate checks.', raw={}, )] for domain in domains: raw = {'domain': domain} info = _get_cert_info(domain) if info['error']: findings.append(Finding( check_name=CHECK_NAME, severity='warning', title=f'{domain}: TLS check failed', detail=info['error'], raw={**raw, 'error': info['error']}, )) continue cert = info['cert'] not_after = cert.get('notAfter', '') raw['not_after'] = not_after try: days = _days_until_expiry(not_after) raw['days_to_expiry'] = days if days < 0: findings.append(Finding( check_name=CHECK_NAME, severity='critical', title=f'{domain}: TLS certificate EXPIRED {abs(days)} days ago', detail=f'Certificate expired on {not_after}. Renew immediately.', raw=raw, )) elif days < 14: findings.append(Finding( check_name=CHECK_NAME, severity='critical', title=f'{domain}: TLS certificate expires in {days} days', detail=f'Certificate will expire on {not_after}. Renew urgently.', raw=raw, )) elif days < 30: findings.append(Finding( check_name=CHECK_NAME, severity='warning', title=f'{domain}: TLS certificate expires in {days} days', detail=f'Certificate expires on {not_after}. Plan renewal soon.', raw=raw, )) else: findings.append(Finding( check_name=CHECK_NAME, severity='ok', title=f'{domain}: TLS certificate valid for {days} more days', detail=f'Certificate expires on {not_after}.', raw=raw, )) except ValueError as e: findings.append(Finding( check_name=CHECK_NAME, severity='warning', title=f'{domain}: could not parse certificate expiry', detail=str(e), raw=raw, )) continue if not _cert_covers_domain(cert, domain): findings.append(Finding( check_name=CHECK_NAME, severity='warning', title=f'{domain}: certificate CN/SAN does not match domain', detail=f'The TLS certificate does not include {domain} in its names.', raw=raw, )) return findings