Skip to content

Commit

Permalink
linting the code, pylintrc, bugfix
Browse files Browse the repository at this point in the history
  • Loading branch information
juerkkil committed Oct 19, 2024
1 parent b26f7fc commit 0ca24dd
Show file tree
Hide file tree
Showing 5 changed files with 105 additions and 102 deletions.
7 changes: 7 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[FORMAT]
max-line-length=120

[MESSAGES CONTROL]
disable=missing-module-docstring,
missing-function-docstring,
missing-class-docstring
37 changes: 3 additions & 34 deletions secheaders/constants.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from . import utils

# If no URL scheme defined, what to use by default
DEFAULT_URL_SCHEME = 'https'
DEFAULT_TIMEOUT = 10
Expand All @@ -17,6 +15,9 @@

EVAL_WARN = 0
EVAL_OK = 1
OK_COLOR = '\033[92m'
END_COLOR = '\033[0m'
WARN_COLOR = '\033[93m'

# There are no universal rules for "safe" and "unsafe" CSP directives, but we apply some common sense here to
# catch some risky configurations
Expand All @@ -41,35 +42,3 @@
HEADER_STRUCTURED_LIST = [ # Response headers that define multiple values as comma-sparated list
'permissions-policy',
]

SECURITY_HEADERS_DICT = {
'x-frame-options': {
'recommended': True,
'eval_func': utils.eval_x_frame_options,
},
'strict-transport-security': {
'recommended': True,
'eval_func': utils.eval_sts,
},
'content-security-policy': {
'recommended': True,
'eval_func': utils.eval_csp,
},
'x-content-type-options': {
'recommended': True,
'eval_func': utils.eval_content_type_options,
},
'x-xss-protection': {
# X-XSS-Protection is deprecated; not supported anymore, and may be even dangerous in older browsers
'recommended': False,
'eval_func': utils.eval_x_xss_protection,
},
'referrer-policy': {
'recommended': True,
'eval_func': utils.eval_referrer_policy,
},
'permissions-policy': {
'recommended': True,
'eval_func': utils.eval_permissions_policy,
}
}
128 changes: 79 additions & 49 deletions secheaders/securityheaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,72 @@

from . import utils
from .constants import DEFAULT_TIMEOUT, DEFAULT_URL_SCHEME, EVAL_WARN, REQUEST_HEADERS, HEADER_STRUCTURED_LIST, \
SECURITY_HEADERS_DICT, SERVER_VERSION_HEADERS
SERVER_VERSION_HEADERS
from .exceptions import SecurityHeadersException, InvalidTargetURL, UnableToConnect


class SecurityHeaders():
SECURITY_HEADERS_DICT = {
'x-frame-options': {
'recommended': True,
'eval_func': utils.eval_x_frame_options,
},
'strict-transport-security': {
'recommended': True,
'eval_func': utils.eval_sts,
},
'content-security-policy': {
'recommended': True,
'eval_func': utils.eval_csp,
},
'x-content-type-options': {
'recommended': True,
'eval_func': utils.eval_content_type_options,
},
'x-xss-protection': {
# X-XSS-Protection is deprecated; not supported anymore, and may be even dangerous in older browsers
'recommended': False,
'eval_func': utils.eval_x_xss_protection,
},
'referrer-policy': {
'recommended': True,
'eval_func': utils.eval_referrer_policy,
},
'permissions-policy': {
'recommended': True,
'eval_func': utils.eval_permissions_policy,
}
}

def __init__(self, url, max_redirects=2, no_check_certificate=False):
parsed = urlparse(url)
if not parsed.scheme and not parsed.netloc:
url = "{}://{}".format(DEFAULT_URL_SCHEME, url)
url = f"{DEFAULT_URL_SCHEME}://{url}"
parsed = urlparse(url)
if not parsed.scheme and not parsed.netloc:
raise InvalidTargetURL("Unable to parse the URL")

self.protocol_scheme = parsed.scheme
self.hostname = parsed.netloc
self.path = parsed.path
self.verify_ssl = False if no_check_certificate else True
self.verify_ssl = not no_check_certificate
self.target_url: ParseResult = self._follow_redirect_until_response(url, max_redirects) if max_redirects > 0 \
else parsed
self.headers = {}

def test_https(self):
redirect_supported = self._test_http_to_https()

conn = http.client.HTTPSConnection(self.hostname, context=ssl.create_default_context(),
timeout=DEFAULT_TIMEOUT)
try:
conn.request('GET', '/')
except (socket.gaierror, socket.timeout, ConnectionRefusedError):
return {'supported': False, 'certvalid': False}
return {'supported': False, 'certvalid': False, 'redirect': redirect_supported}
except ssl.SSLError:
return {'supported': True, 'certvalid': False}
return {'supported': True, 'certvalid': False, 'redirect': redirect_supported}

