diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..c73e032 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,23 @@ +name: Pylint + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..d9aa21f --- /dev/null +++ b/.pylintrc @@ -0,0 +1,7 @@ +[FORMAT] +max-line-length=120 + +[MESSAGES CONTROL] +disable=missing-module-docstring, + missing-function-docstring, + missing-class-docstring diff --git a/README.md b/README.md index f9597a5..35f5c0f 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ Python script to check HTTP security headers -Same functionality as securityheaders.io but as Python script. Also checks some server/version headers. Written and tested using Python 3.4. +Same functionality as securityheaders.io but as Python script. Also checks some server/version headers. Written and tested using Python 3.8. With minor modifications could be used as a library for other projects. +**NOTE**: The project renamed (2024-10-19) from **securityheaders** to **secheaders** to avoid confusion with PyPI package with similar name. + ## Installation -The following assumes you have Python installed and command `python` refers to python version >= 3.4. +The following assumes you have Python installed and command `python` refers to python version >= 3.8. ### Run without installation @@ -57,7 +59,3 @@ HTTPS supported ... [ OK ] HTTPS valid certificate ... [ OK ] HTTP -> HTTPS redirect ... [ WARN ] ``` - -## Note - -The project renamed (2024-10-19) from **securityheaders** to **secheaders** to avoid confusion with PyPI package with similar name. diff --git a/secheaders/constants.py b/secheaders/constants.py index f34c812..54e2770 100644 --- a/secheaders/constants.py +++ b/secheaders/constants.py @@ -1,5 +1,3 @@ -from . import utils - # If no URL scheme defined, what to use by default DEFAULT_URL_SCHEME = 'https' DEFAULT_TIMEOUT = 10 @@ -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 @@ -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, - } -} diff --git a/secheaders/securityheaders.py b/secheaders/securityheaders.py index 5a64c88..ee6c961 100644 --- a/secheaders/securityheaders.py +++ b/secheaders/securityheaders.py @@ -8,15 +8,47 @@ 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") @@ -24,22 +56,24 @@ def __init__(self, url, max_redirects=2, no_check_certificate=False): 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) @@ -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") @@ -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 @@ -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 @@ -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") @@ -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] @@ -125,12 +159,11 @@ 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, @@ -138,9 +171,8 @@ def check_headers(self): '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: @@ -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', @@ -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() diff --git a/secheaders/utils.py b/secheaders/utils.py index 20d1d1c..b1cfa2f 100644 --- a/secheaders/utils.py +++ b/secheaders/utils.py @@ -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]: @@ -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: @@ -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 @@ -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: @@ -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} ]") diff --git a/setup.py b/setup.py index b908cbe..49ff4a6 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,3 @@ -import setuptools +import setuptools # pylint: disable=import-error setuptools.setup()