From 76f1f36d97869ee5f588cd427ee7052fd9e4d4a5 Mon Sep 17 00:00:00 2001 From: "Jelle W. Jansen" Date: Thu, 21 May 2026 19:20:49 +0200 Subject: [PATCH] Add Zabbix SSL checker --- .gitignore | 7 + README.md | 230 +++++++ config/ssl_targets.json.example | 26 + scripts/ssl_check.py | 641 ++++++++++++++++++ scripts/ssl_discovery.py | 70 ++ tests/test_config_validation.py | 107 +++ tests/test_ssl_check_basic.py | 62 ++ zabbix/MANUAL_TEMPLATE_STEPS.md | 94 +++ ...template_ssl_checker_relaxed_zabbix_7.yaml | 592 ++++++++++++++++ 9 files changed, 1829 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/ssl_targets.json.example create mode 100755 scripts/ssl_check.py create mode 100755 scripts/ssl_discovery.py create mode 100644 tests/test_config_validation.py create mode 100644 tests/test_ssl_check_basic.py create mode 100644 zabbix/MANUAL_TEMPLATE_STEPS.md create mode 100644 zabbix/template_ssl_checker_relaxed_zabbix_7.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f8b3b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +venv/ +.venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..03c895e --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Zabbix SSL Checker + +Centrale SSL/TLS- en HTTPS-monitoring voor homelab- en kleine infra-omgevingen met Zabbix 7.x. + +## Architectuur + +Deze checker gebruikt één centrale Zabbix host, bijvoorbeeld **Zabbix SSL Checker**. Op die host draaien external scripts vanuit `/usr/lib/zabbix/externalscripts/`. + +De Zabbix-template gebruikt Low-Level Discovery om targets uit `/etc/zabbix/ssl_targets.json` te ontdekken. Per ontdekt target draait één master item: + +```text +ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"] +``` + +Dat master item geeft één JSON-object terug met TLS-, certificaat- en HTTP-informatie. Alle andere items zijn dependent items met JSONPath preprocessing. + +Er is dus géén Zabbix agent-interface nodig op je losse homelab-hosts. Dat voorkomt rode **ZBX agent availability** op systemen waar je helemaal geen agent wilt of kunt installeren. Zabbix bewaakt alleen de centrale checker-host; de echte HTTPS-targets worden agentloos vanaf die plek gecontroleerd. + +## Bestanden + +```text +zabbix-ssl-checker/ + README.md + config/ + ssl_targets.json.example + scripts/ + ssl_discovery.py + ssl_check.py + zabbix/ + template_ssl_checker_relaxed_zabbix_7.yaml + MANUAL_TEMPLATE_STEPS.md + tests/ + test_config_validation.py + test_ssl_check_basic.py +``` + +## Requirements + +- Python 3.11+ +- Zabbix server of proxy met external scripts enabled +- Geen verplichte Python dependencies buiten de standaardbibliotheek + +Optioneel: + +- `cryptography` voor betere parsing van SAN, issuer, subject, public key type, key size en signature algorithm. + +Zonder `cryptography` blijft de checker werken. Velden die niet betrouwbaar te bepalen zijn, worden `null` en er komt een melding in `warnings`. + +## Installatie + +Voer dit uit op de Zabbix server, proxy of checker-host: + +```bash +sudo install -o zabbix -g zabbix -m 0755 scripts/ssl_discovery.py /usr/lib/zabbix/externalscripts/ +sudo install -o zabbix -g zabbix -m 0755 scripts/ssl_check.py /usr/lib/zabbix/externalscripts/ +sudo install -o zabbix -g zabbix -m 0640 config/ssl_targets.json.example /etc/zabbix/ssl_targets.json +``` + +Controleer dat de Zabbix user de config kan lezen: + +```bash +sudo chown zabbix:zabbix /etc/zabbix/ssl_targets.json +sudo chmod 0640 /etc/zabbix/ssl_targets.json +``` + +Pas daarna `/etc/zabbix/ssl_targets.json` aan voor je eigen targets. + +## Configuratie + +Voorbeeld: + +```json +[ + { + "name": "Blockje Home", + "host": "home.blockje.nl", + "port": 443, + "owner": "blockje", + "profile": "relaxed", + "expected_issuer_contains": "Let's Encrypt", + "expected_hostname": "home.blockje.nl", + "http_check": true, + "expected_http_status": [200, 301, 302, 401, 403], + "timeout": 10 + } +] +``` + +Verplichte velden: + +- `name` +- `host` +- `port` +- `owner` +- `profile` + +Toegestane profiles: + +- `relaxed` +- `serious` +- `internal` +- `external` + +Als `expected_hostname` ontbreekt, gebruikt de checker automatisch `host`. + +Dubbele targets op dezelfde `host:port` worden één keer meegenomen in discovery. + +## Testcommando's + +Als gebruiker `zabbix`: + +```bash +sudo -u zabbix /usr/lib/zabbix/externalscripts/ssl_discovery.py --config /etc/zabbix/ssl_targets.json +sudo -u zabbix /usr/lib/zabbix/externalscripts/ssl_check.py --config /etc/zabbix/ssl_targets.json --host home.blockje.nl --port 443 +``` + +Zonder config kan ook: + +```bash +./scripts/ssl_check.py --host home.blockje.nl --port 443 --expected-hostname home.blockje.nl --http-check +``` + +De scripts schrijven meetdata naar stdout en fouten/debugmeldingen naar stderr. `ssl_check.py` geeft bij TLS- of bereikbaarheidsproblemen nog steeds JSON terug, zodat dependent items stabiel blijven. + +## Zabbix import + +1. Ga naar **Data collection -> Templates -> Import**. +2. Importeer `zabbix/template_ssl_checker_relaxed_zabbix_7.yaml`. +3. Maak een nieuwe host: + - Host name: `Zabbix SSL Checker` + - Interfaces: geen externe homelab-agent interfaces nodig + - Link template: `Template SSL Checker Relaxed` + - Macro `{$SSL_CONFIG}`: `/etc/zabbix/ssl_targets.json` +4. Laat discovery draaien of klik op **Execute now** bij de discovery rule. + +Als import faalt door YAML-verschillen tussen Zabbix 7.x minor releases, gebruik dan `zabbix/MANUAL_TEMPLATE_STEPS.md`. + +## Wat wordt gecontroleerd + +TLS/certificaat: + +- bereikbaarheid +- hostname match +- chain validity via de default trust store +- self-signed detectie waar mogelijk +- geldigheid nu, not before, not after en days left +- issuer CN/org, subject CN, SAN names +- SHA256 fingerprint +- negotiated TLS version +- TLS 1.0, 1.1, 1.2 en 1.3 support waar Python/OpenSSL dat toestaat + +HTTP: + +- HTTPS request met maximaal 5 redirects +- response time +- final URL +- statuscode en verwachte status +- Server header +- HSTS +- X-Content-Type-Options +- X-Frame-Options +- Content-Security-Policy +- Referrer-Policy +- simpele security header score van 0 tot 5 + +## Debugtips + +Unsupported items: + +- Controleer of de scripts executable zijn. +- Controleer of ze in de Zabbix `ExternalScripts` directory staan. +- Run exact dezelfde key als `zabbix` user. +- Kijk in `zabbix_server.log` of `zabbix_proxy.log`. + +Script permissies: + +- Scripts: `0755`, owner `zabbix:zabbix` +- Config: `0640`, owner `zabbix:zabbix` + +Python dependencies: + +- De basis gebruikt alleen standaardbibliotheken. +- Installeer optioneel `cryptography` als je rijkere key- en signaturevelden wilt. + +DNS/firewall: + +- Test DNS vanaf de checker-host, niet vanaf je laptop. +- Test of de checker-host TCP 443 naar het target mag openen. + +CA trust store: + +- Chain-validatie gebruikt de default trust store van Python/OpenSSL. +- Voor interne CA's moet de CA op de checker-host trusted zijn. + +SNI: + +- De checker gebruikt SNI met `expected_hostname`. +- Bij shared hosting moet `expected_hostname` overeenkomen met de certificaatnaam die je verwacht. + +## Security + +- Het configbestand bevat geen secrets. +- Maak het toch niet world-writable. +- Hostnamen, poorten en profiles worden gevalideerd. +- Er wordt geen `shell=True` gebruikt. +- Er wordt geen ruwe input in shellcommando's gestopt. +- De checker gebruikt geen `openssl` subprocess; TLS loopt via Python `ssl` en `socket`. +- Timeouts worden op netwerkverbindingen toegepast zodat checks niet blijven hangen. + +## Tuning + +Standaard in de template: + +- Discovery interval: `1h` +- Check interval: `15m` +- History raw JSON: `7d` +- History dependent items: `30d` +- Trends numerieke dependent items: `365d` + +De profiles `relaxed`, `serious`, `internal` en `external` zijn nu vooral metadata en tags. Je kunt later per profile extra triggers of strengere policies toevoegen. + +## Tests + +Lokaal: + +```bash +python -m pytest +``` + +De tests controleren configuratievalidatie, discovery output en JSON-output bij een onbereikbaar target. diff --git a/config/ssl_targets.json.example b/config/ssl_targets.json.example new file mode 100644 index 0000000..fe403fd --- /dev/null +++ b/config/ssl_targets.json.example @@ -0,0 +1,26 @@ +[ + { + "name": "Blockje Home", + "host": "home.blockje.nl", + "port": 443, + "owner": "blockje", + "profile": "relaxed", + "expected_issuer_contains": "Let's Encrypt", + "expected_hostname": "home.blockje.nl", + "http_check": true, + "expected_http_status": [200, 301, 302, 401, 403], + "timeout": 10 + }, + { + "name": "Mailcow", + "host": "mta.example.net", + "port": 443, + "owner": "jelle", + "profile": "serious", + "expected_issuer_contains": "Let's Encrypt", + "expected_hostname": "mta.example.net", + "http_check": true, + "expected_http_status": [200, 301, 302, 401, 403], + "timeout": 10 + } +] diff --git a/scripts/ssl_check.py b/scripts/ssl_check.py new file mode 100755 index 0000000..e4c5f2e --- /dev/null +++ b/scripts/ssl_check.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +"""Central SSL/TLS and HTTPS checker for Zabbix external checks.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import http.client +import ipaddress +import json +import re +import socket +import ssl +import sys +import time +from dataclasses import dataclass +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Any +from urllib.parse import urljoin, urlparse + +try: # Optional richer certificate parsing. + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, ed448, rsa + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + from cryptography.x509.oid import ExtensionOID, NameOID + + HAS_CRYPTOGRAPHY = True +except Exception: # pragma: no cover - depends on local environment + x509 = None # type: ignore[assignment] + dsa = ec = ed25519 = ed448 = rsa = None # type: ignore[assignment] + Encoding = PublicFormat = None # type: ignore[assignment] + ExtensionOID = NameOID = None # type: ignore[assignment] + HAS_CRYPTOGRAPHY = False + + +VALID_PROFILES = {"relaxed", "serious", "internal", "external"} +DEFAULT_HTTP_STATUS = [200, 301, 302, 401, 403] +HOST_RE = re.compile(r"^[A-Za-z0-9_.:-]+$") + + +class ConfigError(ValueError): + """Raised when the target configuration is invalid.""" + + +@dataclass(frozen=True) +class Target: + """Normalized target configuration.""" + + name: str + host: str + port: int + owner: str + profile: str + expected_issuer_contains: str | None = None + expected_hostname: str | None = None + http_check: bool = False + expected_http_status: tuple[int, ...] = tuple(DEFAULT_HTTP_STATUS) + timeout: float = 10.0 + + @classmethod + def from_dict(cls, raw: dict[str, Any], index: int) -> "Target": + """Validate and normalize one target dictionary.""" + required = ("name", "host", "port", "owner", "profile") + missing = [field for field in required if field not in raw] + if missing: + raise ConfigError(f"target #{index}: missing required field(s): {', '.join(missing)}") + + name = _non_empty_string(raw["name"], f"target #{index}: name") + host = _validate_host(raw["host"], f"target #{index}: host") + port = _validate_port(raw["port"], f"target #{index}: port") + owner = _non_empty_string(raw["owner"], f"target #{index}: owner") + profile = _non_empty_string(raw["profile"], f"target #{index}: profile") + if profile not in VALID_PROFILES: + raise ConfigError( + f"target #{index}: profile must be one of {', '.join(sorted(VALID_PROFILES))}" + ) + + expected_hostname = raw.get("expected_hostname", host) + expected_hostname = _validate_host(expected_hostname, f"target #{index}: expected_hostname") + + issuer = raw.get("expected_issuer_contains") + if issuer is not None: + issuer = _non_empty_string(issuer, f"target #{index}: expected_issuer_contains") + + http_check = raw.get("http_check", False) + if not isinstance(http_check, bool): + raise ConfigError(f"target #{index}: http_check must be boolean") + + timeout = raw.get("timeout", 10) + if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0: + raise ConfigError(f"target #{index}: timeout must be a positive number") + if timeout > 60: + raise ConfigError(f"target #{index}: timeout must be 60 seconds or less") + + statuses = raw.get("expected_http_status", DEFAULT_HTTP_STATUS) + if not isinstance(statuses, list) or not statuses: + raise ConfigError(f"target #{index}: expected_http_status must be a non-empty list") + normalized_statuses: list[int] = [] + for status in statuses: + if not isinstance(status, int) or isinstance(status, bool) or status < 100 or status > 599: + raise ConfigError(f"target #{index}: expected_http_status contains invalid status") + normalized_statuses.append(status) + + return cls( + name=name, + host=host, + port=port, + owner=owner, + profile=profile, + expected_issuer_contains=issuer, + expected_hostname=expected_hostname, + http_check=http_check, + expected_http_status=tuple(normalized_statuses), + timeout=float(timeout), + ) + + def to_dict(self) -> dict[str, Any]: + """Return a serializable target dictionary.""" + return { + "name": self.name, + "host": self.host, + "port": self.port, + "owner": self.owner, + "profile": self.profile, + "expected_issuer_contains": self.expected_issuer_contains, + "expected_hostname": self.expected_hostname, + "http_check": self.http_check, + "expected_http_status": list(self.expected_http_status), + "timeout": self.timeout, + } + + +def _non_empty_string(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{field} must be a non-empty string") + return value.strip() + + +def _validate_port(value: Any, field: str) -> int: + if isinstance(value, bool): + raise ConfigError(f"{field} must be an integer port") + try: + port = int(value) + except (TypeError, ValueError) as exc: + raise ConfigError(f"{field} must be an integer port") from exc + if port < 1 or port > 65535: + raise ConfigError(f"{field} must be between 1 and 65535") + return port + + +def _validate_host(value: Any, field: str) -> str: + host = _non_empty_string(value, field) + if "/" in host or "\\" in host or any(char.isspace() for char in host): + raise ConfigError(f"{field} must be a hostname or IP address, not a URL") + try: + ipaddress.ip_address(host.strip("[]")) + return host.strip("[]") + except ValueError: + pass + if len(host) > 253 or not HOST_RE.match(host): + raise ConfigError(f"{field} contains invalid characters") + labels = host.rstrip(".").split(".") + if any(not label or label.startswith("-") or label.endswith("-") for label in labels): + raise ConfigError(f"{field} contains invalid DNS label(s)") + return host.rstrip(".") + + +def load_targets(path: Path) -> list[dict[str, Any]]: + """Load, validate, normalize, and deduplicate target config.""" + with path.open("r", encoding="utf-8") as handle: + raw = json.load(handle) + if not isinstance(raw, list): + raise ConfigError("config root must be a JSON array") + + targets: list[dict[str, Any]] = [] + seen: set[tuple[str, int]] = set() + for index, entry in enumerate(raw, start=1): + if not isinstance(entry, dict): + raise ConfigError(f"target #{index}: entry must be an object") + target = Target.from_dict(entry, index) + key = (target.host.lower(), target.port) + if key in seen: + continue + seen.add(key) + targets.append(target.to_dict()) + return targets + + +def find_target(config: Path, host: str, port: int) -> Target: + """Find one target by host and port in a validated config file.""" + for raw in load_targets(config): + if str(raw["host"]).lower() == host.lower() and int(raw["port"]) == port: + return Target.from_dict(raw, 0) + raise ConfigError(f"target {host}:{port} was not found in {config}") + + +def default_result(target: Target) -> dict[str, Any]: + """Create a stable JSON result skeleton.""" + return { + "target": target.host, + "port": target.port, + "name": target.name, + "owner": target.owner, + "profile": target.profile, + "reachable": False, + "error": "", + "valid_now": None, + "days_left": None, + "not_before": None, + "not_after": None, + "subject_cn": None, + "san_names": None, + "issuer_cn": None, + "issuer_org": None, + "serial_number": None, + "fingerprint_sha256": None, + "hostname_match": None, + "chain_valid": None, + "self_signed": None, + "not_yet_valid": None, + "expired": None, + "expected_issuer_match": None, + "expected_hostname_match": None, + "tls_version_negotiated": None, + "tls12_supported": None, + "tls13_supported": None, + "tls10_supported": None, + "tls11_supported": None, + "key_type": None, + "key_bits": None, + "signature_algorithm": None, + "http_check_enabled": target.http_check, + "http_reachable": None, + "http_status": None, + "http_status_expected": None, + "http_final_url": None, + "http_redirects_to_https": None, + "http_response_time_ms": None, + "http_server_header": None, + "http_hsts": None, + "http_security_headers_score": None, + "warnings": [], + } + + +def utc_now() -> dt.datetime: + """Return timezone-aware UTC now.""" + return dt.datetime.now(dt.timezone.utc) + + +def iso_z(value: dt.datetime | None) -> str | None: + """Format a datetime as RFC3339 UTC with Z suffix.""" + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=dt.timezone.utc) + return value.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def parse_cert_time(value: str | None) -> dt.datetime | None: + """Parse OpenSSL ASN.1 time strings from getpeercert().""" + if not value: + return None + parsed = parsedate_to_datetime(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + +def colon_hex(data: bytes) -> str: + """Return colon-separated uppercase hex.""" + return ":".join(f"{byte:02X}" for byte in data) + + +def get_name_attr(name_parts: Any, key: str) -> str | None: + """Extract an attribute from ssl.getpeercert() subject/issuer tuples.""" + for rdn in name_parts or (): + for attr_name, value in rdn: + if attr_name == key: + return str(value) + return None + + +def parse_stdlib_cert(cert: dict[str, Any]) -> dict[str, Any]: + """Parse fields available from ssl.getpeercert().""" + san_names = [ + str(value) + for kind, value in cert.get("subjectAltName", []) + if str(kind).lower() in {"dns", "ip address"} + ] + return { + "not_before_dt": parse_cert_time(cert.get("notBefore")), + "not_after_dt": parse_cert_time(cert.get("notAfter")), + "subject_cn": get_name_attr(cert.get("subject"), "commonName"), + "issuer_cn": get_name_attr(cert.get("issuer"), "commonName"), + "issuer_org": get_name_attr(cert.get("issuer"), "organizationName"), + "san_names": san_names, + "serial_number": cert.get("serialNumber"), + } + + +def parse_crypto_cert(der_cert: bytes) -> dict[str, Any]: + """Parse certificate fields with optional cryptography support.""" + if not HAS_CRYPTOGRAPHY or x509 is None: + return {} + + cert = x509.load_der_x509_certificate(der_cert) + + def first_name_attr(name: Any, oid: Any) -> str | None: + attrs = name.get_attributes_for_oid(oid) + return str(attrs[0].value) if attrs else None + + san_names: list[str] = [] + try: + san_ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value + san_names.extend(str(name) for name in san_ext.get_values_for_type(x509.DNSName)) + san_names.extend(str(name) for name in san_ext.get_values_for_type(x509.IPAddress)) + except x509.ExtensionNotFound: + pass + + public_key = cert.public_key() + key_type = type(public_key).__name__ + key_bits: int | None = None + if rsa is not None and isinstance(public_key, rsa.RSAPublicKey): + key_type = "RSA" + key_bits = public_key.key_size + elif dsa is not None and isinstance(public_key, dsa.DSAPublicKey): + key_type = "DSA" + key_bits = public_key.key_size + elif ec is not None and isinstance(public_key, ec.EllipticCurvePublicKey): + key_type = "EC" + key_bits = public_key.key_size + elif ed25519 is not None and isinstance(public_key, ed25519.Ed25519PublicKey): + key_type = "Ed25519" + elif ed448 is not None and isinstance(public_key, ed448.Ed448PublicKey): + key_type = "Ed448" + + return { + "not_before_dt": cert.not_valid_before_utc, + "not_after_dt": cert.not_valid_after_utc, + "subject_cn": first_name_attr(cert.subject, NameOID.COMMON_NAME), + "issuer_cn": first_name_attr(cert.issuer, NameOID.COMMON_NAME), + "issuer_org": first_name_attr(cert.issuer, NameOID.ORGANIZATION_NAME), + "san_names": san_names, + "serial_number": format(cert.serial_number, "X"), + "key_type": key_type, + "key_bits": key_bits, + "signature_algorithm": ( + cert.signature_hash_algorithm.name if cert.signature_hash_algorithm else cert.signature_algorithm_oid._name + ), + "self_signed": cert.issuer == cert.subject and _self_signature_looks_valid(cert), + } + + +def _self_signature_looks_valid(cert: Any) -> bool: + """Best-effort self-signed detection without failing the whole check.""" + try: + public_key = cert.public_key() + public_bytes = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return cert.issuer == cert.subject and bool(public_bytes) + except Exception: + return cert.issuer == cert.subject + + +def connect_tls( + host: str, + port: int, + expected_hostname: str, + timeout: float, + verify: bool = True, + min_version: ssl.TLSVersion | None = None, + max_version: ssl.TLSVersion | None = None, +) -> tuple[ssl.SSLSocket, dict[str, Any], bytes]: + """Open a TLS connection and return socket, parsed cert, and DER leaf cert.""" + context = ssl.create_default_context() if verify else ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = verify + context.verify_mode = ssl.CERT_REQUIRED if verify else ssl.CERT_NONE + if min_version is not None: + context.minimum_version = min_version + if max_version is not None: + context.maximum_version = max_version + + raw_sock = socket.create_connection((host, port), timeout=timeout) + tls_sock = context.wrap_socket(raw_sock, server_hostname=expected_hostname) + tls_sock.settimeout(timeout) + cert = tls_sock.getpeercert() + der_cert = tls_sock.getpeercert(binary_form=True) + if der_cert is None: + raise ssl.SSLError("server did not provide a certificate") + return tls_sock, cert, der_cert + + +def check_tls(target: Target, result: dict[str, Any]) -> None: + """Populate TLS and certificate result fields.""" + try: + sock, cert, der_cert = connect_tls( + target.host, target.port, target.expected_hostname or target.host, target.timeout, verify=True + ) + negotiated_version = sock.version() + sock.close() + result["reachable"] = True + result["chain_valid"] = True + result["hostname_match"] = True + result["tls_version_negotiated"] = negotiated_version + except ssl.SSLCertVerificationError as exc: + result["chain_valid"] = False + result["hostname_match"] = "hostname" not in str(exc).lower() + result["error"] = f"TLS verification failed: {exc.verify_message or exc}" + try: + sock, cert, der_cert = connect_tls( + target.host, target.port, target.expected_hostname or target.host, target.timeout, verify=False + ) + result["reachable"] = True + result["tls_version_negotiated"] = sock.version() + sock.close() + except Exception: + return + except (TimeoutError, OSError, ssl.SSLError) as exc: + result["reachable"] = False + result["error"] = f"TLS connection failed: {type(exc).__name__}: {exc}" + return + + std_fields = parse_stdlib_cert(cert) + crypto_fields = parse_crypto_cert(der_cert) + fields = {**std_fields, **{key: value for key, value in crypto_fields.items() if value not in (None, [], "")}} + + not_before = fields.pop("not_before_dt", None) + not_after = fields.pop("not_after_dt", None) + now = utc_now() + + result.update(fields) + result["fingerprint_sha256"] = colon_hex(hashlib.sha256(der_cert).digest()) + result["not_before"] = iso_z(not_before) + result["not_after"] = iso_z(not_after) + result["not_yet_valid"] = bool(not_before and now < not_before) + result["expired"] = bool(not_after and now > not_after) + result["valid_now"] = bool(not_before and not_after and not_before <= now <= not_after and result["chain_valid"]) + result["days_left"] = max(0, int((not_after - now).total_seconds() // 86400)) if not_after else None + + if result["self_signed"] is None: + result["self_signed"] = bool( + result["subject_cn"] and result["issuer_cn"] and result["subject_cn"] == result["issuer_cn"] + ) + result["hostname_match"] = certificate_matches_hostname(cert, target.expected_hostname or target.host) + result["expected_hostname_match"] = certificate_matches_hostname(cert, target.expected_hostname or target.host) + issuer_text = " ".join( + str(part) + for part in (result.get("issuer_cn"), result.get("issuer_org")) + if part is not None + ) + result["expected_issuer_match"] = ( + target.expected_issuer_contains.lower() in issuer_text.lower() + if target.expected_issuer_contains + else None + ) + + if not HAS_CRYPTOGRAPHY: + result["warnings"].append("cryptography not installed; key/signature parsing is limited") + + result["tls10_supported"] = probe_tls_version(target, ssl.TLSVersion.TLSv1, "TLS 1.0", result["warnings"]) + result["tls11_supported"] = probe_tls_version(target, ssl.TLSVersion.TLSv1_1, "TLS 1.1", result["warnings"]) + result["tls12_supported"] = probe_tls_version(target, ssl.TLSVersion.TLSv1_2, "TLS 1.2", result["warnings"]) + if hasattr(ssl.TLSVersion, "TLSv1_3"): + result["tls13_supported"] = probe_tls_version(target, ssl.TLSVersion.TLSv1_3, "TLS 1.3", result["warnings"]) + + +def certificate_matches_hostname(cert: dict[str, Any], hostname: str) -> bool | None: + """Return whether a peer certificate matches hostname.""" + try: + ssl.match_hostname(cert, hostname) + return True + except ssl.CertificateError: + return False + except Exception: + return None + + +def probe_tls_version( + target: Target, version: ssl.TLSVersion, label: str, warnings: list[str] +) -> bool | None: + """Probe support for one exact TLS protocol version.""" + try: + sock, _, _ = connect_tls( + target.host, + target.port, + target.expected_hostname or target.host, + min(target.timeout, 5.0), + verify=False, + min_version=version, + max_version=version, + ) + sock.close() + return True + except ssl.SSLError as exc: + message = str(exc).lower() + if ( + "unsupported protocol" in message + or "no protocols available" in message + or "no ciphers available" in message + ): + warnings.append(f"{label} could not be tested due to local OpenSSL policy") + return None + return False + except (TimeoutError, OSError): + return False + except ValueError as exc: + warnings.append(f"{label} could not be tested: {exc}") + return None + + +def check_http(target: Target, result: dict[str, Any]) -> None: + """Perform an HTTPS request with limited redirect following.""" + if not target.http_check: + return + + start = time.monotonic() + url = f"https://{target.host}:{target.port}/" if target.port != 443 else f"https://{target.host}/" + redirects_to_https = True + + try: + for _ in range(6): + parsed = urlparse(url) + if parsed.scheme != "https": + redirects_to_https = False + if parsed.scheme not in {"https", "http"}: + raise ValueError(f"unsupported redirect scheme: {parsed.scheme}") + + connection_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection + port = parsed.port or (443 if parsed.scheme == "https" else 80) + path = parsed.path or "/" + if parsed.query: + path += f"?{parsed.query}" + + connection = connection_cls(parsed.hostname, port, timeout=target.timeout) + connection.request("GET", path, headers={"User-Agent": "zabbix-ssl-checker/1.0"}) + response = connection.getresponse() + headers = {key.lower(): value for key, value in response.getheaders()} + response.read(1024) + status = response.status + location = headers.get("location") + connection.close() + + if status in {301, 302, 303, 307, 308} and location: + url = urljoin(url, location) + continue + + elapsed_ms = int((time.monotonic() - start) * 1000) + result["http_reachable"] = True + result["http_status"] = status + result["http_status_expected"] = status in target.expected_http_status + result["http_final_url"] = url + result["http_redirects_to_https"] = redirects_to_https and urlparse(url).scheme == "https" + result["http_response_time_ms"] = elapsed_ms + result["http_server_header"] = headers.get("server") + result["http_hsts"] = "strict-transport-security" in headers + result["http_security_headers_score"] = sum( + 1 + for header in ( + "strict-transport-security", + "x-content-type-options", + "x-frame-options", + "content-security-policy", + "referrer-policy", + ) + if header in headers + ) + return + + raise ValueError("too many redirects") + except Exception as exc: + result["http_reachable"] = False + result["http_status_expected"] = False + result["http_response_time_ms"] = int((time.monotonic() - start) * 1000) + result["warnings"].append(f"HTTP check failed: {type(exc).__name__}: {exc}") + + +def run_check(target: Target) -> dict[str, Any]: + """Run TLS and optional HTTP checks for one target.""" + result = default_result(target) + check_tls(target, result) + check_http(target, result) + return result + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Check one SSL/TLS target for Zabbix.") + parser.add_argument("--config", help="Path to ssl_targets.json") + parser.add_argument("--host", required=True, help="Target hostname or IP") + parser.add_argument("--port", required=True, type=int, help="Target port") + parser.add_argument("--name", help="Target display name when no config is used") + parser.add_argument("--owner", default="unknown", help="Target owner when no config is used") + parser.add_argument("--profile", default="relaxed", choices=sorted(VALID_PROFILES)) + parser.add_argument("--expected-issuer-contains") + parser.add_argument("--expected-hostname") + parser.add_argument("--http-check", action="store_true") + parser.add_argument("--expected-http-status", nargs="*", type=int, default=DEFAULT_HTTP_STATUS) + parser.add_argument("--timeout", type=float, default=10) + return parser.parse_args(argv) + + +def target_from_args(args: argparse.Namespace) -> Target: + """Build target config from CLI arguments or a config file.""" + port = _validate_port(args.port, "--port") + host = _validate_host(args.host, "--host") + if args.config: + return find_target(Path(args.config), host, port) + + raw = { + "name": args.name or host, + "host": host, + "port": port, + "owner": args.owner, + "profile": args.profile, + "expected_issuer_contains": args.expected_issuer_contains, + "expected_hostname": args.expected_hostname or host, + "http_check": args.http_check, + "expected_http_status": args.expected_http_status, + "timeout": args.timeout, + } + return Target.from_dict(raw, 0) + + +def main(argv: list[str] | None = None) -> int: + """Run the checker and always print JSON for valid arguments/config.""" + try: + args = parse_args(argv) + target = target_from_args(args) + except (ConfigError, OSError, json.JSONDecodeError) as exc: + print(f"Configuration/argument error: {exc}", file=sys.stderr) + return 2 + + result = run_check(target) + print(json.dumps(result, separators=(",", ":"), ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ssl_discovery.py b/scripts/ssl_discovery.py new file mode 100755 index 0000000..306ee3b --- /dev/null +++ b/scripts/ssl_discovery.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Zabbix Low-Level Discovery for centrally checked SSL/TLS targets.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +try: + from ssl_check import ConfigError, load_targets +except ImportError as exc: # pragma: no cover - defensive for odd install paths + print(f"Could not import ssl_check.py helpers: {exc}", file=sys.stderr) + sys.exit(2) + + +def build_lld(targets: list[dict[str, object]]) -> dict[str, list[dict[str, str]]]: + """Convert normalized target dictionaries to Zabbix LLD JSON.""" + data: list[dict[str, str]] = [] + seen: set[tuple[str, int]] = set() + + for target in targets: + host = str(target["host"]) + port = int(target["port"]) + key = (host.lower(), port) + if key in seen: + continue + seen.add(key) + + data.append( + { + "{#SSL_NAME}": str(target["name"]), + "{#SSL_HOST}": host, + "{#SSL_PORT}": str(port), + "{#SSL_OWNER}": str(target["owner"]), + "{#SSL_PROFILE}": str(target["profile"]), + } + ) + + return {"data": data} + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="Discover SSL/TLS targets for Zabbix LLD.") + parser.add_argument("--config", required=True, help="Path to ssl_targets.json") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Load target config and print Zabbix LLD JSON.""" + args = parse_args(argv) + try: + targets = load_targets(Path(args.config)) + print(json.dumps(build_lld(targets), separators=(",", ":"), ensure_ascii=False)) + return 0 + except ConfigError as exc: + print(f"Configuration error: {exc}", file=sys.stderr) + return 1 + except OSError as exc: + print(f"Could not read config: {exc}", file=sys.stderr) + return 1 + except json.JSONDecodeError as exc: + print(f"Invalid JSON in config: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..110a98b --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from ssl_check import ConfigError, load_targets # noqa: E402 +from ssl_discovery import build_lld # noqa: E402 + + +def write_config(tmp_path: Path, data: object) -> Path: + config = tmp_path / "ssl_targets.json" + config.write_text(json.dumps(data), encoding="utf-8") + return config + + +def test_valid_config_is_normalized_and_deduplicated(tmp_path: Path) -> None: + config = write_config( + tmp_path, + [ + { + "name": "Example", + "host": "example.test", + "port": 443, + "owner": "ops", + "profile": "relaxed", + }, + { + "name": "Duplicate", + "host": "example.test", + "port": 443, + "owner": "ops", + "profile": "relaxed", + }, + ], + ) + + targets = load_targets(config) + + assert len(targets) == 1 + assert targets[0]["expected_hostname"] == "example.test" + assert targets[0]["timeout"] == 10.0 + + +def test_invalid_profile_fails(tmp_path: Path) -> None: + config = write_config( + tmp_path, + [ + { + "name": "Example", + "host": "example.test", + "port": 443, + "owner": "ops", + "profile": "wild-west", + } + ], + ) + + with pytest.raises(ConfigError): + load_targets(config) + + +def test_invalid_port_fails(tmp_path: Path) -> None: + config = write_config( + tmp_path, + [ + { + "name": "Example", + "host": "example.test", + "port": 70000, + "owner": "ops", + "profile": "relaxed", + } + ], + ) + + with pytest.raises(ConfigError): + load_targets(config) + + +def test_discovery_output_contains_lld_macros(tmp_path: Path) -> None: + config = write_config( + tmp_path, + [ + { + "name": "Example", + "host": "example.test", + "port": 443, + "owner": "ops", + "profile": "internal", + } + ], + ) + + discovery = build_lld(load_targets(config)) + + assert "data" in discovery + assert discovery["data"][0]["{#SSL_NAME}"] == "Example" + assert discovery["data"][0]["{#SSL_HOST}"] == "example.test" + assert discovery["data"][0]["{#SSL_PORT}"] == "443" + assert discovery["data"][0]["{#SSL_OWNER}"] == "ops" + assert discovery["data"][0]["{#SSL_PROFILE}"] == "internal" diff --git a/tests/test_ssl_check_basic.py b/tests/test_ssl_check_basic.py new file mode 100644 index 0000000..41c6cc5 --- /dev/null +++ b/tests/test_ssl_check_basic.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import json +import socket +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from ssl_check import Target, main, run_check # noqa: E402 + + +def unused_local_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = int(sock.getsockname()[1]) + sock.close() + return port + + +def test_ssl_check_unreachable_returns_stable_json() -> None: + target = Target( + name="Unreachable", + host="127.0.0.1", + port=unused_local_port(), + owner="tests", + profile="relaxed", + expected_hostname="127.0.0.1", + timeout=0.5, + ) + + result = run_check(target) + + assert result["reachable"] is False + assert result["error"] + assert result["target"] == "127.0.0.1" + assert "warnings" in result + + +def test_main_prints_json_for_unreachable_target(capsys) -> None: # type: ignore[no-untyped-def] + port = unused_local_port() + + exit_code = main( + [ + "--host", + "127.0.0.1", + "--port", + str(port), + "--expected-hostname", + "127.0.0.1", + "--timeout", + "0.5", + ] + ) + + captured = capsys.readouterr() + payload = json.loads(captured.out) + + assert exit_code == 0 + assert payload["reachable"] is False + assert payload["error"] diff --git a/zabbix/MANUAL_TEMPLATE_STEPS.md b/zabbix/MANUAL_TEMPLATE_STEPS.md new file mode 100644 index 0000000..96d2d00 --- /dev/null +++ b/zabbix/MANUAL_TEMPLATE_STEPS.md @@ -0,0 +1,94 @@ +# Handmatige Zabbix-template stappen + +Gebruik dit bestand alleen als je Zabbix-versie of importpolicy de YAML-export weigert. + +## Template + +1. Ga naar **Data collection -> Templates**. +2. Maak template **Template SSL Checker Relaxed**. +3. Zet de groep op **Templates/Custom**. +4. Voeg macros toe: + - `{$SSL_CONFIG}` = `/etc/zabbix/ssl_targets.json` + - `{$SSL_CHECK_TIMEOUT}` = `10` + +## Discovery rule + +Maak een discovery rule: + +- Name: `SSL target discovery` +- Type: `External check` +- Key: `ssl_discovery.py["--config","{$SSL_CONFIG}"]` +- Update interval: `1h` + +De discovery output bevat de LLD macros direct in het `data` object: + +- `{#SSL_NAME}` +- `{#SSL_HOST}` +- `{#SSL_PORT}` +- `{#SSL_OWNER}` +- `{#SSL_PROFILE}` + +## Master item prototype + +Maak onder de discovery rule een item prototype: + +- Name: `SSL raw check [{#SSL_NAME}]` +- Type: `External check` +- Key: `ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]` +- Type of information: `Text` +- Update interval: `15m` +- History: `7d` +- Trends: `0` + +## Dependent item prototypes + +Maak dependent item prototypes met het master item hierboven als bron. Gebruik JSONPath preprocessing per veld, bijvoorbeeld: + +- `ssl.reachable[{#SSL_HOST},{#SSL_PORT}]` -> `$.reachable` -> JavaScript `return value === true || value === "true" ? 1 : 0;` +- `ssl.days_left[{#SSL_HOST},{#SSL_PORT}]` -> `$.days_left` +- `ssl.valid_now[{#SSL_HOST},{#SSL_PORT}]` -> `$.valid_now` -> boolean JavaScript +- `ssl.hostname_match[{#SSL_HOST},{#SSL_PORT}]` -> `$.hostname_match` -> boolean JavaScript +- `ssl.chain_valid[{#SSL_HOST},{#SSL_PORT}]` -> `$.chain_valid` -> boolean JavaScript +- `ssl.self_signed[{#SSL_HOST},{#SSL_PORT}]` -> `$.self_signed` -> boolean JavaScript +- `ssl.not_yet_valid[{#SSL_HOST},{#SSL_PORT}]` -> `$.not_yet_valid` -> boolean JavaScript +- `ssl.expired[{#SSL_HOST},{#SSL_PORT}]` -> `$.expired` -> boolean JavaScript +- `ssl.issuer_org[{#SSL_HOST},{#SSL_PORT}]` -> `$.issuer_org` +- `ssl.issuer_cn[{#SSL_HOST},{#SSL_PORT}]` -> `$.issuer_cn` +- `ssl.subject_cn[{#SSL_HOST},{#SSL_PORT}]` -> `$.subject_cn` +- `ssl.san_names[{#SSL_HOST},{#SSL_PORT}]` -> `$.san_names` +- `ssl.fingerprint_sha256[{#SSL_HOST},{#SSL_PORT}]` -> `$.fingerprint_sha256` +- `ssl.expected_issuer_match[{#SSL_HOST},{#SSL_PORT}]` -> `$.expected_issuer_match` -> boolean JavaScript +- `ssl.tls_version_negotiated[{#SSL_HOST},{#SSL_PORT}]` -> `$.tls_version_negotiated` +- `ssl.tls10_supported[{#SSL_HOST},{#SSL_PORT}]` -> `$.tls10_supported` -> boolean JavaScript +- `ssl.tls11_supported[{#SSL_HOST},{#SSL_PORT}]` -> `$.tls11_supported` -> boolean JavaScript +- `ssl.tls12_supported[{#SSL_HOST},{#SSL_PORT}]` -> `$.tls12_supported` -> boolean JavaScript +- `ssl.tls13_supported[{#SSL_HOST},{#SSL_PORT}]` -> `$.tls13_supported` -> boolean JavaScript +- `ssl.http_reachable[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_reachable` -> boolean JavaScript +- `ssl.http_status[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_status` +- `ssl.http_status_expected[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_status_expected` -> boolean JavaScript +- `ssl.http_response_time_ms[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_response_time_ms` +- `ssl.http_hsts[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_hsts` -> boolean JavaScript +- `ssl.http_security_headers_score[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_security_headers_score` +- `ssl.http_server_header[{#SSL_HOST},{#SSL_PORT}]` -> `$.http_server_header` +- `ssl.error[{#SSL_HOST},{#SSL_PORT}]` -> `$.error` + +## Triggers + +Maak trigger prototypes op de dependent item prototypes: + +- SSL target unreachable: reachable = 0, Warning +- SSL expires within 30 days: days_left < 30 and >= 14, Information +- SSL expires within 14 days: days_left < 14 and >= 7, Warning +- SSL expires within 7 days: days_left < 7 and >= 2, Average +- SSL expires within 2 days: days_left < 2, High +- SSL hostname mismatch: hostname_match = 0, High +- SSL chain invalid: chain_valid = 0, Average +- SSL is self-signed: self_signed = 1, Warning +- SSL not yet valid: not_yet_valid = 1, High +- TLS 1.0 supported: tls10_supported = 1, Warning +- TLS 1.1 supported: tls11_supported = 1, Warning +- HTTP status not expected: http_status_expected = 0, Warning +- HSTS missing: http_hsts = 0, Information +- HTTP security headers score low: score < 2, Information + +Text-item change triggers voor issuer/fingerprint zijn bewust niet opgenomen, omdat importeerbaarheid per Zabbix 7.x minor release kan verschillen. diff --git a/zabbix/template_ssl_checker_relaxed_zabbix_7.yaml b/zabbix/template_ssl_checker_relaxed_zabbix_7.yaml new file mode 100644 index 0000000..6337b1e --- /dev/null +++ b/zabbix/template_ssl_checker_relaxed_zabbix_7.yaml @@ -0,0 +1,592 @@ +zabbix_export: + version: '7.0' + template_groups: + - uuid: 2fdce7293a314718bbeb9a302dd7533f + name: Templates/Custom + templates: + - uuid: eaa75d8e6bb44b0e8fca872ab5ea6001 + template: Template SSL Checker Relaxed + name: Template SSL Checker Relaxed + groups: + - name: Templates/Custom + macros: + - macro: '{$SSL_CONFIG}' + value: /etc/zabbix/ssl_targets.json + - macro: '{$SSL_CHECK_TIMEOUT}' + value: '10' + discovery_rules: + - uuid: eebfdbd1e6f94de0abe6b014d652679e + name: SSL target discovery + type: EXTERNAL + key: 'ssl_discovery.py["--config","{$SSL_CONFIG}"]' + delay: 1h + item_prototypes: + - uuid: 9cbb898647f74b4f8865f65b036f88cb + name: 'SSL raw check [{#SSL_NAME}]' + type: EXTERNAL + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + delay: 15m + history: 7d + trends: '0' + value_type: TEXT + tags: + - tag: component + value: raw + - tag: scope + value: ssl + - tag: owner + value: '{#SSL_OWNER}' + - tag: profile + value: '{#SSL_PROFILE}' + - uuid: 742b8b1630e048d2b73d95e846165bec + name: 'SSL reachable [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.reachable[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.reachable + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: a44af3b1e4c547d59f9e6e7c5c9186d4 + name: 'SSL days left [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.days_left[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + preprocessing: + - type: JSONPATH + parameters: + - $.days_left + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: fbd0960d21154c18928ad36afae14c40 + name: 'SSL valid now [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.valid_now[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.valid_now + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 35dc642cbe92420da0631a68f48c4662 + name: 'SSL hostname match [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.hostname_match[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.hostname_match + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 3ce5bba7f2b340dcbe74641b318cb08f + name: 'SSL chain valid [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.chain_valid[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.chain_valid + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: ad0aff7a23b9447ea573a6cc4fd03209 + name: 'SSL self signed [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.self_signed[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.self_signed + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 489a246318764ad39ec9effd8d7f5c3f + name: 'SSL not yet valid [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.not_yet_valid[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.not_yet_valid + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 8dc650b706c340a996a80238f775e324 + name: 'SSL expired [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.expired[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.expired + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 204862fd4f8342b7bf3fc6337f5df6d0 + name: 'SSL issuer org [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.issuer_org[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.issuer_org + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 246a068f7f3745a5a1cb0f725a499426 + name: 'SSL issuer CN [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.issuer_cn[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.issuer_cn + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 46a8211952364d3bbd7c359a0f7ebc62 + name: 'SSL subject CN [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.subject_cn[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.subject_cn + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: db6d8a182d6b4e28915cd2c1937d3ba7 + name: 'SSL SAN names [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.san_names[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.san_names + - type: JAVASCRIPT + parameters: + - 'try { return JSON.stringify(JSON.parse(value)); } catch (e) { return value; }' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 3ae58996de784502816d1cd27a722de3 + name: 'SSL fingerprint SHA256 [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.fingerprint_sha256[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.fingerprint_sha256 + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 8cc55e450c064a698053967f8dfd3f35 + name: 'SSL expected issuer match [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.expected_issuer_match[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.expected_issuer_match + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 5e74e1d762a9420395448d9d02b3b3e9 + name: 'TLS negotiated version [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.tls_version_negotiated[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.tls_version_negotiated + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 715157395cf24e738c275e5d99c585d2 + name: 'TLS 1.0 supported [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.tls10_supported[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.tls10_supported + - type: JAVASCRIPT + parameters: + - 'if (value === null || value === "null") { return 0; } return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 7931bd3a2a434be38548fd6867e8473d + name: 'TLS 1.1 supported [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.tls11_supported[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.tls11_supported + - type: JAVASCRIPT + parameters: + - 'if (value === null || value === "null") { return 0; } return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: a20fbc15a8ea4cb7a3c3c4dbfc7789e1 + name: 'TLS 1.2 supported [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.tls12_supported[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.tls12_supported + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: e633e097a3294860853e9f66fc1cc6a8 + name: 'TLS 1.3 supported [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.tls13_supported[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.tls13_supported + - type: JAVASCRIPT + parameters: + - 'if (value === null || value === "null") { return 0; } return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: f97fb439d4e04ad38e2dd0311fd144c7 + name: 'HTTP reachable [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_reachable[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.http_reachable + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 58242f0618944f57a925a3d59c871e39 + name: 'HTTP status [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_status[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + preprocessing: + - type: JSONPATH + parameters: + - $.http_status + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: b8475311fa4e4594bd8a4340b60b6937 + name: 'HTTP status expected [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_status_expected[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.http_status_expected + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 1a3cf22a1f9a4aa6b9219fb091d2eaf1 + name: 'HTTP response time [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_response_time_ms[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + preprocessing: + - type: JSONPATH + parameters: + - $.http_response_time_ms + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 5ae12b7986ea485b908c9e61f49c4e9e + name: 'HTTP HSTS [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_hsts[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + valuemap: + name: SSL boolean + preprocessing: + - type: JSONPATH + parameters: + - $.http_hsts + - type: JAVASCRIPT + parameters: + - 'return value === true || value === "true" ? 1 : 0;' + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 91101608a78846f2b6d6c6fcce8d7648 + name: 'HTTP security headers score [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_security_headers_score[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: 365d + value_type: UNSIGNED + preprocessing: + - type: JSONPATH + parameters: + - $.http_security_headers_score + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: df341fd63eda4d2e8dc457d3fa4d91f7 + name: 'HTTP server header [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.http_server_header[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.http_server_header + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + - uuid: 8bfc3b3d13da40b2a77ac180e910eaa0 + name: 'SSL error [{#SSL_NAME}]' + type: DEPENDENT + key: 'ssl.error[{#SSL_HOST},{#SSL_PORT}]' + history: 30d + trends: '0' + value_type: TEXT + preprocessing: + - type: JSONPATH + parameters: + - $.error + master_item: + key: 'ssl_check.py["--config","{$SSL_CONFIG}","--host","{#SSL_HOST}","--port","{#SSL_PORT}"]' + trigger_prototypes: + - uuid: edb8b81e8f334c56890f44ff209c011e + expression: 'last(/Template SSL Checker Relaxed/ssl.reachable[{#SSL_HOST},{#SSL_PORT}])=0' + name: 'SSL target unreachable [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: scope + value: ssl + - tag: owner + value: '{#SSL_OWNER}' + - tag: profile + value: '{#SSL_PROFILE}' + - tag: notify + value: delayed + - uuid: b6614d7a41184f5c8785e549012782cda + expression: 'last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])<30 and last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])>=14' + name: 'SSL expires within 30 days [{#SSL_NAME}]' + priority: INFO + tags: + - tag: notify + value: 'no' + - uuid: d049184a61514835ad9530e7e6fbc3b3 + expression: 'last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])<14 and last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])>=7' + name: 'SSL expires within 14 days [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: notify + value: owner + - uuid: 82dd7417fc32470085073ef5ed6cc671 + expression: 'last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])<7 and last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])>=2' + name: 'SSL expires within 7 days [{#SSL_NAME}]' + priority: AVERAGE + tags: + - tag: notify + value: 'yes' + - uuid: 5eea97943e164f9b9b7bf42d1e7f5723 + expression: 'last(/Template SSL Checker Relaxed/ssl.days_left[{#SSL_HOST},{#SSL_PORT}])<2' + name: 'SSL expires within 2 days [{#SSL_NAME}]' + priority: HIGH + tags: + - tag: notify + value: 'yes' + - uuid: 9d187f28d1dd43e9bca055333b7fb5a1 + expression: 'last(/Template SSL Checker Relaxed/ssl.hostname_match[{#SSL_HOST},{#SSL_PORT}])=0' + name: 'SSL hostname mismatch [{#SSL_NAME}]' + priority: HIGH + tags: + - tag: notify + value: 'yes' + - uuid: a7dfbb0250f84d04baf634674b4dbb20 + expression: 'last(/Template SSL Checker Relaxed/ssl.chain_valid[{#SSL_HOST},{#SSL_PORT}])=0' + name: 'SSL chain invalid [{#SSL_NAME}]' + priority: AVERAGE + tags: + - tag: notify + value: 'yes' + - uuid: e23bd53ed8c142ef9798e4062f97f2df + expression: 'last(/Template SSL Checker Relaxed/ssl.self_signed[{#SSL_HOST},{#SSL_PORT}])=1' + name: 'SSL is self-signed [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: notify + value: owner + - uuid: fb93047326ff4bd5bed32c9783d90ce9 + expression: 'last(/Template SSL Checker Relaxed/ssl.not_yet_valid[{#SSL_HOST},{#SSL_PORT}])=1' + name: 'SSL not yet valid [{#SSL_NAME}]' + priority: HIGH + tags: + - tag: notify + value: 'yes' + - uuid: 19c2314c9daa4326b48e5e263eb70c4c + expression: 'last(/Template SSL Checker Relaxed/ssl.tls10_supported[{#SSL_HOST},{#SSL_PORT}])=1' + name: 'TLS 1.0 supported [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: scope + value: tls + - tag: notify + value: owner + - uuid: 3bce8d223f0d443393f407dd0c81f444 + expression: 'last(/Template SSL Checker Relaxed/ssl.tls11_supported[{#SSL_HOST},{#SSL_PORT}])=1' + name: 'TLS 1.1 supported [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: scope + value: tls + - tag: notify + value: owner + - uuid: b484f5ff1d974e499a8f765a8e63a7c0 + expression: 'last(/Template SSL Checker Relaxed/ssl.http_status_expected[{#SSL_HOST},{#SSL_PORT}])=0' + name: 'HTTP status not expected [{#SSL_NAME}]' + priority: WARNING + tags: + - tag: scope + value: http + - tag: notify + value: delayed + - uuid: 4149f1a7f8bc45aa8b6a1b9953978df5 + expression: 'last(/Template SSL Checker Relaxed/ssl.http_hsts[{#SSL_HOST},{#SSL_PORT}])=0' + name: 'HSTS missing [{#SSL_NAME}]' + priority: INFO + tags: + - tag: scope + value: http + - tag: notify + value: 'no' + - uuid: 7c0b3de5aec14b538df5495945c72719 + expression: 'last(/Template SSL Checker Relaxed/ssl.http_security_headers_score[{#SSL_HOST},{#SSL_PORT}])<2' + name: 'HTTP security headers score low [{#SSL_NAME}]' + priority: INFO + tags: + - tag: scope + value: http + - tag: notify + value: 'no' + valuemaps: + - uuid: 7f0d5857156543cf9808cad8c3328e4d + name: SSL boolean + mappings: + - value: '0' + newvalue: 'No' + - value: '1' + newvalue: 'Yes'