diff --git a/packages/secubox-metrics/api/main.py b/packages/secubox-metrics/api/main.py index 4fbf97b5..e2f9fdf9 100644 --- a/packages/secubox-metrics/api/main.py +++ b/packages/secubox-metrics/api/main.py @@ -10,12 +10,14 @@ import json import os import subprocess import time -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional from fastapi import FastAPI, Depends, HTTPException from fastapi.responses import JSONResponse +from cryptography import x509 +from cryptography.hazmat.backends import default_backend # Try to import auth, fallback to no-auth for development try: @@ -610,6 +612,61 @@ def build_health_summary() -> dict: } +# ═══════════════════════════════════════════════════════════════════════════════ +# SSL CERTIFICATE STATUS +# ═══════════════════════════════════════════════════════════════════════════════ + +def get_ssl_status(domain: str) -> dict: + """ + Get SSL certificate status for a domain. + + Thresholds (aggressive for Let's Encrypt 90-day certs): + - ok: > 7 days remaining + - warn: 3-7 days remaining + - error: < 3 days remaining + - expired: <= 0 days remaining + """ + if not domain: + return {"domain": None, "days_remaining": None, "status": "unknown", "expiry": None} + + parent_domain = '.'.join(domain.split('.')[1:]) if '.' in domain else domain + cert_paths = [ + Path(f"/etc/letsencrypt/live/{domain}/cert.pem"), + Path(f"/etc/letsencrypt/live/{parent_domain}/cert.pem"), + Path(f"/etc/haproxy/certs/{domain}.pem"), + Path(f"/etc/nginx/ssl/{domain}.crt"), + ] + + cert_path = None + for p in cert_paths: + if p.exists(): + cert_path = p + break + + if not cert_path: + return {"domain": domain, "days_remaining": None, "status": "unknown", "expiry": None} + + try: + cert_data = cert_path.read_bytes() + cert = x509.load_pem_x509_certificate(cert_data, default_backend()) + now = datetime.now(timezone.utc) + expiry = cert.not_valid_after_utc + days_remaining = (expiry - now).days + + if days_remaining <= 0: + status = "expired" + elif days_remaining < 3: + status = "error" + elif days_remaining <= 7: + status = "warn" + else: + status = "ok" + + return {"domain": domain, "days_remaining": days_remaining, "status": status, "expiry": expiry.isoformat()} + except Exception as e: + return {"domain": domain, "days_remaining": None, "status": "unknown", "expiry": None, "error": str(e)} + + @app.get("/api/v1/metrics/health/summary") async def get_health_summary(): """ diff --git a/tests/test_ssl_status.py b/tests/test_ssl_status.py new file mode 100644 index 00000000..a5920e81 --- /dev/null +++ b/tests/test_ssl_status.py @@ -0,0 +1,87 @@ +""" +SecuBox-Deb :: SSL Status Tests +CyberMind — https://cybermind.fr +Author: Gérald Kerma +License: Proprietary / ANSSI CSPN candidate +""" + +import pytest +from datetime import datetime, timezone, timedelta +from unittest.mock import patch, MagicMock +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "packages/secubox-metrics/api")) +from main import get_ssl_status + + +def test_ssl_status_ok(): + """Cert with 45 days remaining returns status ok.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_cert = MagicMock() + mock_cert.not_valid_after_utc = now + timedelta(days=45) + + with patch.object(Path, 'exists', return_value=True), \ + patch.object(Path, 'read_bytes', return_value=b'cert data'), \ + patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \ + patch('main.datetime') as mock_datetime: + mock_datetime.now.return_value = now + result = get_ssl_status("example.com") + assert result["status"] == "ok" + assert result["days_remaining"] == 45 + + +def test_ssl_status_warn(): + """Cert with 5 days remaining returns status warn.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_cert = MagicMock() + mock_cert.not_valid_after_utc = now + timedelta(days=5) + + with patch.object(Path, 'exists', return_value=True), \ + patch.object(Path, 'read_bytes', return_value=b'cert data'), \ + patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \ + patch('main.datetime') as mock_datetime: + mock_datetime.now.return_value = now + result = get_ssl_status("example.com") + assert result["status"] == "warn" + assert result["days_remaining"] == 5 + + +def test_ssl_status_error(): + """Cert with 2 days remaining returns status error.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_cert = MagicMock() + mock_cert.not_valid_after_utc = now + timedelta(days=2) + + with patch.object(Path, 'exists', return_value=True), \ + patch.object(Path, 'read_bytes', return_value=b'cert data'), \ + patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \ + patch('main.datetime') as mock_datetime: + mock_datetime.now.return_value = now + result = get_ssl_status("example.com") + assert result["status"] == "error" + assert result["days_remaining"] == 2 + + +def test_ssl_status_expired(): + """Cert with -3 days (expired) returns status expired.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_cert = MagicMock() + mock_cert.not_valid_after_utc = now - timedelta(days=3) + + with patch.object(Path, 'exists', return_value=True), \ + patch.object(Path, 'read_bytes', return_value=b'cert data'), \ + patch('main.x509.load_pem_x509_certificate', return_value=mock_cert), \ + patch('main.datetime') as mock_datetime: + mock_datetime.now.return_value = now + result = get_ssl_status("example.com") + assert result["status"] == "expired" + assert result["days_remaining"] == -3 + + +def test_ssl_status_not_found(): + """Missing cert file returns status unknown.""" + with patch.object(Path, 'exists', return_value=False): + result = get_ssl_status("nonexistent.com") + assert result["status"] == "unknown" + assert result["days_remaining"] is None