Files
cleveragents-core/scripts/check-tls-cert.py
T
freemo 02250473ad fix(ci): restore all CI quality gates to passing on master
Fix all failing CI quality gates (lint, unit_tests, format) without
suppressing any quality enforcement.

Root causes and fixes:

1. Format: features/steps/plan_namespaced_name_tdd_steps.py had trailing
   whitespace; fixed by running ruff format.

2. Unit tests - A2A JSON-RPC 2.0 migration (commit 9c6d6915) renamed
   A2aRequest fields (operation→method, request_id→id, a2a_version→jsonrpc)
   and A2aResponse fields (status+data→result, request_id→id) but did not
   update all step files and feature files:
   - a2a_jsonrpc_wire_format_steps.py: added use_step_matcher('re') and
     reset to 'parse' at end to prevent parallel test interference
   - a2a_facade_wiring_steps.py: updated operation= to method=, .status/.data
     to .result
   - a2a_facade_steps.py: updated request_id→id, a2a_version→jsonrpc,
     A2aResponse(request_id=..., status=...) to new API
   - m6_facade_steps.py: updated all old API usage
   - devcontainer_cleanup_steps.py: updated A2aRequest(operation=...)
   - plan_prompt_command_steps.py: updated A2aRequest(operation=...)
   - wf03_plan_prompt_confidence_steps.py: updated A2aRequest(operation=...)
   - consolidated_misc.feature: updated old A2aRequest/A2aResponse scenarios

3. Unit tests - Session CLI output changed (commit 0d5d9cf0 and others):
   - 'Session Created' → 'Session created' (lowercase)
   - 'Session Details' → 'Session Summary'
   - 'Sessions (N total)' → 'Sessions'
   - session list JSON: top-level 'total' → nested 'summary.total'
   - Fixed in: session_cli.feature, session_cli_coverage_boost.feature,
     session_cli_uncovered_branches.feature, session_list_error.feature,
     tdd_session_create_persist_steps.py

4. Unit tests - Plan list output changed (commit 1a07a891):
   - 'V3 Lifecycle Plans' → 'Plans'
   - 'Lifecycle Plans' → 'Plans'
   - Name column removed (restored in source)
   - Invariants column removed (restored in source)
   - Project truncation removed (restored in source)
   - Fixed in: plan_cli_cancel_revert_coverage.feature,
     plan_lifecycle_cli_coverage.feature, plan_cli_coverage_boost_steps.py,
     plan.py (source code restored)

5. Unit tests - Plan apply command now requires ULID (commit 300a5d6d):
   - plan_cli_coverage_r3.feature: updated 'PLAN-001' to valid ULID
   - plan_cli_coverage_r3_steps.py: added --yes flag, added new step for
     no-eligible-plans path

6. Unit tests - Various source code bugs:
   - ThoughtBlock: converted from @dataclass to Pydantic BaseModel
     (architecture test requires all dataclasses to use Pydantic)
   - session.py: added DatabaseError handling to export, import, tell commands
   - database.py: fixed rollback_to() to reuse checkpoint connection for writes
   - database.py: added _get_checkpoint_conn() helper
   - check-tls-cert.py: fixed SSLCertVerificationError.reason AttributeError

7. Unit tests - Test step bugs:
   - error_recovery_coverage_boost_steps.py: fixed invalid ULID _PLAN_ID
   - session_service_coverage_steps.py: fixed 'sha256:' prefix bug in checksum
   - database_models_new_coverage_steps.py: added 'name' field to session mock
   - async_audit_recording_steps.py: fixed Settings(audit_async=False) via env var
   - coverage_threshold_config_steps.py: added --coverage-min pattern support
   - m5_acms_smoke_steps.py: updated usage hint text
   - actor_cli_yaml_steps.py: updated 'Removed actor' → 'Actor removed'
   - aimodelscredentials_steps.py: set context.imported_class in import step
   - domain_base_model.feature: added missing 'When I examine model_config' step
   - tui_first_run_steps.py: fixed module reload to restore cleveragents.tui.*
     modules after test (prevented patch interference in subsequent tests)
   - tui_first_run_steps.py: added set_search('') step for empty string
   - resource_handler_base_coverage_r3_steps.py: use _MinimalHandler instead
     of DatabaseResourceHandler for NotImplementedError tests
   - resource_handler_crud.feature: updated to test new DatabaseHandler behavior
   - resource_handler_sandbox.feature: updated to test new DatabaseHandler behavior
   - tdd_json_decode_crash_persistence.feature: fixed @tdd_bug → @tdd_issue tags

