Skip to content

cert_checker.py

Niveau : Intermédiaire

Vérification des certificats SSL/TLS pour plusieurs domaines.


Description

Ce script vérifie les certificats SSL/TLS : - Validité et expiration - Chaîne de certificats - Correspondance CN/SAN avec le domaine - Algorithmes de signature - Export des résultats en JSON/CSV


Prérequis

pip install cryptography requests

Script

#!/usr/bin/env python3
"""
cert_checker.py - Vérification des certificats SSL/TLS
"""

import ssl
import socket
import json
import csv
import sys
import argparse
from datetime import datetime, timezone
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
from pathlib import Path

try:
    from cryptography import x509
    from cryptography.hazmat.backends import default_backend
    from cryptography.x509.oid import NameOID, ExtensionOID
    CRYPTOGRAPHY_AVAILABLE = True
except ImportError:
    CRYPTOGRAPHY_AVAILABLE = False


@dataclass
class CertificateInfo:
    """Information sur un certificat"""
    domain: str
    port: int
    valid: bool
    issuer: str
    subject: str
    serial_number: str
    not_before: str
    not_after: str
    days_until_expiry: int
    expired: bool
    expiring_soon: bool
    san_domains: List[str]
    signature_algorithm: str
    public_key_type: str
    public_key_bits: int
    chain_length: int
    error: Optional[str] = None


class CertificateChecker:
    """Vérificateur de certificats SSL/TLS"""

    def __init__(self, timeout: int = 10, warning_days: int = 30):
        self.timeout = timeout
        self.warning_days = warning_days

    def check_certificate(self, domain: str, port: int = 443) -> CertificateInfo:
        """Check le certificat d'un domaine"""
        try:
            # Connexion SSL
            context = ssl.create_default_context()
            context.check_hostname = False
            context.verify_mode = ssl.CERT_OPTIONAL

            with socket.create_connection((domain, port), timeout=self.timeout) as sock:
                with context.wrap_socket(sock, server_hostname=domain) as ssock:
                    cert_der = ssock.getpeercert(binary_form=True)
                    cert_dict = ssock.getpeercert()

                    if CRYPTOGRAPHY_AVAILABLE and cert_der:
                        return self._parse_cert_cryptography(domain, port, cert_der, cert_dict)
                    elif cert_dict:
                        return self._parse_cert_stdlib(domain, port, cert_dict)
                    else:
                        return self._error_result(domain, port, "No certificate received")

        except socket.timeout:
            return self._error_result(domain, port, "Connection timeout")
        except socket.gaierror as e:
            return self._error_result(domain, port, f"DNS resolution failed: {e}")
        except ssl.SSLError as e:
            return self._error_result(domain, port, f"SSL error: {e}")
        except ConnectionRefusedError:
            return self._error_result(domain, port, "Connection refused")
        except Exception as e:
            return self._error_result(domain, port, f"Error: {e}")

    def _parse_cert_cryptography(self, domain: str, port: int,
                                  cert_der: bytes, cert_dict: dict) -> CertificateInfo:
        """Parse le certificat avec cryptography"""
        cert = x509.load_der_x509_certificate(cert_der, default_backend())

        # Dates
        now = datetime.now(timezone.utc)
        not_before = cert.not_valid_before_utc
        not_after = cert.not_valid_after_utc
        days_until_expiry = (not_after - now).days

        # Subject et Issuer
        subject = self._get_cn(cert.subject)
        issuer = self._get_cn(cert.issuer)

        # SAN (Subject Alternative Names)
        san_domains = []
        try:
            san_ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
            san_domains = [name.value for name in san_ext.value
                          if isinstance(name, x509.DNSName)]
        except x509.ExtensionNotFound:
            pass

        # Clé publique
        public_key = cert.public_key()
        key_type = type(public_key).__name__.replace('_', ' ')
        key_bits = public_key.key_size if hasattr(public_key, 'key_size') else 0

        return CertificateInfo(
            domain=domain,
            port=port,
            valid=True,
            issuer=issuer,
            subject=subject,
            serial_number=format(cert.serial_number, 'X'),
            not_before=not_before.strftime('%Y-%m-%d %H:%M:%S UTC'),
            not_after=not_after.strftime('%Y-%m-%d %H:%M:%S UTC'),
            days_until_expiry=days_until_expiry,
            expired=days_until_expiry < 0,
            expiring_soon=0 <= days_until_expiry <= self.warning_days,
            san_domains=san_domains,
            signature_algorithm=cert.signature_algorithm_oid._name,
            public_key_type=key_type,
            public_key_bits=key_bits,
            chain_length=len(cert_dict.get('chain', [])) if cert_dict else 1
        )

    def _parse_cert_stdlib(self, domain: str, port: int, cert_dict: dict) -> CertificateInfo:
        """Parse le certificat avec la stdlib"""
        # Dates
        not_before = datetime.strptime(cert_dict['notBefore'], '%b %d %H:%M:%S %Y %Z')
        not_after = datetime.strptime(cert_dict['notAfter'], '%b %d %H:%M:%S %Y %Z')
        now = datetime.utcnow()
        days_until_expiry = (not_after - now).days

        # Subject et Issuer
        subject = dict(x[0] for x in cert_dict.get('subject', ()))
        issuer = dict(x[0] for x in cert_dict.get('issuer', ()))

        # SAN
        san_domains = []
        for san_type, san_value in cert_dict.get('subjectAltName', ()):
            if san_type == 'DNS':
                san_domains.append(san_value)

        return CertificateInfo(
            domain=domain,
            port=port,
            valid=True,
            issuer=issuer.get('organizationName', issuer.get('commonName', 'Unknown')),
            subject=subject.get('commonName', 'Unknown'),
            serial_number=str(cert_dict.get('serialNumber', '')),
            not_before=not_before.strftime('%Y-%m-%d %H:%M:%S UTC'),
            not_after=not_after.strftime('%Y-%m-%d %H:%M:%S UTC'),
            days_until_expiry=days_until_expiry,
            expired=days_until_expiry < 0,
            expiring_soon=0 <= days_until_expiry <= self.warning_days,
            san_domains=san_domains,
            signature_algorithm='Unknown',
            public_key_type='Unknown',
            public_key_bits=0,
            chain_length=1
        )

    def _get_cn(self, name: x509.Name) -> str:
        """Extrait le Common Name"""
        try:
            cn = name.get_attributes_for_oid(NameOID.COMMON_NAME)
            if cn:
                return cn[0].value
        except Exception:
            pass
        return str(name)

    def _error_result(self, domain: str, port: int, error: str) -> CertificateInfo:
        """Retourne un résultat d'erreur"""
        return CertificateInfo(
            domain=domain,
            port=port,
            valid=False,
            issuer='',
            subject='',
            serial_number='',
            not_before='',
            not_after='',
            days_until_expiry=-1,
            expired=True,
            expiring_soon=False,
            san_domains=[],
            signature_algorithm='',
            public_key_type='',
            public_key_bits=0,
            chain_length=0,
            error=error
        )


