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:
12
README.rst
Normal file
12
README.rst
Normal file
@@ -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
|
||||
1
certo/__init__.py
Normal file
1
certo/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
36
certo/__main__.py
Normal file
36
certo/__main__.py
Normal 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
0
certo/checks/__init__.py
Normal file
44
certo/checks/hostname.py
Normal file
44
certo/checks/hostname.py
Normal 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
34
certo/output.py
Normal 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)
|
||||
324
poetry.lock
generated
Normal file
324
poetry.lock
generated
Normal file
@@ -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 = []
|
||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[tool.poetry]
|
||||
name = "certo"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Guilhem MARION <gmarion@netc.fr>"]
|
||||
|
||||
[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"
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
5
tests/test_certo.py
Normal file
5
tests/test_certo.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from certo import __version__
|
||||
|
||||
|
||||
def test_version():
|
||||
assert __version__ == "0.1.0"
|
||||
Reference in New Issue
Block a user