return {'supported': True, 'certvalid': True}
return {'supported': True, 'certvalid': True, 'redirect': redirect_supported}

def _follow_redirect_until_response(self, url, follow_redirects=5):
temp_url = urlparse(url)
Expand All @@ -48,7 +82,7 @@ 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()
ctx = ssl.create_default_context() if self.verify_ssl else ssl._create_stdlib_context() # pylint: disable=protected-access
conn = http.client.HTTPSConnection(temp_url.netloc, context=ctx, timeout=DEFAULT_TIMEOUT)
else:
raise InvalidTargetURL("Unsupported protocol scheme")
Expand All @@ -57,7 +91,7 @@ def _follow_redirect_until_response(self, url, follow_redirects=5):
conn.request('GET', temp_url.path, headers=REQUEST_HEADERS)
res = conn.getresponse()
except (socket.gaierror, socket.timeout, ConnectionRefusedError) as e:
raise UnableToConnect("Connection failed {}".format(temp_url.netloc)) from e
raise UnableToConnect(f"Connection failed {temp_url.netloc}") from e
except ssl.SSLError as e:
raise UnableToConnect("SSL Error") from e

Expand All @@ -77,9 +111,9 @@ def _follow_redirect_until_response(self, url, follow_redirects=5):
# More than x redirects, stop here
return temp_url

def test_http_to_https(self, follow_redirects=5):
url = "http://{}{}".format(self.hostname, self.path)
target_url = self._follow_redirect_until_response(url)
def _test_http_to_https(self, follow_redirects=5):
url = f"http://{self.hostname}{self.path}"
target_url = self._follow_redirect_until_response(url, follow_redirects)
if target_url and target_url.scheme == 'https':
return True

Expand All @@ -92,7 +126,7 @@ def open_connection(self, target_url):
if self.verify_ssl:
ctx = ssl.create_default_context()
else:
ctx = ssl._create_stdlib_context()
ctx = ssl._create_stdlib_context() # pylint: disable=protected-access
conn = http.client.HTTPSConnection(target_url.hostname, context=ctx, timeout=DEFAULT_TIMEOUT)
else:
raise InvalidTargetURL("Unsupported protocol scheme")
Expand All @@ -107,14 +141,14 @@ def fetch_headers(self):
conn.request('GET', self.target_url.path, headers=REQUEST_HEADERS)
res = conn.getresponse()
except (socket.gaierror, socket.timeout, ConnectionRefusedError, ssl.SSLError) as e:
raise UnableToConnect("Connection failed {}".format(self.target_url.hostname)) from e
raise UnableToConnect(f"Connection failed {self.target_url.hostname}") from e

headers = res.getheaders()
for h in headers:
key = h[0].lower()
if key in HEADER_STRUCTURED_LIST and key in self.headers:
# Scenario described in RFC 2616 section 4.2
self.headers[key] += ', {}'.format(h[1])
self.headers[key] += f', {h[1]}'
else:
self.headers[key] = h[1]

Expand All @@ -125,22 +159,20 @@ def check_headers(self):
if not self.headers:
raise SecurityHeadersException("Headers not fetched successfully")

""" Loop through headers and evaluate the risk """
for header in SECURITY_HEADERS_DICT:
for header, settings in self.SECURITY_HEADERS_DICT.items():
if header in self.headers:
eval_func = SECURITY_HEADERS_DICT[header].get('eval_func')
eval_func = settings.get('eval_func')
if not eval_func:
raise SecurityHeadersException("No evaluation function found for header: {}".format(header))
raise SecurityHeadersException(f"No evaluation function found for header: {header}")
res, notes = eval_func(self.headers[header])
retval[header] = {
'defined': True,
'warn': res == EVAL_WARN,
'contents': self.headers[header],
'notes': notes,
}

else:
warn = SECURITY_HEADERS_DICT[header].get('recommended')
warn = settings.get('recommended')
retval[header] = {'defined': False, 'warn': warn, 'contents': None, 'notes': []}

for header in SERVER_VERSION_HEADERS:
Expand All @@ -155,6 +187,32 @@ def check_headers(self):

return retval

def output_cli(headers, https):
for header, value in headers.items():
if value['warn']:
if not value['defined']:
utils.print_warning(f"Header '{header}' is missing")
else:
utils.print_warning(f"Header '{header}' contains value '{value['contents']}")
for note in value['notes']:
print(f" * {note}")
else:
if not value['defined']:
utils.print_ok(f"Header '{header}' is missing")
else:
utils.print_ok(f"Header '{header}' contains a proper value")

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])
else:
utils.print_warning(msg_map[key])


