Files
2026-03-21 16:41:14 +11:00

132 lines
4.4 KiB
Python

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