mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
132 lines
4.4 KiB
Python
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
|