Add Zabbix SSL checker

This commit is contained in:
2026-05-21 19:20:49 +02:00
commit 76f1f36d97
9 changed files with 1829 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
venv/
.venv/
+230
View File
@@ -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.
+26
View File
@@ -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
}
]
+641
View File
@@ -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())
+70
View File
@@ -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())
+107
View File
@@ -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"
+62
View File
@@ -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"]
+94
View File
@@ -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.
@@ -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'