From bcfac1a7c8724d64e24b15df971c0ca6ef29f787 Mon Sep 17 00:00:00 2001 From: Pawel Dulak Date: Tue, 11 Feb 2025 09:03:29 +0100 Subject: [PATCH 1/5] feat: allow txt file as the source of the list of domains --- .gitignore | 2 ++ check-certificates.py | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 6b0159c..76e574c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea *.iml *.pyc +*.csv +*.txt \ No newline at end of file diff --git a/check-certificates.py b/check-certificates.py index de2e30c..c084b72 100644 --- a/check-certificates.py +++ b/check-certificates.py @@ -5,6 +5,7 @@ import datetime import concurrent.futures import math +import argparse DEFAULT_HTTPS_PORT = 443 @@ -106,12 +107,22 @@ def check_certificates(endpoints): exit_code = get_exit_code(err_count, min_days) sys.exit(exit_code) -if __name__ == '__main__': - endpoints = sys.argv[1:] +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--domains-file", help="File with one domain per line") + parser.add_argument("endpoints", nargs="*", help="Endpoints (host:port or host)") + args = parser.parse_args() - if len(endpoints): - check_certificates(endpoints) + if args.domains_file: + with open(args.domains_file, "r") as f: + endpoints = [line.strip() for line in f if line.strip()] else: - print('Usage: {} '.format(sys.argv[0])) + endpoints = args.endpoints + + if not endpoints: sys.exit(EXIT_NO_HOST_LIST) + check_certificates(endpoints) + +if __name__ == "__main__": + main() From c760a49108768cddcffb652db50fe1f1e3ab2328 Mon Sep 17 00:00:00 2001 From: Pawel Dulak Date: Tue, 11 Feb 2025 09:13:19 +0100 Subject: [PATCH 2/5] feat: show issuer info --- check-certificates.py | 49 +++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/check-certificates.py b/check-certificates.py index c084b72..77f9774 100644 --- a/check-certificates.py +++ b/check-certificates.py @@ -26,14 +26,20 @@ def make_host_port_pair(endpoint): def pluralise(singular, count): return '{} {}{}'.format(count, singular, '' if count == 1 else 's') -def get_certificate_expiry_date_time(context, host, port): +def get_certificate_expiry_and_issuer(context, host, port): with socket.create_connection((host, port), SOCKET_CONNECTION_TIMEOUT_SECONDS) as tcp_socket: with context.wrap_socket(tcp_socket, server_hostname=host) as ssl_socket: - # certificate_info is a dict with lots of information about the certificate - certificate_info = ssl_socket.getpeercert() - exp_date_text = certificate_info['notAfter'] - return datetime.datetime.fromtimestamp(ssl.cert_time_to_seconds(exp_date_text), datetime.timezone.utc) - + cert = ssl_socket.getpeercert() + exp_date_text = cert['notAfter'] + expiry = datetime.datetime.fromtimestamp(ssl.cert_time_to_seconds(exp_date_text), datetime.timezone.utc) + issuer_blocks = cert.get('issuer', []) + org_name = None + for block in issuer_blocks: + for k, v in block: + if k == 'organizationName': + org_name = v + break + return expiry, org_name def format_time_remaining(time_remaining): day_count = time_remaining.days @@ -71,38 +77,48 @@ def get_exit_code(err_count, min_days): def format_host_port(host, port): return host + ('' if port == DEFAULT_HTTPS_PORT else ':{}'.format(port)) -def check_certificates(endpoints): +def check_certificates(endpoints, show_issuer): context = ssl.create_default_context() host_port_pairs = [make_host_port_pair(endpoint) for endpoint in endpoints] with concurrent.futures.ThreadPoolExecutor(max_workers=WORKER_THREAD_COUNT) as executor: futures = { - executor.submit(get_certificate_expiry_date_time, context, host, port): + executor.submit(get_certificate_expiry_and_issuer, context, host, port): (host, port) for host, port in host_port_pairs } endpoint_count = len(endpoints) err_count = 0 min_days = math.inf - max_host_port_len = max([len(format_host_port(host, port)) for host, port in host_port_pairs]) + max_host_port_len = max(len(format_host_port(host, port)) for host, port in host_port_pairs) print('Checking {}...'.format(pluralise('endpoint', endpoint_count))) + for future in concurrent.futures.as_completed(futures): host, port = futures[future] try: - expiry_time = future.result() + expiry, issuer = future.result() except Exception as ex: err_count += 1 print('{} ERROR {}'.format(format_host_port(host, port).ljust(max_host_port_len), ex)) else: - time_remaining = expiry_time - datetime.datetime.now(datetime.timezone.utc) + time_remaining = expiry - datetime.datetime.now(datetime.timezone.utc) time_remaining_txt = format_time_remaining(time_remaining) days_remaining = time_remaining.days min_days = min(min_days, days_remaining) - print('{} {:<5} expires in {}'.format( - format_host_port(host, port).ljust(max_host_port_len), - 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK', - time_remaining_txt)) + if show_issuer: + print('{} {:<5} expires in {} [Issuer: {}]'.format( + format_host_port(host, port).ljust(max_host_port_len), + 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK', + time_remaining_txt, + issuer if issuer else 'not found' + )) + else: + print('{} {:<5} expires in {}'.format( + format_host_port(host, port).ljust(max_host_port_len), + 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK', + time_remaining_txt + )) exit_code = get_exit_code(err_count, min_days) sys.exit(exit_code) @@ -110,6 +126,7 @@ def check_certificates(endpoints): def main(): parser = argparse.ArgumentParser() parser.add_argument("--domains-file", help="File with one domain per line") + parser.add_argument("--show-issuer", action="store_true", help="Display certificate supplier info") parser.add_argument("endpoints", nargs="*", help="Endpoints (host:port or host)") args = parser.parse_args() @@ -122,7 +139,7 @@ def main(): if not endpoints: sys.exit(EXIT_NO_HOST_LIST) - check_certificates(endpoints) + check_certificates(endpoints, args.show_issuer) if __name__ == "__main__": main() From cb8137d7a9f78dfdd61d6c8dfd91721affdde9d2 Mon Sep 17 00:00:00 2001 From: Pawel Dulak Date: Tue, 11 Feb 2025 09:19:15 +0100 Subject: [PATCH 3/5] feat: csv output mode --- check-certificates.py | 48 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/check-certificates.py b/check-certificates.py index 77f9774..1eeccac 100644 --- a/check-certificates.py +++ b/check-certificates.py @@ -77,21 +77,23 @@ def get_exit_code(err_count, min_days): def format_host_port(host, port): return host + ('' if port == DEFAULT_HTTPS_PORT else ':{}'.format(port)) -def check_certificates(endpoints, show_issuer): +def check_certificates(endpoints, show_issuer, csv_output): context = ssl.create_default_context() host_port_pairs = [make_host_port_pair(endpoint) for endpoint in endpoints] with concurrent.futures.ThreadPoolExecutor(max_workers=WORKER_THREAD_COUNT) as executor: futures = { - executor.submit(get_certificate_expiry_and_issuer, context, host, port): - (host, port) for host, port in host_port_pairs + executor.submit(get_certificate_expiry_and_issuer, context, host, port): (host, port) + for host, port in host_port_pairs } endpoint_count = len(endpoints) err_count = 0 min_days = math.inf max_host_port_len = max(len(format_host_port(host, port)) for host, port in host_port_pairs) - print('Checking {}...'.format(pluralise('endpoint', endpoint_count))) + + if not csv_output: + print('Checking {}...'.format(pluralise('endpoint', endpoint_count))) for future in concurrent.futures.as_completed(futures): host, port = futures[future] @@ -99,26 +101,33 @@ def check_certificates(endpoints, show_issuer): expiry, issuer = future.result() except Exception as ex: err_count += 1 - print('{} ERROR {}'.format(format_host_port(host, port).ljust(max_host_port_len), ex)) + if csv_output: + print(f"{host},{port},ERROR,,\"{ex}\",\"\"") + else: + print('{} ERROR {}'.format(format_host_port(host, port).ljust(max_host_port_len), ex)) else: time_remaining = expiry - datetime.datetime.now(datetime.timezone.utc) time_remaining_txt = format_time_remaining(time_remaining) days_remaining = time_remaining.days min_days = min(min_days, days_remaining) - - if show_issuer: - print('{} {:<5} expires in {} [Issuer: {}]'.format( - format_host_port(host, port).ljust(max_host_port_len), - 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK', - time_remaining_txt, - issuer if issuer else 'not found' - )) + status = 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK' + issuer_text = issuer if issuer else 'not found' + if csv_output: + print(f"{host},{port},{status},{days_remaining},\"{time_remaining_txt}\",\"{issuer_text}\"") else: - print('{} {:<5} expires in {}'.format( - format_host_port(host, port).ljust(max_host_port_len), - 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK', - time_remaining_txt - )) + if show_issuer: + print('{} {:<5} expires in {} [Issuer: {}]'.format( + format_host_port(host, port).ljust(max_host_port_len), + status, + time_remaining_txt, + issuer_text + )) + else: + print('{} {:<5} expires in {}'.format( + format_host_port(host, port).ljust(max_host_port_len), + status, + time_remaining_txt + )) exit_code = get_exit_code(err_count, min_days) sys.exit(exit_code) @@ -127,6 +136,7 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--domains-file", help="File with one domain per line") parser.add_argument("--show-issuer", action="store_true", help="Display certificate supplier info") + parser.add_argument("--csv", action="store_true", help="Output in CSV format, always includes issuer info") parser.add_argument("endpoints", nargs="*", help="Endpoints (host:port or host)") args = parser.parse_args() @@ -139,7 +149,7 @@ def main(): if not endpoints: sys.exit(EXIT_NO_HOST_LIST) - check_certificates(endpoints, args.show_issuer) + check_certificates(endpoints, args.show_issuer, args.csv) if __name__ == "__main__": main() From 8e966286c92114855740aadc9e1c2d3bfee67f77 Mon Sep 17 00:00:00 2001 From: Pawel Dulak Date: Tue, 11 Feb 2025 09:34:18 +0100 Subject: [PATCH 4/5] feat: warn days as a parameter --- check-certificates.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/check-certificates.py b/check-certificates.py index 1eeccac..43b90e5 100644 --- a/check-certificates.py +++ b/check-certificates.py @@ -63,13 +63,12 @@ def format_time_remaining(time_remaining): pluralise('min', minutes) ) -def get_exit_code(err_count, min_days): +def get_exit_code(err_count, min_days, warn_days): code = EXIT_SUCCESS - if err_count: code += EXIT_ERROR - if min_days < WARN_IF_DAYS_LESS_THAN: + if min_days < warn_days: code += EXIT_EXPIRING_SOON return code @@ -77,7 +76,7 @@ def get_exit_code(err_count, min_days): def format_host_port(host, port): return host + ('' if port == DEFAULT_HTTPS_PORT else ':{}'.format(port)) -def check_certificates(endpoints, show_issuer, csv_output): +def check_certificates(endpoints, show_issuer, csv_output, warn_days): context = ssl.create_default_context() host_port_pairs = [make_host_port_pair(endpoint) for endpoint in endpoints] @@ -87,13 +86,12 @@ def check_certificates(endpoints, show_issuer, csv_output): for host, port in host_port_pairs } - endpoint_count = len(endpoints) err_count = 0 min_days = math.inf - max_host_port_len = max(len(format_host_port(host, port)) for host, port in host_port_pairs) + max_host_port_len = max(len(format_host_port(h, p)) for h, p in host_port_pairs) if not csv_output: - print('Checking {}...'.format(pluralise('endpoint', endpoint_count))) + print('Checking {}...'.format(pluralise('endpoint', len(endpoints)))) for future in concurrent.futures.as_completed(futures): host, port = futures[future] @@ -110,7 +108,7 @@ def check_certificates(endpoints, show_issuer, csv_output): time_remaining_txt = format_time_remaining(time_remaining) days_remaining = time_remaining.days min_days = min(min_days, days_remaining) - status = 'WARN' if days_remaining < WARN_IF_DAYS_LESS_THAN else 'OK' + status = 'WARN' if days_remaining < warn_days else 'OK' issuer_text = issuer if issuer else 'not found' if csv_output: print(f"{host},{port},{status},{days_remaining},\"{time_remaining_txt}\",\"{issuer_text}\"") @@ -129,14 +127,16 @@ def check_certificates(endpoints, show_issuer, csv_output): time_remaining_txt )) - exit_code = get_exit_code(err_count, min_days) + exit_code = get_exit_code(err_count, min_days, warn_days) sys.exit(exit_code) def main(): parser = argparse.ArgumentParser() parser.add_argument("--domains-file", help="File with one domain per line") parser.add_argument("--show-issuer", action="store_true", help="Display certificate supplier info") - parser.add_argument("--csv", action="store_true", help="Output in CSV format, always includes issuer info") + parser.add_argument("--csv", action="store_true", help="Output in CSV format") + parser.add_argument("--warn-days", type=int, default=WARN_IF_DAYS_LESS_THAN, + help="Override the default days threshold for warning") parser.add_argument("endpoints", nargs="*", help="Endpoints (host:port or host)") args = parser.parse_args() @@ -149,7 +149,7 @@ def main(): if not endpoints: sys.exit(EXIT_NO_HOST_LIST) - check_certificates(endpoints, args.show_issuer, args.csv) + check_certificates(endpoints, args.show_issuer, args.csv, args.warn_days) if __name__ == "__main__": main() From f4ecfade2b4938f9e876f4eb3171574c4de7aef5 Mon Sep 17 00:00:00 2001 From: Pawel Dulak Date: Tue, 11 Feb 2025 09:48:58 +0100 Subject: [PATCH 5/5] chore: README.md adjusted --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bed77b9..88daf01 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ To use the script simply run it from the command line, along with a list of the The script will list the status of each domain's certificate, displaying '`OK`' if the certificate was retrieved and is not expiring soon, '`WARN`' if the certificate's expiry date is getting close, or '`ERROR`' if the certificate has already expired, or if there is some other problem such as the host could not be found, or no certificate could be retrieved. -By default '`WARN`' will be displayed if there are less than 7 days until a certificate expires, but this interval can be changed by altering the value of the [WARN_IF_DAYS_LESS_THAN](https://github.com/codebox/https-certificate-expiry-checker/blob/main/check-certificates.py#L13) variable. +By default '`WARN`' will be displayed if there are less than 7 days until a certificate expires, but this interval can be changed by adding a `--warn-days` execution parameter If any of the domains are using a non-standard port for HTTPS then this should be specified using the usual notation of `host:port`, for example: @@ -31,5 +31,21 @@ The script returns an exit code indicating whether the checks passed or not, mak | Both of the previous conditions occurred | 3 | | No domain list was provided when running the script | 9 | -Certificate checks are performed in parallel, making the process of checking multiple domains much quicker. The number of concurrent checks that will be performed is determined by the value of the [WORKER_THREAD_COUNT](https://github.com/codebox/https-certificate-expiry-checker/blob/main/check-certificates.py#L11) variable. - \ No newline at end of file +Certificate checks are performed in parallel, making the process of checking multiple domains much quicker. The number of concurrent checks that will be performed is determined by the value of the [WORKER_THREAD_COUNT](https://github.com/codebox/https-certificate-expiry-checker/blob/main/check-certificates.py#L12) variable. + +## Optional Execution Parameters + +- `--domains-file` + Provide a file path containing one domain per line. This removes the need to pass endpoints on the command line. + +- `--csv` + Output results in CSV format, useful for parsing by other processes. No extra console messages will be printed. Always includes issuer info. + +- `--show-issuer` + Display the certificate's organization name (issuer) alongside its expiry info. + +- `--warn-days` + Override the default (7 days) warning threshold for expiring certificates. + +### Example: + > python check-certificates.py --domains-file domains.txt --show-issuer --csv --warn-days 10 \ No newline at end of file