8. Parallel test interference:
   - All step files using use_step_matcher('re') now reset to 'parse' at end
     to prevent global matcher state leaking to subsequent step files
2026-04-04 20:38:16 +00:00

310 lines
10 KiB
Python

#!/usr/bin/env python3
"""TLS certificate health-check script for CleverAgents infrastructure.
Verifies that a given hostname's TLS certificate:
- Is reachable via HTTPS
- Presents a valid certificate trusted by the system CA bundle
- Includes the target hostname as a Subject Alternative Name (SAN)
- Has not expired and will not expire within the warning threshold
Usage:
python scripts/check-tls-cert.py git.dev.cleveragents.com
python scripts/check-tls-cert.py git.dev.cleveragents.com --port 443
python scripts/check-tls-cert.py git.dev.cleveragents.com --warn-days 30
python scripts/check-tls-cert.py git.dev.cleveragents.com git.cleveragents.com
Exit codes:
0 All checks passed
1 One or more checks failed (certificate error, expiry, SAN mismatch)
2 Usage error (bad arguments)
This script is used by the ops team to diagnose TLS issues and by CI to
monitor certificate health. See docs/development/ops-runbook.md for the
full certificate renewal procedure.
"""
from __future__ import annotations
import argparse
import datetime
import socket
import ssl
import sys
from collections.abc import Sequence
from dataclasses import dataclass, field
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
@dataclass
class CertCheckResult:
"""Result of a single TLS certificate check for one hostname."""
hostname: str
port: int
ok: bool = True
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
subject_cn: str = ""
sans: list[str] = field(default_factory=list)
not_after: datetime.datetime | None = None
days_remaining: int | None = None
def add_error(self, msg: str) -> None:
"""Record a fatal error and mark the result as failed."""
self.errors.append(msg)
self.ok = False
def add_warning(self, msg: str) -> None:
"""Record a non-fatal warning (does not affect ok status)."""
self.warnings.append(msg)
# ---------------------------------------------------------------------------
# Certificate inspection helpers
# ---------------------------------------------------------------------------
def _parse_not_after(cert: dict) -> datetime.datetime | None:
"""Parse the ``notAfter`` field from a DER-decoded certificate dict.
Returns a timezone-aware UTC datetime, or ``None`` if the field is
absent or cannot be parsed.
"""
not_after_str: str | None = cert.get("notAfter")
if not not_after_str:
return None
try:
# ssl module returns strings like "Apr 2 23:59:59 2026 GMT"
dt = datetime.datetime.strptime(not_after_str, "%b %d %H:%M:%S %Y %Z")
return dt.replace(tzinfo=datetime.UTC)
except ValueError:
return None
def _extract_sans(cert: dict) -> list[str]:
"""Extract Subject Alternative Names from a DER-decoded certificate dict.
Returns a list of SAN values (e.g. ``["git.dev.cleveragents.com",
"git.cleveragents.com"]``). DNS SANs are returned without the
``DNS:`` prefix.
"""
sans: list[str] = []
for san_type, san_value in cert.get("subjectAltName", ()):
if san_type == "DNS":
sans.append(san_value)
return sans
def _extract_cn(cert: dict) -> str:
"""Extract the Common Name (CN) from the certificate subject."""
for rdn in cert.get("subject", ()):
for attr_type, attr_value in rdn:
if attr_type == "commonName":
return attr_value
return ""
def _hostname_matches_san(hostname: str, sans: list[str]) -> bool:
"""Return True if *hostname* matches any SAN entry.
Supports exact matches and single-level wildcard patterns
(e.g. ``*.cleveragents.com`` matches ``git.cleveragents.com`` but
not ``a.b.cleveragents.com``).
"""
hostname_lower = hostname.lower()
for san in sans:
san_lower = san.lower()
if san_lower == hostname_lower:
return True
if san_lower.startswith("*."):
# Wildcard: *.example.com matches foo.example.com only
suffix = san_lower[1:] # ".example.com"
if (
hostname_lower.endswith(suffix)
and "." not in hostname_lower[: -len(suffix)]
):
return True
return False
# ---------------------------------------------------------------------------
# Core check function
# ---------------------------------------------------------------------------
def check_tls_certificate(
hostname: str,
port: int = 443,
warn_days: int = 30,
timeout: float = 10.0,
ssl_context: ssl.SSLContext | None = None,
) -> CertCheckResult:
"""Perform a full TLS certificate check for *hostname*:*port*.
Args:
hostname: The hostname to connect to and verify.
port: TCP port (default 443).
warn_days: Warn if the certificate expires within this many days.
timeout: Socket connection timeout in seconds.
ssl_context: Optional custom SSLContext (used in tests to inject
mock behaviour). When ``None``, a default context
with system CA verification is used.
Returns:
A :class:`CertCheckResult` describing the outcome.
"""
result = CertCheckResult(hostname=hostname, port=port)
# Build default SSL context if none provided
ctx = ssl.create_default_context() if ssl_context is None else ssl_context
# --- Attempt TLS handshake ---
try:
with (
socket.create_connection((hostname, port), timeout=timeout) as raw_sock,
ctx.wrap_socket(raw_sock, server_hostname=hostname) as tls_sock,
):
cert = tls_sock.getpeercert()
except ssl.SSLCertVerificationError as exc:
reason = getattr(exc, "reason", None) or str(exc)
result.add_error(f"TLS verification failed: {reason}")
return result
except ssl.SSLError as exc:
result.add_error(f"TLS handshake error: {exc}")
return result
except TimeoutError:
result.add_error(f"Connection timed out after {timeout}s")
return result
except OSError as exc:
result.add_error(f"Connection failed: {exc}")
return result
if cert is None:
result.add_error("No certificate returned by server")
return result
# --- Parse certificate fields ---
result.subject_cn = _extract_cn(cert)
result.sans = _extract_sans(cert)
result.not_after = _parse_not_after(cert)
# --- SAN check ---
if not _hostname_matches_san(hostname, result.sans):
result.add_error(
f"Hostname '{hostname}' not found in certificate SANs: "
f"{result.sans!r}. The certificate may be missing this hostname "
"or SNI routing is misconfigured."
)
# --- Expiry check ---
if result.not_after is not None:
now = datetime.datetime.now(tz=datetime.UTC)
delta = result.not_after - now
result.days_remaining = delta.days
if delta.total_seconds() <= 0:
expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
result.add_error(f"Certificate expired on {expiry}")
elif delta.days <= warn_days:
expiry_date = result.not_after.strftime("%Y-%m-%d")
result.add_warning(
f"Certificate expires in {delta.days} day(s) "
f"({expiry_date}). "
f"Renewal recommended (threshold: {warn_days} days)."
)
else:
result.add_warning("Could not determine certificate expiry date")
return result
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def _print_result(result: CertCheckResult) -> None:
"""Print a human-readable summary of a :class:`CertCheckResult`."""
status = "OK" if result.ok else "FAIL"
print(f"\n[{status}] {result.hostname}:{result.port}")
if result.subject_cn:
print(f" Subject CN : {result.subject_cn}")
if result.sans:
print(f" SANs : {', '.join(result.sans)}")
if result.not_after:
days = result.days_remaining
expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
print(f" Expires : {expiry} ({days} day(s) remaining)")
for err in result.errors:
print(f" ERROR : {err}")
for warn in result.warnings:
print(f" WARNING : {warn}")
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main(argv: Sequence[str] | None = None) -> int:
"""Parse arguments and run TLS checks. Returns the process exit code."""
parser = argparse.ArgumentParser(
description="Check TLS certificate validity for one or more hostnames.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"hostnames",
nargs="+",
metavar="HOSTNAME",
help="One or more hostnames to check (e.g. git.dev.cleveragents.com)",
)
parser.add_argument(
"--port",
type=int,
default=443,
help="HTTPS port to connect to (default: 443)",
)
parser.add_argument(
"--warn-days",
type=int,
default=30,
metavar="DAYS",
help="Warn if certificate expires within DAYS days (default: 30)",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
metavar="SECONDS",
help="Connection timeout in seconds (default: 10)",
)
args = parser.parse_args(argv)
all_ok = True
for hostname in args.hostnames:
result = check_tls_certificate(
hostname=hostname,
port=args.port,
warn_days=args.warn_days,
timeout=args.timeout,
)
_print_result(result)
if not result.ok:
all_ok = False
print()
if all_ok:
print("All TLS checks PASSED")
return 0
else:
print("One or more TLS checks FAILED")
return 1
if __name__ == "__main__":
sys.exit(main())