secubox-deb/docs/superpowers/plans/2026-05-12-ssl-health-banner.md
CyberMind-FR a99773418b docs(plan): SSL certificate health in Health Banner implementation
5 tasks, 25 steps, TDD approach
Reference: CM-SSL-BANNER-2026-05-12

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 10:51:43 +02:00

19 KiB

SSL Certificate Health in Health Banner — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Display SSL certificate expiration status for the current domain in the Health Banner sidebar.

Architecture: Backend (secubox-metrics/api/main.py) extracts domain from Host header, reads cert from /etc/letsencrypt/live/{domain}/cert.pem, returns ssl object in health summary. Frontend (health-banner.js) renders SSL line after score section.

Tech Stack: Python 3.11, cryptography, FastAPI, vanilla JS


File Structure

File Responsibility
packages/secubox-metrics/api/main.py Add get_ssl_status() function, enrich /api/v1/metrics/health/summary
packages/secubox-hub/www/shared/health-banner.js Add SSL display after score section
tests/test_ssl_status.py Unit tests for SSL status logic

Task 1: Add SSL Status Function to Metrics API

Files:

  • Modify: packages/secubox-metrics/api/main.py:1-50 (imports)

  • Modify: packages/secubox-metrics/api/main.py:430-620 (health summary section)

  • Create: tests/test_ssl_status.py

  • Step 1: Write the failing test

Create tests/test_ssl_status.py:

"""
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

# We'll import the function after creating it
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "packages/secubox-metrics/api"))


def test_ssl_status_ok():
    """Cert with 45 days remaining returns status ok."""
    from main import get_ssl_status

    # Mock cert with 45 days remaining
    mock_cert = MagicMock()
    mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=45)

    with patch('main.Path.exists', return_value=True), \
         patch('main.Path.read_bytes', return_value=b'cert data'), \
         patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):

        result = get_ssl_status("example.com")

        assert result["status"] == "ok"
        assert result["days_remaining"] == 45
        assert result["domain"] == "example.com"


def test_ssl_status_warn():
    """Cert with 5 days remaining returns status warn."""
    from main import get_ssl_status

    mock_cert = MagicMock()
    mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=5)

    with patch('main.Path.exists', return_value=True), \
         patch('main.Path.read_bytes', return_value=b'cert data'), \
         patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):

        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."""
    from main import get_ssl_status

    mock_cert = MagicMock()
    mock_cert.not_valid_after_utc = datetime.now(timezone.utc) + timedelta(days=2)

    with patch('main.Path.exists', return_value=True), \
         patch('main.Path.read_bytes', return_value=b'cert data'), \
         patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):

        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."""
    from main import get_ssl_status

    mock_cert = MagicMock()
    mock_cert.not_valid_after_utc = datetime.now(timezone.utc) - timedelta(days=3)

    with patch('main.Path.exists', return_value=True), \
         patch('main.Path.read_bytes', return_value=b'cert data'), \
         patch('main.x509.load_pem_x509_certificate', return_value=mock_cert):

        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."""
    from main import get_ssl_status

    with patch.object(Path, 'exists', return_value=False):
        result = get_ssl_status("nonexistent.com")

        assert result["status"] == "unknown"
        assert result["days_remaining"] is None
  • Step 2: Run test to verify it fails

Run: cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v

Expected: FAIL with ImportError: cannot import name 'get_ssl_status' from 'main'

  • Step 3: Add imports to main.py

At the top of packages/secubox-metrics/api/main.py, after existing imports (around line 17), add:

from cryptography import x509
from cryptography.hazmat.backends import default_backend
  • Step 4: Add get_ssl_status function

In packages/secubox-metrics/api/main.py, after the build_health_summary() function (around line 610), add:

# ═══════════════════════════════════════════════════════════════════════════════
# 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

    Returns:
        dict with domain, days_remaining, status, expiry
    """
    if not domain:
        return {
            "domain": None,
            "days_remaining": None,
            "status": "unknown",
            "expiry": None
        }

    # Certificate search paths (in order of priority)
    # 1. Direct domain match
    # 2. Wildcard (parent domain)
    # 3. HAProxy combined cert
    # 4. Nginx cert
    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

        # Thresholds: >7j ok, 3-7j warn, <3j error, ≤0 expired
        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)
        }
  • Step 5: Run tests to verify they pass

Run: cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v

Expected: All 5 tests PASS

  • Step 6: Commit
git add packages/secubox-metrics/api/main.py tests/test_ssl_status.py
git commit -m "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>"

Task 2: Enrich Health Summary Endpoint with SSL Status

Files:

  • Modify: packages/secubox-metrics/api/main.py:613-620 (get_health_summary endpoint)

  • Step 1: Write the failing test

Add to tests/test_ssl_status.py:

def test_health_summary_includes_ssl():
    """Health summary endpoint includes ssl field."""
    from main import build_health_summary
    from fastapi import Request
    from unittest.mock import AsyncMock

    # Mock the request with Host header
    mock_request = MagicMock()
    mock_request.headers = {"host": "admin.gk2.secubox.in"}

    # We can't easily test the full endpoint, but we can verify
    # build_health_summary returns the expected structure
    result = build_health_summary()

    # The function should have these keys
    assert "score" in result
    assert "modules" in result
    # SSL will be added in the endpoint, not build_health_summary
  • Step 2: Modify the endpoint to include SSL

Replace the get_health_summary endpoint in packages/secubox-metrics/api/main.py (around line 613-620):

@app.get("/api/v1/metrics/health/summary")
async def get_health_summary(request: Request):
    """
    Health summary for the global health banner.
    Returns aggregated health score and module statuses.
    No auth required for banner display.
    """
    summary = build_health_summary()

    # Add SSL certificate status for the current domain
    host = request.headers.get("host", "").split(":")[0]  # Remove port if present
    if host:
        summary["ssl"] = get_ssl_status(host)
    else:
        summary["ssl"] = None

    return summary
  • Step 3: Add Request import

At the top of the file, update the FastAPI import (around line 17):

from fastapi import FastAPI, Depends, HTTPException, Request
  • Step 4: Run tests to verify

Run: cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb && python -m pytest tests/test_ssl_status.py -v

Expected: All tests PASS

  • Step 5: Commit
git add packages/secubox-metrics/api/main.py tests/test_ssl_status.py
git commit -m "feat(metrics): Add SSL status to health summary endpoint

- Extract domain from Host header
- Call get_ssl_status() and include in response
- Frontend can now display certificate health

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 3: Add SSL Display to Health Banner Frontend

Files:

  • Modify: packages/secubox-hub/www/shared/health-banner.js

  • Step 1: Add SSL CSS styles

In packages/secubox-hub/www/shared/health-banner.js, find the style block (around line 269-590) and add after the .hb-pct style (around line 390):

            /* SSL Certificate Status */
            .hb-ssl {
                display: flex;
                align-items: center;
                gap: 6px;
                padding: 8px 12px;
                background: rgba(255,255,255,0.03);
                border-radius: 6px;
                border: 1px solid rgba(255,255,255,0.08);
                font-size: 11px;
            }
            .hb-ssl-icon {
                font-size: 14px;
            }
            .hb-ssl-days {
                font-weight: 600;
            }
            .hb-ssl.ssl-ok {
                border-color: rgba(0, 255, 65, 0.3);
            }
            .hb-ssl.ssl-ok .hb-ssl-days {
                color: #00ff41;
            }
            .hb-ssl.ssl-warn {
                border-color: rgba(255, 193, 7, 0.3);
            }
            .hb-ssl.ssl-warn .hb-ssl-days {
                color: #ffc107;
            }
            .hb-ssl.ssl-error {
                border-color: rgba(230, 57, 70, 0.3);
            }
            .hb-ssl.ssl-error .hb-ssl-days {
                color: #e63946;
            }
            .hb-ssl.ssl-expired {
                border-color: rgba(230, 57, 70, 0.5);
                background: rgba(230, 57, 70, 0.1);
                animation: ssl-blink 1s infinite;
            }
            .hb-ssl.ssl-expired .hb-ssl-days {
                color: #e63946;
            }
            @keyframes ssl-blink {
                50% { opacity: 0.6; }
            }
            .hb-ssl.ssl-unknown {
                border-color: rgba(107, 107, 122, 0.3);
            }
            .hb-ssl.ssl-unknown .hb-ssl-days {
                color: #6b6b7a;
            }
  • Step 2: Add SSL rendering function

In the same file, after the getScoreVibe function (around line 226), add:

    function renderSslStatus(ssl) {
        if (!ssl) return '';

        const statusConfig = {
            ok: { emoji: '🔒', label: 'Certificate OK' },
            warn: { emoji: '🔐', label: 'Certificate expiring soon' },
            error: { emoji: '🔓', label: 'Certificate critical' },
            expired: { emoji: '🔓', label: 'Certificate EXPIRED' },
            unknown: { emoji: '❓', label: 'Certificate unknown' }
        };

        const config = statusConfig[ssl.status] || statusConfig.unknown;
        const days = ssl.days_remaining;
        const daysText = ssl.status === 'expired' ? 'EXPIRÉ' :
                         days !== null ? `${days}j` : '--';

        return `
            <div class="hb-ssl ssl-${ssl.status}" title="${config.label}${ssl.domain || 'unknown'}">
                <span class="hb-ssl-icon">${config.emoji}</span>
                <span class="hb-ssl-days">${daysText}</span>
            </div>
        `;
    }
  • Step 3: Add SSL section to banner HTML structure

Find the createBannerElement function (around line 228) and add an SSL placeholder div after the score div:

        banner.innerHTML = `
            <div class="hb-content">
                <div class="hb-score">
                    <span class="hb-icon">💖</span>
                    <div class="hb-score-info">
                        <span class="hb-label">VIBING</span>
                        <div class="hb-bar"><div class="hb-fill"></div></div>
                    </div>
                    <span class="hb-pct">--</span>
                </div>
                <div class="hb-ssl-container"></div>
                <div class="hb-modules"></div>
                <div class="hb-alerts"></div>
                <div class="hb-sparkle">✨</div>
                <div class="hb-details">
                    <div class="hb-stats-grid"></div>
                </div>
            </div>
        `;
  • Step 4: Update the render function to display SSL

Find the updateBanner function (search for function updateBanner) and add SSL rendering after the score update:

        // Update SSL status (after score section)
        const sslContainer = banner.querySelector('.hb-ssl-container');
        if (sslContainer && data.ssl) {
            sslContainer.innerHTML = renderSslStatus(data.ssl);
        }
  • Step 5: Update version number

At the top of the file (around line 18), update:

    const VERSION = '1.2.0';
  • Step 6: Test manually in browser

Open: https://admin.gk2.secubox.in/ Expected: Health banner sidebar shows SSL status line (🔒 XXj) after the score

  • Step 7: Commit
git add packages/secubox-hub/www/shared/health-banner.js
git commit -m "feat(health-banner): Add SSL certificate status display v1.2.0

- Show cert expiry days after score section
- Color-coded: green (>7j), yellow (3-7j), red (<3j)
- Blinking animation for expired certs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 4: Copy Updated Files to Debian Package

Files:

  • Sync: packages/secubox-metrics/debian/secubox-metrics/usr/lib/secubox/metrics/api/main.py

  • Sync: packages/secubox-hub/debian/secubox-hub/usr/share/secubox/www/shared/health-banner.js

  • Step 1: Copy metrics API

cp packages/secubox-metrics/api/main.py \
   packages/secubox-metrics/debian/secubox-metrics/usr/lib/secubox/metrics/api/main.py
  • Step 2: Copy health banner JS
cp packages/secubox-hub/www/shared/health-banner.js \
   packages/secubox-hub/debian/secubox-hub/usr/share/secubox/www/shared/health-banner.js
  • Step 3: Commit
git add packages/secubox-metrics/debian packages/secubox-hub/debian
git commit -m "chore: Sync debian package files for SSL health feature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 5: Deploy and Validate

Files: None (deployment task)

  • Step 1: Deploy to MOCHAbin
# Deploy metrics API
bash scripts/deploy.sh secubox-metrics root@192.168.1.200

# Deploy hub (health-banner.js)
bash scripts/deploy.sh secubox-hub root@192.168.1.200
  • Step 2: Restart metrics service
ssh root@192.168.1.200 'systemctl restart secubox-metrics'
  • Step 3: Validate API response
curl -s https://admin.gk2.secubox.in/api/v1/metrics/health/summary | jq '.ssl'

Expected output:

{
  "domain": "admin.gk2.secubox.in",
  "days_remaining": 45,
  "status": "ok",
  "expiry": "2026-06-26T12:00:00+00:00"
}
  • Step 4: Validate frontend display
  1. Open: https://admin.gk2.secubox.in/
  2. Click the health banner trigger (◀) on the right edge
  3. Verify SSL line shows: 🔒 XXj (green)
  • Step 5: Update HISTORY.md

Add session entry to .claude/HISTORY.md:

### Session 153 — SSL Certificate Health in Health Banner

**Goal:** Display SSL certificate expiration status in the Health Banner sidebar.

**Changes:**
- `packages/secubox-metrics/api/main.py`: Added `get_ssl_status()` function
- `packages/secubox-hub/www/shared/health-banner.js`: Added SSL display (v1.2.0)
- `tests/test_ssl_status.py`: 5 unit tests

**Thresholds:**
- 🔒 >7j (green)
- 🔐 3-7j (yellow)
- 🔓 <3j (red)
- 🔓 EXPIRÉ (red blink)

**Reference:** CM-SSL-BANNER-2026-05-12
  • Step 6: Final commit
git add .claude/HISTORY.md
git commit -m "docs: Add Session 153 - SSL certificate health in Health Banner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Summary

Task Description Files
1 Add get_ssl_status() function main.py, test_ssl_status.py
2 Enrich health summary endpoint main.py
3 Add SSL display to frontend health-banner.js
4 Sync debian package files debian/ dirs
5 Deploy and validate deployment

Total estimated steps: 25 Tests: 5 unit tests