feat(metrics): Add get_ssl_status() for certificate health

- Reads cert from /etc/letsencrypt/live/{domain}/cert.pem
- Thresholds: >7j ok, 3-7j warn, <3j error, ≤0 expired
- 5 unit tests with mocked certificates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-12 10:57:31 +02:00
parent c911e8192f
commit 6bd4fb3e11
2 changed files with 145 additions and 1 deletions

View File

@ -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():
"""

87
tests/test_ssl_status.py Normal file
View File

@ -0,0 +1,87 @@
"""
SecuBox-Deb :: SSL Status Tests
CyberMind https://cybermind.fr
Author: Gérald Kerma <gandalf@gk2.net>
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