diff --git a/checkcert/checkcert.py b/checkcert/checkcert.py index 4d8e637..78f69b9 100644 --- a/checkcert/checkcert.py +++ b/checkcert/checkcert.py @@ -1,10 +1,97 @@ -import ssl -import sys +# heavily modified version of https://gist.githubusercontent.com/gdamjan/55a8b9eec6cf7b771f92021d93b87b2c/raw/d8dc194ec4d0187f985a57138019d04e3a59b51f/ssl-check.py +# to give a CLI for passing the hosts to check and other optional output import click -import M2Crypto +from OpenSSL import SSL +from OpenSSL import crypto +from cryptography import x509 +from cryptography.x509.oid import NameOID +import idna +import sys + +from socket import socket +from collections import namedtuple __version__ = "0.1.0" +HostInfo = namedtuple(field_names="cert hostname peername", typename="HostInfo") + + +def verify_cert(cert, hostname): + # verify notAfter/notBefore, CA trusted, servername/sni/hostname + cert.has_expired() + # service_identity.pyopenssl.verify_hostname(client_ssl, hostname) + # issuer + + +def get_certificate(hostname, port): + hostname_idna = idna.encode(hostname) + sock = socket() + + try: + sock.connect((hostname, port)) + peername = sock.getpeername() + ctx = SSL.Context(SSL.SSLv23_METHOD) # most compatible + ctx.check_hostname = False + ctx.verify_mode = SSL.VERIFY_NONE + sock_ssl = SSL.Connection(ctx, sock) + sock_ssl.set_connect_state() + sock_ssl.set_tlsext_host_name(hostname_idna) + sock_ssl.do_handshake() + cert = sock_ssl.get_peer_certificate() + crypto_cert = cert.to_cryptography() + sock_ssl.close() + sock.close() + except ConnectionRefusedError: + pass + + return HostInfo(cert=crypto_cert, peername=peername, hostname=hostname) + + +def get_alt_names(cert): + try: + ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + return ext.value.get_values_for_type(x509.DNSName) + except x509.ExtensionNotFound: + return None + + +def get_x509_text(cert): + return crypto.dump_certificate(crypto.FILETYPE_TEXT, cert) + + +def get_common_name(cert): + try: + names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + return names[0].value + except x509.ExtensionNotFound: + return None + + +def get_issuer(cert): + try: + names = cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME) + return names[0].value + except x509.ExtensionNotFound: + return None + + +def print_basic_info(hostinfo): + print( + f""" +{hostinfo.hostname} ({hostinfo.peername[0]}:{hostinfo.peername[1]}) +\tcommonName: {get_common_name(hostinfo.cert)} +\tSAN: {get_alt_names(hostinfo.cert)} +\tissuer: {get_issuer(hostinfo.cert)} +\tnotBefore: {hostinfo.cert.not_valid_before} +\tnotAfter: {hostinfo.cert.not_valid_after} + """ + ) + + +def check_it_out(hostname, port): + hostinfo = get_certificate(hostname, port) + print_basic_info(hostinfo) + @click.command() @click.version_option(__version__, prog_name="checkcert") @@ -12,29 +99,36 @@ __version__ = "0.1.0" @click.option( "--dump", is_flag=True, help="Dump the full text version of the x509 certificate" ) -@click.option( - "--port", default=443, type=int, help="TCP port to connect to (default 443)" -) @click.option("--expires", is_flag=True, help="Display the expiration date") -@click.argument("domain") -def main(san, dump, port, expires, domain): +@click.argument("hosts", nargs=-1) +def main(san, dump, expires, hosts): + # setup the list of tuples + HOSTS = [] # handle a domain given with a : in it to specify the port - if ":" in domain: - uri = domain.split(":") - domain = uri[0] - port = uri[1] - cert = ssl.get_server_certificate((domain, port)) - x509 = M2Crypto.X509.load_cert_string(cert) - if dump: - print(x509.as_text()) - sys.exit() - if san: - all_sans = x509.get_ext("subjectAltName").get_value() - sans = all_sans.split(",") - for san in sans: - print(str(san).strip().removeprefix("DNS:")) - print(x509.get_subject().as_text()) - print(f"Certificate for {domain}\nexpires after: {x509.get_not_after()}") + for host in hosts: + # if a host has a : in it, split on the :, first field will be host + # second field will be the port + if ":" in host: + host_info = host.split(":") + HOSTS.append((host_info[0], int(host_info[1]))) + else: + HOSTS.append((host, 443)) + output_string = "" + for hostinfo in map(lambda x: get_certificate(x[0], x[1]), HOSTS): + if dump: + print(get_x509_text(hostinfo.cert).decode()) + else: + output_string += ( + f"{hostinfo.hostname} ({hostinfo.peername[0]}:{hostinfo.peername[1]})\n" + ) + output_string += f"\tcommonName: {get_common_name(hostinfo.cert)}\n" + if san: + output_string += f"\tSAN: {get_alt_names(hostinfo.cert)}\n" + output_string += f"\tissuer: {get_issuer(hostinfo.cert)}\n" + output_string += f"\tnotBefore: {hostinfo.cert.not_valid_before}\n" + output_string += f"\tnotAfter: {hostinfo.cert.not_valid_after}\n\n" + print(output_string) + # print(f"Certificate for {domain}\nexpires after: {x509.get_not_after()}") if __name__ == "__main__": diff --git a/poetry.lock b/poetry.lock index e20acc0..ba56da6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -58,7 +58,7 @@ python-versions = "*" name = "cffi" version = "1.14.6" description = "Foreign Function Interface for Python calling C code." -category = "dev" +category = "main" optional = false python-versions = "*" @@ -121,7 +121,7 @@ toml = ["toml"] name = "cryptography" version = "3.4.8" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -336,7 +336,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "pycparser" version = "2.20" description = "C parser in Python" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -373,6 +373,22 @@ platformdirs = ">=2.2.0" toml = ">=0.7.1" typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} +[[package]] +name = "pyopenssl" +version = "21.0.0" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + +[package.dependencies] +cryptography = ">=3.3" +six = ">=1.5.2" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + [[package]] name = "pyparsing" version = "2.4.7" @@ -548,7 +564,7 @@ toml = ["setuptools (>=42)", "tomli (>=1.0.0)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -671,7 +687,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "c1ffdd5000aa0956b993deef3eefa5f636b041a5c5a4a35b56c638d397f82bbf" +content-hash = "f9e3c5502f1aa3c91b8c2a76f7f4c14d39cb84281143d987cf9e8ed043efa175" [metadata.files] astroid = [ @@ -938,6 +954,10 @@ pylint = [ {file = "pylint-2.11.1-py3-none-any.whl", hash = "sha256:0f358e221c45cbd4dad2a1e4b883e75d28acdcccd29d40c76eb72b307269b126"}, {file = "pylint-2.11.1.tar.gz", hash = "sha256:2c9843fff1a88ca0ad98a256806c82c5a8f86086e7ccbdb93297d86c3f90c436"}, ] +pyopenssl = [ + {file = "pyOpenSSL-21.0.0-py2.py3-none-any.whl", hash = "sha256:8935bd4920ab9abfebb07c41a4f58296407ed77f04bd1a92914044b848ba1ed6"}, + {file = "pyOpenSSL-21.0.0.tar.gz", hash = "sha256:5e2d8c5e46d0d865ae933bef5230090bdaf5506281e9eec60fa250ee80600cb3"}, +] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, diff --git a/pyproject.toml b/pyproject.toml index db72c66..98af5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ authors = ["Alex Kelly "] [tool.poetry.dependencies] python = "^3.9" click = "^8.0.1" +pyOpenSSL = "^21.0.0" [tool.poetry.dev-dependencies] python-semantic-release = "^7.19.2"