Check TLS certificate expiry, chain, key size and offered protocol versions, including STARTTLS services (MIT, free; pipx install) https://errant.solutions/tools/tls-cert-watchdog.html
  • Python 89.3%
  • Shell 10.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Hermes Agent 5bf24d7bf5
All checks were successful
tests / test (3.14) (push) Successful in 41s
tests / test (3.8) (push) Successful in 40s
ci: mutation-incremental.py shared-baseline version (copy of ctg-infrastructure 87ba7a5)
2026-10-21 20:26:16 -07:00
.forgejo/workflows mutation/chaos CI on every rw-man host again (owner decision 2026-10-18: colo backlog too big, GuardDuty suppression kept): EC2 guards are opt-in (refuse only with CI_COLO_ONLY=1, code kept), colo jobs move to the shared labels mutation-x86_64 / mutation-any (rw-man-01..05, 6g slots) 2026-10-18 13:45:45 -07:00
ci ci: mutation-incremental.py shared-baseline version (copy of ctg-infrastructure 87ba7a5) 2026-10-21 20:26:16 -07:00
tests Test depth: verdict contract tests from mutation survivors (exact severity+wording for every key type incl. P-224/secp256k1/DSA/Ed448/unknown curve, expiry boundaries 0/14/15/28/29 days and expired, SHA-1 and CN-only, every handshake-failure path, chain entries + expiry, protocol scan classification, every STARTTLS refusal and what is sent, text report, --fail-on levels, --file comments, --starttls none, target parsing edges); claims ledger maps them 2026-10-17 02:35:30 -07:00
.gitignore nightly mutation: incremental (only functions changed since the last green run) except a weekly full run on the repo's own weekday; replace-queued concurrency; staggered cron 2026-10-18 12:22:56 -07:00
cert_watch.py STARTTLS (SMTP/IMAP/POP3/FTP/PostgreSQL), per-version protocol enumeration (TLS 1.3 + legacy 1.0 no longer passes), key size, served-chain expiry, --fail-on; pyproject (pipx), --version, tag releases with checksums 2026-10-16 19:22:11 -07:00
CHANGELOG.md Test depth: accuracy vs openssl s_client (verification + per-version protocol acceptance), nightly mutation + matrix + install channels, claims ledger 2026-10-16 22:02:26 -07:00
claims.json Test depth: verdict contract tests from mutation survivors (exact severity+wording for every key type incl. P-224/secp256k1/DSA/Ed448/unknown curve, expiry boundaries 0/14/15/28/29 days and expired, SHA-1 and CN-only, every handshake-failure path, chain entries + expiry, protocol scan classification, every STARTTLS refusal and what is sent, text report, --fail-on levels, --file comments, --starttls none, target parsing edges); claims ledger maps them 2026-10-17 02:35:30 -07:00
LICENSE Initial commit: TLS certificate watchdog v1 2026-09-13 22:18:12 -07:00
pyproject.toml Mutation testing: copy all of ci/ into mutmut's sandbox (also_copy of ci/check_claims.py failed: no mutants/ci dir), keep real sleeps in fixture-server threads (--timeout test), per-repo mutant parallelism (ci/mutation-children) 2026-10-16 23:56:22 -07:00
README.md STARTTLS (SMTP/IMAP/POP3/FTP/PostgreSQL), per-version protocol enumeration (TLS 1.3 + legacy 1.0 no longer passes), key size, served-chain expiry, --fail-on; pyproject (pipx), --version, tag releases with checksums 2026-10-16 19:22:11 -07:00

TLS Certificate Watchdog

A single-file, dependency-free Python script that checks TLS certificate expiry, weak protocol versions, and common misconfigurations across any number of hosts. No dependencies beyond the Python standard library (ssl, socket).

By Errant Solutions (errant.solutions), makers of Rustinion (device provisioning & monitoring), /Dev/Out Digital (security awareness training), OOPS! (hands-on security labs), and SSHerpa (Android SSH client).

Why

"Set a calendar reminder for cert renewal" doesn't scale past a handful of domains, and most monitoring suites are overkill if all you want is "tell me before something expires." This is a real, runnable, testable check — pipe it into cron/CI and get a non-zero exit code before your customers see a browser warning page.

Install

