From d3041f46aa86f8f105ae5947433818d4eedd6e37 Mon Sep 17 00:00:00 2001 From: Guilhem MARION Date: Sun, 9 Oct 2022 20:47:57 +1030 Subject: [PATCH] Initial commit: Basic CLI and checks Certo supports polling one or several hostnames. Output can be human-readable or JSON. --- README.rst | 12 ++ certo/__init__.py | 1 + certo/__main__.py | 36 +++++ certo/checks/__init__.py | 0 certo/checks/hostname.py | 44 ++++++ certo/output.py | 34 ++++ poetry.lock | 324 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 18 +++ tests/__init__.py | 0 tests/test_certo.py | 5 + 10 files changed, 474 insertions(+) create mode 100644 README.rst create mode 100644 certo/__init__.py create mode 100644 certo/__main__.py create mode 100644 certo/checks/__init__.py create mode 100644 certo/checks/hostname.py create mode 100644 certo/output.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_certo.py diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5625957 --- /dev/null +++ b/README.rst @@ -0,0 +1,12 @@ +# Certo - An SSL Certificate Checker + +Certo checks one or several servers' SSL certificates for validity and impending expiration. It is made to be called regularly by a CRON job or CI system such as Jenkins and polls hostnames for SSL Certificates on port 443 and reports on their status : + +* Certificate is valid +* Certificate is valid, but expires soon (configurable) +* Certificate could not be validated + +## TODO + +Support for custom CAs +Example CRON/Jenkins jobs diff --git a/certo/__init__.py b/certo/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/certo/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/certo/__main__.py b/certo/__main__.py new file mode 100644 index 0000000..854f469 --- /dev/null +++ b/certo/__main__.py @@ -0,0 +1,36 @@ +""" +Usage: certo [-v] [-j] ... [-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("") + 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)) diff --git a/certo/checks/__init__.py b/certo/checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/certo/checks/hostname.py b/certo/checks/hostname.py new file mode 100644 index 0000000..da62b32 --- /dev/null +++ b/certo/checks/hostname.py @@ -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", + ) diff --git a/certo/output.py b/certo/output.py new file mode 100644 index 0000000..a1c0395 --- /dev/null +++ b/certo/output.py @@ -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) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..ddf45cd --- /dev/null +++ b/poetry.lock @@ -0,0 +1,324 @@ +[[package]] +name = "aiohttp" +version = "3.8.3" +description = "Async http client/server framework (asyncio)" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "22.1.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "22.10.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +aiohttp = { version = ">=3.7.4", optional = true, markers = "extra == \"d\"" } +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = { version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\"" } + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = { version = "*", markers = "platform_system == \"Windows\"" } + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "frozenlist" +version = "1.3.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "multidict" +version = "6.0.2" +description = "multidict implementation" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.10.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] +test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +testing = ["pytest-benchmark", "pytest"] +dev = ["tox", "pre-commit"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = { version = ">=1.0", markers = "sys_platform == \"win32\"" } +attrs = ">=19.2.0" +colorama = { version = "*", markers = "sys_platform == \"win32\"" } +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "yarl" +version = "1.8.1" +description = "Yet another URL library" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "6691e6e08458b93888e890c61a8e5fff39e4c9e197b0696130ca14d3aeb4a3d9" + +[metadata.files] +aiohttp = [] +aiosignal = [] +async-timeout = [] +atomicwrites = [] +attrs = [] +black = [] +charset-normalizer = [] +click = [] +colorama = [] +docopt = [] +frozenlist = [] +idna = [] +iniconfig = [] +multidict = [] +mypy-extensions = [] +packaging = [] +pathspec = [] +platformdirs = [] +pluggy = [] +py = [] +pyparsing = [] +pytest = [] +python-dateutil = [] +six = [] +toml = [] +tomli = [] +yarl = [] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..87d579a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "certo" +version = "0.1.0" +description = "" +authors = ["Guilhem MARION "] + +[tool.poetry.dependencies] +python = "^3.10" +python-dateutil = "^2.8.2" +docopt = "^0.6.2" + +[tool.poetry.dev-dependencies] +pytest = "^6.2" +black = { extras = ["d"], version = "^22.10.0" } + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_certo.py b/tests/test_certo.py new file mode 100644 index 0000000..0edabd7 --- /dev/null +++ b/tests/test_certo.py @@ -0,0 +1,5 @@ +from certo import __version__ + + +def test_version(): + assert __version__ == "0.1.0"