-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsimple-ssl-check.py
More file actions
112 lines (99 loc) · 3.91 KB
/
simple-ssl-check.py
File metadata and controls
112 lines (99 loc) · 3.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""Check SSL certificate expiry for one or more domains."""
import ssl
import socket
import sys
import argparse
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed
ANSI = {"ok": "\033[32m", "warn": "\033[33m", "err": "\033[31m", "dim": "\033[90m"}
RESET = "\033[0m"
def check_cert(host: str, port: int, timeout: int) -> dict:
ctx = ssl.create_default_context()
try:
with socket.create_connection((host, port), timeout=timeout) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
cert = ssock.getpeercert()
not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(
tzinfo=timezone.utc
)
days_left = (not_after - datetime.now(timezone.utc)).days
subject = dict(x[0] for x in cert.get("subject", []))
sans = [v for _, v in cert.get("subjectAltName", []) if _ == "DNS"]
return {
"host": host,
"port": port,
"days_left": days_left,
"expires": not_after.strftime("%Y-%m-%d"),
"cn": subject.get("commonName", ""),
"sans": sans,
"error": None,
}
except ssl.SSLCertVerificationError as e:
return {"host": host, "port": port, "error": f"Verification failed: {e}"}
except Exception as e:
return {"host": host, "port": port, "error": str(e)}
def status_color(days: int, no_color: bool) -> str:
if no_color:
return ""
if days < 0:
return ANSI["err"]
if days < 14:
return ANSI["warn"]
return ANSI["ok"]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Check SSL certificate expiry.")
parser.add_argument("hosts", nargs="*", help="Domains to check (host[:port])")
parser.add_argument("-f", "--file", help="File with one host[:port] per line")
parser.add_argument(
"-p", "--port", type=int, default=443, help="Default port (default: 443)"
)
parser.add_argument(
"-t", "--timeout", type=int, default=10, help="Timeout seconds (default: 10)"
)
parser.add_argument(
"-w",
"--warn",
type=int,
default=30,
help="Warn threshold in days (default: 30)",
)
parser.add_argument(
"-s", "--sans", action="store_true", help="Show Subject Alternative Names"
)
parser.add_argument("--no-color", action="store_true", help="Disable color output")
args = parser.parse_args()
entries: list[tuple[str, int]] = []
for raw in args.hosts:
h, _, p = raw.partition(":")
entries.append((h, int(p) if p else args.port))
if args.file:
for line in open(args.file):
line = line.strip()
if line and not line.startswith("#"):
h, _, p = line.partition(":")
entries.append((h, int(p) if p else args.port))
if not entries:
parser.print_help()
sys.exit(1)
results = []
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {
pool.submit(check_cert, h, p, args.timeout): (h, p) for h, p in entries
}
for f in as_completed(futures):
results.append(f.result())
results.sort(key=lambda r: r.get("days_left", 9999))
for r in results:
if r["error"]:
c = "" if args.no_color else ANSI["err"]
print(f"{c}ERR {r['host']}:{r['port']} {r['error']}{RESET}")
else:
d = r["days_left"]
tag = "OK " if d >= args.warn else ("WARN" if d >= 0 else "EXP ")
c = status_color(d, args.no_color)
print(
f"{c}{tag} {r['host']}:{r['port']} {d:>4}d expires {r['expires']} CN={r['cn']}{RESET}"
)
if args.sans and r["sans"]:
dim = "" if args.no_color else ANSI["dim"]
print(f"{dim} SANs: {', '.join(r['sans'][:6])}{RESET}")