From e27e5e2805d45093929525679478e3fbc8ea6ebc Mon Sep 17 00:00:00 2001 From: Jussi-Pekka Erkkila Date: Sat, 26 Oct 2024 00:48:44 +0300 Subject: [PATCH] json output format, fine-tuning cli output --- README.md | 5 ++- secheaders/constants.py | 3 +- secheaders/securityheaders.py | 71 ++++++++++++++++++++++++----------- secheaders/utils.py | 19 +++++++--- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index b3dbd6e..c5e42c3 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ $ pip install secheaders ## Usage ``` $ secheaders --help -usage: secheaders [-h] [--max-redirects N] [--insecure] [--verbose] URL +sage: secheaders [-h] [--max-redirects N] [--insecure] [--format FORMAT] [--verbose] URL -Check HTTP security headers +Scan HTTP security headers positional arguments: URL Target URL @@ -46,6 +46,7 @@ options: -h, --help show this help message and exit --max-redirects N Max redirects, set 0 to disable (default: 2) --insecure Do not verify TLS certificate chain (default: False) + --format FORMAT Output format, options: text, json (default: text) --verbose, -v Verbose output (default: False) ``` diff --git a/secheaders/constants.py b/secheaders/constants.py index 3a9c125..bbfd8cf 100644 --- a/secheaders/constants.py +++ b/secheaders/constants.py @@ -18,7 +18,8 @@ OK_COLOR = '\033[92m' END_COLOR = '\033[0m' WARN_COLOR = '\033[93m' -HEADER_OUTPUT_MAX_LEN = 40 # longer response headers are truncated in cli output, unless -v flag used +COLUMN_WIDTH_L = 72 +COLUMN_WIDTH_R = 12 # 72+12 = 84 -> standard terminal width # There are no universal rules for "safe" and "unsafe" CSP directives, but we apply some common sense here to # catch some risky configurations diff --git a/secheaders/securityheaders.py b/secheaders/securityheaders.py index 5b935f7..dd11828 100644 --- a/secheaders/securityheaders.py +++ b/secheaders/securityheaders.py @@ -1,14 +1,16 @@ import argparse import http.client +import json import re import socket import ssl import sys +import textwrap from urllib.parse import ParseResult, urlparse from . import utils from .constants import DEFAULT_TIMEOUT, DEFAULT_URL_SCHEME, EVAL_WARN, REQUEST_HEADERS, HEADER_STRUCTURED_LIST, \ - SERVER_VERSION_HEADERS, HEADER_OUTPUT_MAX_LEN + SERVER_VERSION_HEADERS, COLUMN_WIDTH_L, COLUMN_WIDTH_R from .exceptions import SecurityHeadersException, InvalidTargetURL, UnableToConnect @@ -82,7 +84,8 @@ def _follow_redirect_until_response(self, url, follow_redirects=5): if temp_url.scheme == 'http': conn = http.client.HTTPConnection(temp_url.netloc, timeout=DEFAULT_TIMEOUT) elif temp_url.scheme == 'https': - ctx = ssl.create_default_context() if self.verify_ssl else ssl._create_stdlib_context() # pylint: disable=protected-access + # pylint: disable-next=protected-access + ctx = ssl.create_default_context() if self.verify_ssl else ssl._create_stdlib_context() conn = http.client.HTTPSConnection(temp_url.netloc, context=ctx, timeout=DEFAULT_TIMEOUT) else: raise InvalidTargetURL("Unsupported protocol scheme") @@ -187,25 +190,38 @@ def check_headers(self): return retval -def output_cli(headers, https, verbose=False): + def get_full_url(self) -> str: + return f"{self.protocol_scheme}://{self.hostname}{self.path}" + + +def output_text(headers, https, verbose=False, no_color=False): + output_str = "" + + # If the stdout is not going into terminal, disable colors + no_color = no_color or not sys.stdout.isatty() for header, value in headers.items(): - output_str = "" + truncated = False + header_contents = value['contents'] if not value['defined']: output_str = f"Header '{header}' is missing" else: - header_contents = value['contents'] - if not verbose and len(header_contents) > HEADER_OUTPUT_MAX_LEN: - header_contents = f"{header_contents[0:HEADER_OUTPUT_MAX_LEN]}... (truncated)" - output_str = f"{header}: {header_contents}" - notes = "" - for note in value['notes']: - notes = f"{notes} * {note}\n" + if len(output_str) > COLUMN_WIDTH_L: + truncated = True + output_str = f"{output_str[0:(COLUMN_WIDTH_L - 5)]}..." - print_func = utils.print_warning if value['warn'] else utils.print_ok - print_func(output_str) - if notes: - print(notes) + eval_value = utils.get_eval_output(value['warn'], no_color) + + if no_color: + print(f"{output_str:<{COLUMN_WIDTH_L}}{eval_value:^{COLUMN_WIDTH_R}}") + else: + # This is a dirty hack required to align ANSI-colored str correctly + print(f"{output_str:<{COLUMN_WIDTH_L}}{eval_value:^{COLUMN_WIDTH_R + 9}}") + + if truncated and verbose: + print((f"Full header contents: {header_contents}")) + for note in value['notes']: + print(textwrap.fill(f" * {note}", 72, subsequent_indent=' ')) msg_map = { 'supported': 'HTTPS supported', @@ -213,20 +229,27 @@ def output_cli(headers, https, verbose=False): 'redirect': 'HTTP -> HTTPS automatic redirect', } for key in https: - if https[key]: - utils.print_ok(msg_map[key]) + output_str = f"{msg_map[key]}" + eval_value = utils.get_eval_output(not https[key], no_color) + if no_color: + output_str = f"{output_str:<{COLUMN_WIDTH_L}}{eval_value:^{COLUMN_WIDTH_R}}" else: - utils.print_warning(msg_map[key]) + # This is a dirty hack required to align ANSI-colored str correctly + output_str = f"{output_str:<{COLUMN_WIDTH_L}}{eval_value:^{COLUMN_WIDTH_R + 9}}" + + print(output_str) def main(): - parser = argparse.ArgumentParser(description='Check HTTP security headers', + parser = argparse.ArgumentParser(description='Scan HTTP security headers', formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('url', metavar='URL', type=str, help='Target URL') parser.add_argument('--max-redirects', dest='max_redirects', metavar='N', default=2, type=int, help='Max redirects, set 0 to disable') parser.add_argument('--insecure', dest='insecure', action='store_true', help='Do not verify TLS certificate chain') + parser.add_argument('--json', dest='json', action='store_true', help='JSON output instead of text') + parser.add_argument('--no-color', dest='no_color', action='store_true', help='Do not output colors in terminal') parser.add_argument('--verbose', '-v', dest='verbose', action='store_true', help='Verbose output') args = parser.parse_args() @@ -235,15 +258,19 @@ def main(): header_check.fetch_headers() headers = header_check.check_headers() except SecurityHeadersException as e: - print(e) + print(e, file=sys.stderr) sys.exit(1) if not headers: - print("Failed to fetch headers, exiting...") + print("Failed to fetch headers, exiting...", file=sys.stderr) sys.exit(1) https = header_check.test_https() - output_cli(headers, https, args.verbose) + if args.json: + print(json.dumps({'target': header_check.get_full_url(), 'headers': headers, 'https': https}, indent=2)) + else: + output_text(headers, https, args.verbose, args.no_color) + if __name__ == "__main__": main() diff --git a/secheaders/utils.py b/secheaders/utils.py index 4be56ad..ff7e2bf 100644 --- a/secheaders/utils.py +++ b/secheaders/utils.py @@ -134,9 +134,16 @@ def permissions_policy_parser(contents: str) -> dict: return retval -def print_ok(msg: str): - print(f"{msg} [ {OK_COLOR}OK{END_COLOR} ]") - - -def print_warning(msg: str): - print(f"{msg} [ {WARN_COLOR}WARN{END_COLOR} ]") +def get_eval_output(warn, no_color): + color_start = OK_COLOR + color_end = END_COLOR + eval_result = "OK" + if warn: + color_start = WARN_COLOR + eval_result = "WARN" + + if no_color: + color_start = "" + color_end = "" + + return f"[ {color_start}{eval_result}{color_end} ]"