Skip to content

Commit

Permalink
json output format, fine-tuning cli output
Browse files Browse the repository at this point in the history
  • Loading branch information
juerkkil committed Oct 26, 2024
1 parent a7debbf commit e27e5e2
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 31 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
```

Expand Down
3 changes: 2 additions & 1 deletion secheaders/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 49 additions & 22 deletions secheaders/securityheaders.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -187,46 +190,66 @@ 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',
'certvalid': 'HTTPS valid certificate',
'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()
Expand All @@ -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()
19 changes: 13 additions & 6 deletions secheaders/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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} ]"

0 comments on commit e27e5e2

Please sign in to comment.