pipx install git+https://git.canutethegreat.com/CanuteTheGreat/tls-cert-watchdog.git        # latest release branch; puts `tls-cert-watchdog` on PATH
pipx install git+https://git.canutethegreat.com/CanuteTheGreat/tls-cert-watchdog.git@v1.2.0  # a pinned release
brew install canutethegreat/errant-tools/tls-cert-watchdog   # after: brew tap canutethegreat/errant-tools https://git.canutethegreat.com/CanuteTheGreat/homebrew-errant-tools
scoop install errant-tools/tls-cert-watchdog                 # after: scoop bucket add errant-tools https://git.canutethegreat.com/CanuteTheGreat/scoop-errant-tools

Or download the single file (stdlib-only Python 3.8+) and run it directly: curl -fsSLO https://errant.solutions/downloads/cert_watch.py && python3 cert_watch.py --help. Every release has a changelog, the wheel, the sdist and a SHA256SUMS file. Not on PyPI yet, so plain pipx install tls-cert-watchdog doesn't work until it's published there.

Usage

tls-cert-watchdog example.com                          # default port 443
tls-cert-watchdog example.com:8443 api.example.com
tls-cert-watchdog --warn-days 21 example.com
tls-cert-watchdog smtp://mail.example.com              # STARTTLS on 25 (also: submission:// 587)
tls-cert-watchdog imap://mail.example.com pop3://mail.example.com ftp://files.example.com
tls-cert-watchdog postgres://db.example.com            # PostgreSQL SSLRequest on 5432
tls-cert-watchdog --starttls smtp mx.example.com:2525  # force a STARTTLS dialect
tls-cert-watchdog --file hosts.txt                     # one [scheme://]host[:port] per line
tls-cert-watchdog --json example.com                   # machine-readable
tls-cert-watchdog --fail-on medium example.com         # stricter CI gate

STARTTLS is picked automatically from the scheme or the well-known port (25/587 SMTP, 143 IMAP, 110 POP3, 21 FTP, 5432 PostgreSQL); --starttls none turns it off.

Exit codes (same convention as every Errant Solutions tool):

Code Meaning
0 no finding at or above --fail-on (default high) on any host
1 at least one such finding (expired, expiring soon, untrusted, legacy protocol accepted, weak cipher/signature/key...)
2 usage error

What it checks

  • Certificate expiry, with a configurable warning window (--warn-days, default 14) and a MEDIUM-severity heads-up at 2x that window
  • Verification failures (expired, self-signed, hostname mismatch, broken chain)
  • Every protocol version the server accepts: TLS 1.0, 1.1, 1.2 and 1.3 are each tried on their own connection, so a server offering TLS 1.3 and TLS 1.0 is caught (HIGH; MEDIUM on opportunistic STARTTLS services); no TLS 1.3 is LOW. --no-protocol-scan does a single handshake instead.
  • Negotiated protocol and cipher — flags SSLv3/TLS 1.0/1.1 and RC4/DES/EXPORT/NULL/MD5 suites
  • Key type and size — RSA under 2048 bits, DSA and weak EC curves are HIGH
  • Certificate signature algorithm — flags MD5/SHA-1 signatures
  • The chain the server sends (Python 3.13+): every certificate's subject, issuer and expiry, and an intermediate that expires within --warn-days is HIGH
  • Missing Subject Alternative Names (legacy CN-only certs)

Expiry, SAN, key and signature details are reported even when verification fails (expired, self-signed, wrong hostname). --timeout SECONDS sets the per-connection timeout (default 10). Hosts can be host, host:port, [ipv6] or [ipv6]:port, optionally with a scheme; --file accepts # comments.

Example cron entry

0 6 * * * tls-cert-watchdog --file /etc/cert-watch/hosts.txt || mail -s "Cert issue" you@example.com < /dev/null

License

MIT — see LICENSE. Free to use, modify, and redistribute. If it saves you time, pay what you think it's worth here — or check out Rustinion for full fleet-wide monitoring with alerting, dashboards, and SLOs.

Roadmap ideas (not yet built)

  • Webhook/Slack notification mode
  • OCSP stapling / revocation checks
  • (HSTS and other security headers: see http-header-auditor)

Tests

tests/ runs every check against real local TLS servers (expired, expiring, self-signed, wrong-host, CN-only, SHA-1-signed, TLS 1.0/1.1-only, NULL-cipher) on Python 3.8 and current Python in Forgejo CI: sh ci/run-tests.sh 3.13 reproduces it locally (needs docker).