From ee12fe59de8193eab6c911d63484eeccb0ef4136 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/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 154 bytes certo/__pycache__/__main__.cpython-310.pyc | Bin 0 -> 1242 bytes certo/__pycache__/output.cpython-310.pyc | Bin 0 -> 1428 bytes certo/checks/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 140 bytes .../__pycache__/hostname.cpython-310.pyc | Bin 0 -> 1304 bytes certo/checks/hostname.py | 44 +++ certo/output.py | 34 ++ poetry.lock | 324 ++++++++++++++++++ pyproject.toml | 18 + tests/__init__.py | 0 tests/test_certo.py | 5 + 15 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/__pycache__/__init__.cpython-310.pyc create mode 100644 certo/__pycache__/__main__.cpython-310.pyc create mode 100644 certo/__pycache__/output.cpython-310.pyc create mode 100644 certo/checks/__init__.py create mode 100644 certo/checks/__pycache__/__init__.cpython-310.pyc create mode 100644 certo/checks/__pycache__/hostname.cpython-310.pyc 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/__pycache__/__init__.cpython-310.pyc b/certo/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1abc29d349e6ffe580f6933de9f7ed0a5c037ffb GIT binary patch literal 154 zcmd1j<>g`kg70-s$znkIF^Gc<7=auIATH(r5-AK(3@MDk44O<;tOk09dIo-)j8WY2 z@nxw+#hLke@$oAeia;8`#4io~jQreG{lucO%p85^{FGGvZGyl8suV>T2n3@YNh|LzHzcLR zMsEI$pn!GkPxK#p-D83tdg%xB)Zxl*)FKUmot+uZaNax)_4al|aQ*(~{`l8`kaynL zTn#kt;%DEYh>0LEjaf1H^X9{6|N`T&7h+9OUrZZ?oAw+jpE;|yZDDXtoXCn!~VeOB`5o|Y!rn_a_6;Jk9Fa-FUB(%pGtqX(%*P8Nyf^7e2;&h zl{;4~OY9MOE4SuW=hsFkWu}r`%5&D6;W-u)Jw^E@3r`2H!M7@hI` z&&#Hi2uw=RO!ah%+lC=@Q$rzcR`b}e;M)H6EE;^bM6}m?YjmdWbNt&a5t<)ZDa$~a zsV!?$aAId2CY6=G4bej96D$Wtgcem;LMnnsCl3drbzKHikvP%ylP;a!U`I3!SR6nr z*vytel23HmP=z0ZvR={s3o#v!$cw+eI+*DU4wP9Y>A{hX;h=(kC9j$67iVR7E7S2J qg**5piTxTyK*Cm=wpp8XXvjhuv50nPM0aSwLiBa<-=Q5A{P{PoxMhL> literal 0 HcmV?d00001 diff --git a/certo/__pycache__/output.cpython-310.pyc b/certo/__pycache__/output.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be289e7fc4fdf2dee0a9881c7f3384ad798b504c GIT binary patch literal 1428 zcmZ`(TW=gS6t+E{*&)f25?W9ZNCv4@M}m#|gcL;)Wf20YM2NgCs#cS+x0!Zs*q+j? zCQpe3Jn##uNSnw08s7cNQ~yF95a)O{H!hau*#2~GKIc1A4~HuR)_130W~ZExKk;&X z1#tNSKJyI-NhB@E1b0@@l1&)Jb5H~&pKv&H#T36wW=w`MI^mP>8ZwYEAd#edBzfx% zcq6+Bb^LK|EbQxJrS14YWzT<5rY)@7$N-pTh)lJIOh(aax$qpphY6YoAS9LSg#1P< zc}6X>!7pS+Wgz)U+>v8;OfjY383Z5f-f+a0PWI5zCm=eqAZO%=cHl^_qg*ga&u9k$ zwn>Ni2lGEx#Bz=6>II}~L@NIA1~IF(cxLKK7^U;H$W3bVy1KO7YEqq+!oP9rU&g5s zb!(f}iv7c0$6Q%zWm{cs^|%04nP>JvB7V;8fk>N1*9}CpO4!3jiOi>HAhx;8X(ZdS zx$-y>o604&D1@`dlsBds&_RLdDnx6mb;MTa?rJr&z)1(sH|~ zv$QZfqyI6#(OIMjcK6*2pT9zHkFkGr*JqnhjiFvsy#||y^eUWOyj`T_zD##+qbd4` z$^4`LOyolI4NMO>c2QnQRoR3)j`}Y2b#@%OZ(UHP4FpBiKH3mZKIG~(?*l*$mNTY& z^)_WR`b*z|>y6i;6I2|-XI_G^)kRJk-&_~g`kf)GBZWDxxrL?8o3AjbiSi&=m~3PUi1CZpdzBZnS_i9T6qRbE;x|xWX{qRUZX?i3%`jAM4-5IPnq<7g&h~6^kwjp ziH_{aj_ke!(UmY|QU5JwX3rw1yLr4+(w3`C;m%vd+NgSXth7BoQ_1^>SC4Z*rr83ld)B`C)JPTX~OA{{h&96IFE6fA5d6!4L5Ad zE56{?xpr zZ&%SZ%Gz|PiqeVzBXgBtsvZ8aZucj02Sg^ zgYxgFYyyk8R(!{|&I!vOxXi+WUw#a1>jK*htV5_vR=Epz=bkVh5tQir$(`=kHLRKS zunxY&8th=}*oh@rJxfpo9noav`+-pZ3+>s|C-GwE0Gn!Uku zrmXP?|MQ1$9P&k-XHP_!7xt^URT|64?FPB&%J|Z3?h`fBd75D9#trY38K?Pp^I%N= z@#kd3;wU*@7Fvz%S)7lbZ@k7E^&8wY!6c5R)2Ec|JEhxvNZ)U?F0>}D)}gUl(Y6kx zZQH`ZlGGtFoQf=*yjTn0Zqj^Shz^;g%51Dh_VdgUw90o9g4YDzEKo4o@=|f!VUQ%QkLWejQO}#6Ibf40u*~10^t%+;;9trB@2FC`i7FNo6 l=k~-|-8>qDQ1JOqx@eII&*&cE|dOCJCL literal 0 HcmV?d00001 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"