def main():
parser = argparse.ArgumentParser(description='Check HTTP security headers',
Expand All @@ -177,36 +235,8 @@ def main():
print("Failed to fetch headers, exiting...")
sys.exit(1)

for header, value in headers.items():
if value['warn']:
if not value['defined']:
utils.print_warning("Header '{}' is missing".format(header))
else:
utils.print_warning("Header '{}' contains value '{}".format(header, value['contents']))
for n in value['notes']:
print(" * {}".format(n))
else:
if not value['defined']:
utils.print_ok("Header '{}' is missing".format(header))
else:
utils.print_ok("Header '{}' contains a proper value".format(header))

https = header_check.test_https()
if https['supported']:
utils.print_ok("HTTPS supported")
else:
utils.print_warning("HTTPS supported")

if https['certvalid']:
utils.print_ok("HTTPS valid certificate")
else:
utils.print_warning("HTTPS valid certificate")

if header_check.test_http_to_https():
utils.print_ok("HTTP -> HTTPS redirect")
else:
utils.print_warning("HTTP -> HTTPS redirect")

output_cli(headers, https)

if __name__ == "__main__":
main()
33 changes: 15 additions & 18 deletions secheaders/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import re
from typing import Tuple

from .constants import EVAL_WARN, EVAL_OK, UNSAFE_CSP_RULES, RESTRICTED_PERM_POLICY_FEATURES
from .constants import EVAL_WARN, EVAL_OK, UNSAFE_CSP_RULES, RESTRICTED_PERM_POLICY_FEATURES, WARN_COLOR, OK_COLOR, \
END_COLOR


def eval_x_frame_options(contents: str) -> Tuple[int, list]:
Expand Down Expand Up @@ -42,22 +43,23 @@ def eval_csp(contents: str) -> Tuple[int, list]:

csp_parsed = csp_parser(contents)

for rule in UNSAFE_CSP_RULES:
for rule, values in UNSAFE_CSP_RULES.items():
if rule not in csp_parsed:
if '-src' in rule and 'default-src' in csp_parsed:
# fallback to default-src
for unsafe_src in UNSAFE_CSP_RULES[rule]:
for unsafe_src in values:
if unsafe_src in csp_parsed['default-src']:
csp_unsafe = True
csp_notes.append("Directive {} not defined, and default-src contains unsafe source {}".format(
rule, unsafe_src))
csp_notes.append(
f"Directive {rule} not defined, and default-src contains unsafe source {unsafe_src}"
)
elif 'default-src' not in csp_parsed:
csp_notes.append("No directive {} nor default-src defined in the Content Security Policy".format(rule))
csp_notes.append(f"No directive {rule} nor default-src defined in the Content Security Policy")
csp_unsafe = True
else:
for unsafe_src in UNSAFE_CSP_RULES[rule]:
for unsafe_src in values:
if unsafe_src in csp_parsed[rule]:
csp_notes.append("Unsafe source {} in directive {}".format(unsafe_src, rule))
csp_notes.append(f"Unsafe source {unsafe_src} in directive {rule}")
csp_unsafe = True

if csp_unsafe:
Expand All @@ -83,11 +85,10 @@ def eval_permissions_policy(contents: str) -> Tuple[int, list]:
feat_policy = pp_parsed.get(feature)
if feat_policy is None:
pp_unsafe = True
notes.append("Privacy-sensitive feature '{}' not defined in permission-policy, always allowed.".format(
feature))
notes.append(f"Privacy-sensitive feature '{feature}' not defined in permission-policy, always allowed.")
elif '*' in feat_policy:
pp_unsafe = True
notes.append("Privacy-sensitive feature '{}' allowed from unsafe origin '*'".format(feature))
notes.append(f"Privacy-sensitive feature '{feature}' allowed from unsafe origin '*'")
if pp_unsafe:
return EVAL_WARN, notes

Expand All @@ -106,7 +107,7 @@ def eval_referrer_policy(contents: str) -> Tuple[int, list]:
]:
return EVAL_OK, []

return EVAL_WARN, ["Unsafe contents: {}".format(contents)]
return EVAL_WARN, [f"Unsafe contents: {contents}"]


def csp_parser(contents: str) -> dict:
Expand Down Expand Up @@ -134,12 +135,8 @@ def permissions_policy_parser(contents: str) -> dict:


def print_ok(msg: str):
OK_COLOR = '\033[92m'
END_COLOR = '\033[0m'
print("{} ... [ {}OK{} ]".format(msg, OK_COLOR, END_COLOR))
print(f"{msg} ... [ {OK_COLOR}OK{END_COLOR} ]")


def print_warning(msg: str):
WARN_COLOR = '\033[93m'
END_COLOR = '\033[0m'
print("{} ... [ {}WARN{} ]".format(msg, WARN_COLOR, END_COLOR))
print(f"{msg} ... [ {WARN_COLOR}WARN{END_COLOR} ]")
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import setuptools
import setuptools # pylint: disable=import-error

setuptools.setup()

0 comments on commit 0ca24dd

Please sign in to comment.