class OutputFormatter:
    """Formateur de sortie"""

    @staticmethod
    def print_console(results: List[CertificateInfo], verbose: bool = False):
        """Display les résultats en console"""
        # Colors ANSI
        RED = '\033[91m'
        GREEN = '\033[92m'
        YELLOW = '\033[93m'
        CYAN = '\033[96m'
        GRAY = '\033[90m'
        RESET = '\033[0m'
        BOLD = '\033[1m'

        print(f"\n{CYAN}{'='*70}{RESET}")
        print(f"{GREEN}  SSL/TLS CERTIFICATE CHECK{RESET}")
        print(f"{CYAN}{'='*70}{RESET}")
        print(f"  Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"  Domains checked: {len(results)}")
        print(f"{CYAN}{'-'*70}{RESET}\n")

        for cert in results:
            # Status icon
            if cert.error:
                status = f"{RED}[FAIL]{RESET}"
            elif cert.expired:
                status = f"{RED}[EXPIRED]{RESET}"
            elif cert.expiring_soon:
                status = f"{YELLOW}[WARNING]{RESET}"
            else:
                status = f"{GREEN}[OK]{RESET}"

            print(f"{status} {BOLD}{cert.domain}:{cert.port}{RESET}")

            if cert.error:
                print(f"    {RED}Error: {cert.error}{RESET}")
                continue

            # Expiration
            if cert.expired:
                print(f"    {RED}EXPIRED {abs(cert.days_until_expiry)} days ago{RESET}")
            elif cert.expiring_soon:
                print(f"    {YELLOW}Expires in {cert.days_until_expiry} days{RESET}")
            else:
                print(f"    {GRAY}Expires in {cert.days_until_expiry} days ({cert.not_after}){RESET}")

            if verbose:
                print(f"    {GRAY}Subject: {cert.subject}{RESET}")
                print(f"    {GRAY}Issuer: {cert.issuer}{RESET}")
                print(f"    {GRAY}Serial: {cert.serial_number[:20]}...{RESET}")
                print(f"    {GRAY}Algorithm: {cert.signature_algorithm}{RESET}")
                print(f"    {GRAY}Key: {cert.public_key_type} {cert.public_key_bits} bits{RESET}")
                if cert.san_domains:
                    print(f"    {GRAY}SAN: {', '.join(cert.san_domains[:5])}"
                          f"{'...' if len(cert.san_domains) > 5 else ''}{RESET}")
            print()

        # Résumé
        ok = sum(1 for c in results if c.valid and not c.expired and not c.expiring_soon)
        warning = sum(1 for c in results if c.expiring_soon)
        failed = sum(1 for c in results if c.error or c.expired)

        print(f"{CYAN}{'='*70}{RESET}")
        print(f"  {GREEN}Valid: {ok}{RESET}  {YELLOW}Expiring soon: {warning}{RESET}  {RED}Failed/Expired: {failed}{RESET}")
        print(f"{CYAN}{'='*70}{RESET}\n")

    @staticmethod
    def export_json(results: List[CertificateInfo], filepath: str):
        """Exporte en JSON"""
        data = {
            'timestamp': datetime.now().isoformat(),
            'certificates': [asdict(cert) for cert in results]
        }
        with open(filepath, 'w') as f:
            json.dump(data, f, indent=2)
        print(f"Results exported to {filepath}")

    @staticmethod
    def export_csv(results: List[CertificateInfo], filepath: str):
        """Exporte en CSV"""
        if not results:
            return

        fieldnames = list(asdict(results[0]).keys())
        with open(filepath, 'w', newline='') as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for cert in results:
                row = asdict(cert)
                row['san_domains'] = ';'.join(row['san_domains'])
                writer.writerow(row)
        print(f"Results exported to {filepath}")


def main():
    parser = argparse.ArgumentParser(
        description='Check SSL/TLS certificates for domains'
    )
    parser.add_argument(
        'domains',
        nargs='*',
        help='Domains to check (format: domain or domain:port)'
    )
    parser.add_argument(
        '-f', '--file',
        help='File containing domains (one per line)'
    )
    parser.add_argument(
        '-w', '--warning-days',
        type=int,
        default=30,
        help='Days before expiry to trigger warning (default: 30)'
    )
    parser.add_argument(
        '-t', '--timeout',
        type=int,
        default=10,
        help='Connection timeout in seconds (default: 10)'
    )
    parser.add_argument(
        '-v', '--verbose',
        action='store_true',
        help='Verbose output'
    )
    parser.add_argument(
        '--json',
        metavar='FILE',
        help='Export results to JSON file'
    )
    parser.add_argument(
        '--csv',
        metavar='FILE',
        help='Export results to CSV file'
    )
    parser.add_argument(
        '-q', '--quiet',
        action='store_true',
        help='Only output errors and warnings'
    )

    args = parser.parse_args()

    # Collecter les domaines
    domains = []

    for d in args.domains:
        if ':' in d:
            domain, port = d.rsplit(':', 1)
            domains.append((domain, int(port)))
        else:
            domains.append((d, 443))

    if args.file:
        with open(args.file) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith('#'):
                    if ':' in line:
                        domain, port = line.rsplit(':', 1)
                        domains.append((domain, int(port)))
                    else:
                        domains.append((line, 443))

    if not domains:
        parser.print_help()
        sys.exit(1)

    # Vérifier les certificats
    checker = CertificateChecker(
        timeout=args.timeout,
        warning_days=args.warning_days
    )

    results = []
    for domain, port in domains:
        result = checker.check_certificate(domain, port)
        results.append(result)

    # Affichage
    if not args.quiet:
        OutputFormatter.print_console(results, args.verbose)
    else:
        # Mode quiet: seulement les problèmes
        for cert in results:
            if cert.error or cert.expired or cert.expiring_soon:
                if cert.error:
                    print(f"FAIL {cert.domain}:{cert.port} - {cert.error}")
                elif cert.expired:
                    print(f"EXPIRED {cert.domain}:{cert.port} - {abs(cert.days_until_expiry)} days ago")
                else:
                    print(f"WARNING {cert.domain}:{cert.port} - expires in {cert.days_until_expiry} days")

    # Export
    if args.json:
        OutputFormatter.export_json(results, args.json)
    if args.csv:
        OutputFormatter.export_csv(results, args.csv)

    # Code de sortie
    if any(c.error or c.expired for c in results):
        sys.exit(2)
    elif any(c.expiring_soon for c in results):
        sys.exit(1)
    sys.exit(0)


if __name__ == '__main__':
    main()

Utilisation

# Vérifier un domaine
python cert_checker.py example.com

# Plusieurs domaines
python cert_checker.py google.com github.com:443 smtp.gmail.com:465

# Depuis un fichier
python cert_checker.py -f domains.txt

# Mode verbeux
python cert_checker.py -v example.com

# Export JSON
python cert_checker.py --json results.json example.com

# Mode monitoring (quiet)
python cert_checker.py -q -w 60 example.com

Fichier domains.txt

# Domaines à vérifier
google.com
github.com
# SMTP avec port
smtp.gmail.com:465

Exemple de Sortie

======================================================================
  SSL/TLS CERTIFICATE CHECK
======================================================================
  Date: 2025-12-01 16:45:23
  Domains checked: 5
----------------------------------------------------------------------

[OK] google.com:443
    Expires in 67 days (2026-02-06 12:00:00 UTC)

[OK] github.com:443
    Expires in 245 days (2026-08-03 23:59:59 UTC)

[WARNING] api.internal.example.com:443
    Expires in 21 days
    Subject: *.internal.example.com
    Issuer: Let's Encrypt
    Serial: 04A3B2C1D4E5F6...
    Algorithm: sha256WithRSAEncryption
    Key: RSAPublicKey 2048 bits

[EXPIRED] legacy.example.com:443
    EXPIRED 15 days ago

[FAIL] smtp.offline.local:465
    Error: Connection timeout

======================================================================
  Valid: 2  Expiring soon: 1  Failed/Expired: 2
======================================================================

Voir Aussi