diff --git a/.pylintrc b/.pylintrc index 4bb4f8c..d3ee538 100644 --- a/.pylintrc +++ b/.pylintrc @@ -45,7 +45,7 @@ limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. -load-plugins=pylint_django +load-plugins= # Pickle collected data for later comparisons. persistent=yes diff --git a/checkcert/checkcert.py b/checkcert/checkcert.py index 008c0c6..7b0fb9e 100644 --- a/checkcert/checkcert.py +++ b/checkcert/checkcert.py @@ -1,15 +1,14 @@ -# 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 +""" CLI to get basic information about certificates and determine validity""" +from collections import namedtuple +import concurrent.futures +from socket import socket import click 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.2.0" @@ -19,25 +18,23 @@ HostInfo = namedtuple( def get_certificate(hostname, port): + """retreive certificate details and return HostInfo tuple of values""" 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 + 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() return HostInfo( cert=crypto_cert, @@ -48,6 +45,7 @@ def get_certificate(hostname, port): def get_alt_names(cert): + """retreive the SAN values for given cert""" try: ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) return ext.value.get_values_for_type(x509.DNSName) @@ -56,10 +54,12 @@ def get_alt_names(cert): def get_x509_text(cert): + """return the human-readable text version of the certificate""" return crypto.dump_certificate(crypto.FILETYPE_TEXT, cert) def get_common_name(cert): + """Retrun the common name from the certificate""" try: names = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) return names[0].value @@ -68,6 +68,7 @@ def get_common_name(cert): def get_issuer(cert): + """Return the name of the CA/Issuer of the certificate""" try: names = cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME) return names[0].value @@ -75,62 +76,56 @@ def get_issuer(cert): 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} - """ - ) - - @click.command() @click.version_option(__version__, prog_name="checkcert") @click.option("--san", is_flag=True, help="Output Subject Alternate Names") @click.option( "--dump", is_flag=True, help="Dump the full text version of the x509 certificate" ) -@click.option("--color/--no-color", default=True) +@click.option( + "--color/--no-color", + default=True, + help="Enable/disable ANSI color output to show cert validity", +) @click.argument("hosts", nargs=-1) def main(san, dump, color, hosts): + """Return information about certificates given including their validity""" # setup the list of tuples - HOSTS = [] + all_hosts = [] # handle a domain given with a : in it to specify the port 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]))) + all_hosts.append((host_info[0], int(host_info[1]))) else: - HOSTS.append((host, 443)) - for hostinfo in map(lambda x: get_certificate(x[0], x[1]), HOSTS): - output_string = "" - 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" - if hostinfo.is_valid and color: - click.echo(click.style(output_string, fg="green")) - elif not hostinfo.is_valid and color: - click.echo(click.style(output_string, fg="red")) + all_hosts.append((host, 443)) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as epool: + for hostinfo in epool.map(lambda x: get_certificate(x[0], x[1]), all_hosts): + output_string = "" + if dump: + print(get_x509_text(hostinfo.cert).decode()) else: - click.echo(click.style(output_string)) + output_string += ( + f"{hostinfo.hostname} " + f"({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" + if hostinfo.is_valid and color: + click.echo(click.style(output_string, fg="green")) + elif not hostinfo.is_valid and color: + click.echo(click.style(output_string, fg="red")) + else: + click.echo(click.style(output_string)) # print(f"Certificate for {domain}\nexpires after: {x509.get_not_after()}") if __name__ == "__main__": - main() # pragma: no cover + main() # pylint: disable=no-value-for-parameter # pragma: no cover