Initial commit: Basic CLI and checks

Certo supports polling one or several hostnames.
Output can be human-readable or JSON.
This commit is contained in:
2022-10-09 20:47:57 +10:30
commit d3041f46aa
10 changed files with 474 additions and 0 deletions

1
certo/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.1.0"

36
certo/__main__.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Usage: certo [-v] [-j] <hostnames>... [-d DAYS|--days-to-expiration=DAYS] [-t SECONDS|--timeout=SECONDS]
Options:
-v Increase verbosity [default: False]
-j Output in JSON format [default: False]
-d DAYS --days-to-expiration=DAYS Warn about impending expiration if within DAYS of the cert's notAfter [default: 5]
-t SECONDS --timeout=SECONDS Timeout for SSL Handshake [default: 5]
"""
import logging
from pprint import pprint
from docopt import docopt
from certo.checks.hostname import check_host_certificate_expiration
from certo.output import default_output, json_output
if __name__ == "__main__":
args = docopt(__doc__)
output_as_json = args.get("-j")
if args.get("-v"):
logging.getLogger().setLevel(logging.INFO)
hostnames = args.get("<hostnames>")
days_to_expiration = int(args.get("--days-to-expiration"))
results = []
for hs in hostnames:
logging.info(f"Getting CERT from {hs}")
results.append(check_host_certificate_expiration(hs, days_to_expiration))
if output_as_json:
print(json_output(results))
else:
print(default_output(results))

0
certo/checks/__init__.py Normal file
View File

44
certo/checks/hostname.py Normal file
View File

@@ -0,0 +1,44 @@
import datetime
from collections import namedtuple
from dateutil.parser import parse as dtparse
import socket
import ssl
CertCheckResult = namedtuple(
"CertCheckResult", ["hostname", "check_successful", "expiration_date", "debug"]
)
def get_cert(hostname, timeout):
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname=hostname) as s:
s.settimeout(timeout)
s.connect((hostname, 443))
return s.getpeercert()
def check_host_certificate_expiration(hostname, days_to_expiration, timeout=5):
try:
cert = get_cert(hostname, timeout)
except ssl.SSLCertVerificationError as e:
return CertCheckResult(hostname, False, None, e.strerror)
expdate = dtparse(cert.get("notAfter"))
curdate = datetime.datetime.now(tz=expdate.tzinfo)
if expdate - curdate < datetime.timedelta(days=days_to_expiration):
return CertCheckResult(
hostname,
False,
expdate,
f"Certificate expires in {(expdate - curdate).days} days - expected more than {days_to_expiration}",
)
return CertCheckResult(
hostname,
True,
expdate,
f"Certificate expires in {(expdate - curdate).days} days",
)

34
certo/output.py Normal file
View File

@@ -0,0 +1,34 @@
import json
from typing import List
from certo.checks.hostname import CertCheckResult
def json_output(cert_check_results: List[CertCheckResult]):
def make_check_serialisable(check):
"""
Converts a CertCheckResult for json serialisation
:param check: CertCheckResult as output by checks
:return: check as dict() with appropriate type conversions for json.dumps
"""
ret = check._asdict()
if check.expiration_date:
ret["expiration_date"] = check.expiration_date.strftime("%c %Z")
return ret
return json.dumps(
list(map(lambda check: make_check_serialisable(check), cert_check_results)),
indent=4,
)
def default_output(cert_check_results: List[CertCheckResult]):
res = list()
for check in cert_check_results:
result = f"[{'PASS' if check.check_successful else 'FAIL'}] Check host {check.hostname}"
if check.debug:
result += f" - {check.debug}"
if check.expiration_date:
result += f" - Certificate expires on {check.expiration_date} {check.expiration_date.tzname()}"
res.append(result)
return "\n".join(res)