├── docs ├── _static │ └── .keep ├── history.rst ├── readme.rst ├── contributing.rst ├── modules.rst ├── index.rst ├── Makefile ├── usage.rst ├── drheader.rst ├── make.bat ├── installation.rst └── conf.py ├── tests ├── __init__.py ├── unit_tests │ ├── __init__.py │ ├── test_utils.py │ ├── test_cli.py │ └── test_validators.py ├── integration_tests │ ├── __init__.py │ ├── utils.py │ ├── test_cli.py │ ├── test_rules.py │ └── test_drheader.py └── test_resources │ ├── bulk_scan.txt │ ├── custom_rules.yml │ ├── bulk_scan.json │ ├── headers_ko.json │ ├── headers_ok.json │ ├── headers_bulk_ko.json │ ├── default_rules.yml │ ├── custom_rules_merged.yml │ ├── headers_bulk_ok.json │ └── report.json ├── drheader ├── cli │ ├── __init__.py │ ├── utils.py │ └── cli.py ├── validators │ ├── __init__.py │ ├── cookie_validator.py │ ├── base.py │ ├── directive_validator.py │ └── header_validator.py ├── __init__.py ├── resources │ ├── cli │ │ ├── bulk_compare_schema.json │ │ └── bulk_scan_schema.json │ ├── delimiters.json │ └── rules.yml ├── report.py ├── utils.py └── core.py ├── HISTORY.md ├── modules.rst ├── assets └── img │ ├── hero.png │ ├── drheaderscansingle.png │ └── drheaderscansinglejson.png ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── pull_request.yml │ └── codeql-analysis.yml ├── Dockerfile ├── .editorconfig ├── SECURITY.md ├── tox.ini ├── drheader.rst ├── LICENSE ├── pyproject.toml ├── .dockerignore ├── .gitignore ├── README.md ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── CLI.md └── RULES.md /docs/_static/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /drheader/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /drheader/validators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | History 2 | ======= 3 | 4 | .. mdinclude:: ../HISTORY.md 5 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | Read me 2 | ======= 3 | 4 | .. mdinclude:: ../README.md 5 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 0.1.0 (2019-07-18) 4 | 5 | - First release. 6 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. mdinclude:: ../CONTRIBUTING.md 5 | -------------------------------------------------------------------------------- /modules.rst: -------------------------------------------------------------------------------- 1 | drheader 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | drheader 8 | -------------------------------------------------------------------------------- /assets/img/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Santandersecurityresearch/DrHeader/HEAD/assets/img/hero.png -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | drheader 2 | ======== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | drheader 8 | -------------------------------------------------------------------------------- /tests/test_resources/bulk_scan.txt: -------------------------------------------------------------------------------- 1 | https://example.com 2 | https://example.net 3 | https://example.org 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: 'https://www.santandertechnology.co.uk' 4 | -------------------------------------------------------------------------------- /assets/img/drheaderscansingle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Santandersecurityresearch/DrHeader/HEAD/assets/img/drheaderscansingle.png -------------------------------------------------------------------------------- /assets/img/drheaderscansinglejson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Santandersecurityresearch/DrHeader/HEAD/assets/img/drheaderscansinglejson.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.17 2 | 3 | ADD . /drheader 4 | 5 | WORKDIR drheader 6 | 7 | RUN pip install . 8 | 9 | ENTRYPOINT drheader 10 | -------------------------------------------------------------------------------- /drheader/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for drHEADer core.""" 4 | 5 | from drheader.core import Drheader # noqa 6 | -------------------------------------------------------------------------------- /tests/test_resources/custom_rules.yml: -------------------------------------------------------------------------------- 1 | Content-Security-Policy: 2 | Required: True 3 | X-Powered-By: 4 | Required: True 5 | New-Invented-Header: 6 | Required: True 7 | Value: required-value 8 | -------------------------------------------------------------------------------- /tests/test_resources/bulk_scan.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://example.com" 4 | }, 5 | { 6 | "url": "https://example.net" 7 | }, 8 | { 9 | "url": "https://example.org" 10 | } 11 | ] -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to drHEADer's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | history 14 | 15 | Indices and tables 16 | ================== 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE: -------------------------------------------------------------------------------- 1 | * drHEADer version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [.github/**/*.yml] 14 | indent_size = 2 15 | 16 | [*.bat] 17 | indent_style = tab 18 | end_of_line = crlf 19 | 20 | [LICENSE] 21 | insert_final_newline = false 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | DrHeader is best effort project , we will try to fix issues and welcome bug reports and feature requests. 6 | 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 1.0.0 | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you find a vulnerability please raise and issue and let us know ! 15 | 16 | If you think you can fix then feel free to put it in a branch and raise a pull request. 17 | -------------------------------------------------------------------------------- /drheader/resources/cli/bulk_compare_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "array", 3 | "items": { 4 | "type": "object", 5 | "properties": { 6 | "url": { 7 | "type": "string", 8 | "format": "uri" 9 | }, 10 | "headers": { 11 | "type": "object" 12 | } 13 | }, 14 | "required": [ 15 | "url", 16 | "headers" 17 | ], 18 | "additionalProperties": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/test_resources/headers_ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cache-Control": "public, must-revalidate", 3 | "Content-Security-Policy": "script-src 'self' 'unsafe-eval'; object-src 'self'; style-src 'unsafe-inline'", 4 | "Pragma": "no-cache", 5 | "Referrer-Policy": "origin", 6 | "Server": "Apache/2.4.1 (Unix)", 7 | "Set-Cookie": [ 8 | "session_id=2943020342; Max-Age=2592000; Domain=.example.com; Secure" 9 | ], 10 | "X-Content-Type-Options": "nosniff", 11 | "X-Frame-Options": "SAMEORIGIN", 12 | "X-XSS-Protection": "1; mode=block" 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | versioning-strategy: "lockfile-only" 6 | schedule: 7 | interval: "daily" 8 | target-branch: "develop" 9 | reviewers: 10 | - "danielcuthbert" 11 | - "javixeneize" 12 | - "pealtrufo" 13 | - "emilejq" 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | target-branch: "develop" 19 | reviewers: 20 | - "danielcuthbert" 21 | - "javixeneize" 22 | - "pealtrufo" 23 | - "emilejq" 24 | -------------------------------------------------------------------------------- /tests/test_resources/headers_ok.json: -------------------------------------------------------------------------------- 1 | { 2 | "X-XSS-Protection": "0", 3 | "Content-Security-Policy": "default-src 'none'; script-src 'self'; object-src 'self'; frame-src 'self'", 4 | "Cross-Origin-Embedder-Policy": "require-corp", 5 | "Cross-Origin-Opener-Policy": "same-origin", 6 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 7 | "X-Frame-Options": "DENY", 8 | "X-Content-Type-Options": "nosniff", 9 | "Referrer-Policy": "no-referrer", 10 | "Cache-Control": "no-store, max-age=0", 11 | "Pragma": "no-cache", 12 | "Set-Cookie": [ 13 | "session_id=4589399433; HttpOnly; Secure" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python3 -msphinx 7 | SPHINXPROJ = drheader 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use drHEADer core in a project:: 6 | 7 | import drheader 8 | import yaml 9 | 10 | # load rules file 11 | with open('rules.yml', 'r') as f: 12 | rules = yaml.safe_load(file) 13 | 14 | # create drheader instance 15 | drheader_instance = Drheader(headers={'X-XSS-Protection': '1; mode=block'}, status_code=200) 16 | 17 | report = drheader_instance.analyze(rules) 18 | print(report) 19 | 20 | To use the drHEADer cli:: 21 | 22 | $ drheader --help 23 | 24 | Usage: drheader [OPTIONS] COMMAND [ARGS]... 25 | 26 | Console script for drheader. 27 | 28 | Options: 29 | --help Show this message and exit. 30 | 31 | Commands: 32 | compare Compare your headers through drheader. 33 | scan Scan endpoints with drheader. 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | env_list = lint, sast, verify-lock, py{38, 39, 310, 311} 3 | 4 | [testenv] 5 | description = Run unit tests with Pytest 6 | extras = 7 | tests 8 | commands = 9 | pytest --cov={tox_root}/drheader --cov-fail-under=80 {posargs} 10 | 11 | [testenv:lint] 12 | description = Run lint scan with Ruff 13 | skip_install = true 14 | extras = 15 | pipelines 16 | commands = 17 | ruff check . 18 | allowlist_externals = 19 | ruff 20 | 21 | [testenv:sast] 22 | description = Run SAST scan with Bandit 23 | skip_install = true 24 | extras = 25 | pipelines 26 | commands = 27 | ruff check ./drheader --select S 28 | allowlist_externals = 29 | ruff 30 | 31 | [testenv:verify-lock] 32 | description = Verify poetry.lock is up to date 33 | skip_install = true 34 | commands = 35 | poetry check 36 | allowlist_externals = 37 | poetry 38 | -------------------------------------------------------------------------------- /drheader.rst: -------------------------------------------------------------------------------- 1 | drheader package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | drheader.cli module 8 | ------------------- 9 | 10 | .. automodule:: drheader.cli 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | drheader.cli\_utils module 16 | -------------------------- 17 | 18 | .. automodule:: drheader.cli_utils 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | drheader.core module 24 | -------------------- 25 | 26 | .. automodule:: drheader.core 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | drheader.utils module 32 | --------------------- 33 | 34 | .. automodule:: drheader.utils 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: drheader 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /docs/drheader.rst: -------------------------------------------------------------------------------- 1 | drheader package 2 | ================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | drheader.cli module 8 | ------------------- 9 | 10 | .. automodule:: drheader.cli 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | drheader.cli\_utils module 16 | -------------------------- 17 | 18 | .. automodule:: drheader.cli_utils 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | drheader.core module 24 | -------------------- 25 | 26 | .. automodule:: drheader.core 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | drheader.utils module 32 | --------------------- 33 | 34 | .. automodule:: drheader.utils 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: drheader 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=drheader 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /drheader/resources/delimiters.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cache-Control": { 3 | "item_delimiter": ",", 4 | "key_value_delimiter": "=" 5 | }, 6 | "Clear-Site-Data": { 7 | "item_delimiter": ",", 8 | "strip_chars": "\" " 9 | }, 10 | "Content-Security-Policy": { 11 | "item_delimiter": ";", 12 | "key_value_delimiter": " ", 13 | "value_delimiter": " ", 14 | "strip_chars": "' " 15 | }, 16 | "Feature-Policy": { 17 | "item_delimiter": ";", 18 | "key_value_delimiter": " ", 19 | "value_delimiter": " ", 20 | "strip_chars": "' " 21 | }, 22 | "Referrer-Policy": { 23 | "item_delimiter": "," 24 | }, 25 | "Set-Cookie": { 26 | "item_delimiter": ";", 27 | "key_value_delimiter": "=" 28 | }, 29 | "Strict-Transport-Security": { 30 | "item_delimiter": ";", 31 | "key_value_delimiter": "=" 32 | }, 33 | "X-Forwarded-For": { 34 | "item_delimiter": "," 35 | }, 36 | "X-XSS-Protection": { 37 | "item_delimiter": ";", 38 | "key_value_delimiter": "=" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Santander UK 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core>=1.0.0"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "drheader" 7 | description = "Audit your HTTP response headers for misconfigurations and enforce custom security rules!" 8 | version = "2.0.0" 9 | license = "MIT" 10 | 11 | authors = [ 12 | "Santander UK Security Engineering" 13 | ] 14 | readme = ["README.md", "CLI.md", "RULES.md"] 15 | 16 | [tool.poetry.scripts] 17 | drheader = "drheader.cli.cli:start" 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.8" 21 | 22 | click = "^8.0.1" 23 | jsonschema = "^4.19.2" 24 | junit-xml = "^1.9" 25 | pyyaml = "^6.0.1" 26 | requests = "^2.22.0" 27 | tabulate = "^0.9.0" 28 | 29 | lxml = { version = "^4.6.3", optional = true } 30 | m2r2 = { version = "^0.3.3.post2", optional = true } 31 | pytest = { version = "^8.0.0", optional = true } 32 | pytest-cov = { version = "^4.1.0", optional = true } 33 | responses = { version = "^0.23.3", optional = true } 34 | ruff = { version = "^0.2.2", optional = true } 35 | sphinx = { version = "^7.1.2", optional = true } 36 | xmlunittest = { version = "^0.5.0", optional = true } 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | tox = "^4.11.3" 40 | 41 | [tool.poetry.extras] 42 | docs = ["m2r2", "sphinx"] 43 | pipelines = ["ruff"] 44 | tests = ["lxml", "pytest", "pytest-cov", "responses", "xmlunittest"] 45 | -------------------------------------------------------------------------------- /tests/test_resources/headers_bulk_ko.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://example.com", 4 | "headers": { 5 | "Cache-Control": "public, must-revalidate", 6 | "Content-Security-Policy": "script-src 'self' 'unsafe-eval'; object-src 'self'; style-src 'unsafe-inline'", 7 | "Pragma": "no-cache", 8 | "Referrer-Policy": "origin", 9 | "Server": "Apache/2.4.1 (Unix)", 10 | "Set-Cookie": [ 11 | "session_id=2943020342; Max-Age=2592000; Domain=.example.com; Secure" 12 | ], 13 | "X-Content-Type-Options": "nosniff", 14 | "X-Frame-Options": "SAMEORIGIN", 15 | "X-XSS-Protection": "1; mode=block" 16 | } 17 | }, 18 | { 19 | "url": "https://example.net", 20 | "headers": { 21 | "Content-Security-Policy": "default-src 'none'; script-src 'self' 'unsafe-inline'", 22 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 23 | "X-Frame-Options": "DENY", 24 | "X-XSS-Protection": "0" 25 | } 26 | }, 27 | { 28 | "url": "https://example.org", 29 | "headers": { 30 | "Cache-Control": "no-store, max-age=0", 31 | "Strict-Transport-Security": "max-age=31536000", 32 | "X-XSS-Protection": "1; mode-block" 33 | } 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /tests/test_resources/default_rules.yml: -------------------------------------------------------------------------------- 1 | Cache-Control: 2 | Required: true 3 | Value: 4 | - no-store 5 | - max-age=0 6 | Content-Security-Policy: 7 | Required: true 8 | Must-Avoid: 9 | - unsafe-inline 10 | - unsafe-eval 11 | Directives: 12 | default-src: 13 | Required: true 14 | Value-One-Of: 15 | - none 16 | - self 17 | Cross-Origin-Embedder-Policy: 18 | Required: true 19 | Value: require-corp 20 | Cross-Origin-Opener-Policy: 21 | Required: true 22 | Value: same-origin 23 | Pragma: 24 | Required: true 25 | Value: no-cache 26 | Referrer-Policy: 27 | Required: true 28 | Value-One-Of: 29 | - strict-origin 30 | - strict-origin-when-cross-origin 31 | - no-referrer 32 | Server: 33 | Required: false 34 | Set-Cookie: 35 | Required: Optional 36 | Must-Contain: 37 | - HttpOnly 38 | - Secure 39 | Strict-Transport-Security: 40 | Required: true 41 | Value: 42 | - max-age=31536000 43 | - includeSubDomains 44 | User-Agent: 45 | Required: false 46 | X-AspNet-Version: 47 | Required: false 48 | X-Client-IP: 49 | Required: false 50 | X-Content-Type-Options: 51 | Required: true 52 | Value: nosniff 53 | X-Forwarded-For: 54 | Required: false 55 | X-Frame-Options: 56 | Required: true 57 | Value-One-Of: 58 | - DENY 59 | - SAMEORIGIN 60 | X-Generator: 61 | Required: false 62 | X-Powered-By: 63 | Required: false 64 | X-XSS-Protection: 65 | Required: true 66 | Value: 0 67 | -------------------------------------------------------------------------------- /tests/test_resources/custom_rules_merged.yml: -------------------------------------------------------------------------------- 1 | Cache-Control: 2 | Required: True 3 | Value: 4 | - no-store 5 | - max-age=0 6 | Content-Security-Policy: 7 | Required: True 8 | Cross-Origin-Embedder-Policy: 9 | Required: True 10 | Value: require-corp 11 | Cross-Origin-Opener-Policy: 12 | Required: True 13 | Value: same-origin 14 | Pragma: 15 | Required: True 16 | Value: no-cache 17 | Referrer-Policy: 18 | Required: True 19 | Value-One-Of: 20 | - strict-origin 21 | - strict-origin-when-cross-origin 22 | - no-referrer 23 | Server: 24 | Required: False 25 | Set-Cookie: 26 | Required: Optional 27 | Must-Contain: 28 | - HttpOnly 29 | - Secure 30 | Strict-Transport-Security: 31 | Required: True 32 | Value: 33 | - max-age=31536000 34 | - includeSubDomains 35 | User-Agent: 36 | Required: False 37 | X-AspNet-Version: 38 | Required: False 39 | X-Client-IP: 40 | Required: False 41 | X-Content-Type-Options: 42 | Required: True 43 | Value: nosniff 44 | X-Forwarded-For: 45 | Required: False 46 | X-Frame-Options: 47 | Required: True 48 | Value-One-Of: 49 | - DENY 50 | - SAMEORIGIN 51 | X-Generator: 52 | Required: False 53 | X-Powered-By: 54 | Required: True 55 | X-XSS-Protection: 56 | Required: True 57 | Value: 0 58 | New-Invented-Header: 59 | Required: True 60 | Value: required-value 61 | -------------------------------------------------------------------------------- /drheader/resources/cli/bulk_scan_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "array", 3 | "items": { 4 | "type": "object", 5 | "properties": { 6 | "url": { 7 | "type": "string", 8 | "format": "uri" 9 | }, 10 | "allow_redirects": { 11 | "type": "boolean" 12 | }, 13 | "auth": { 14 | "type": "string" 15 | }, 16 | "cert": { 17 | "type": "string" 18 | }, 19 | "cookies": { 20 | "type": "object" 21 | }, 22 | "data": { 23 | }, 24 | "headers": { 25 | "type": "object" 26 | }, 27 | "json": { 28 | "type": "object" 29 | }, 30 | "method": { 31 | "enum": [ 32 | "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT" 33 | ] 34 | }, 35 | "params": { 36 | }, 37 | "proxies": { 38 | "type": "object" 39 | }, 40 | "timeout": { 41 | "type": ["number", "string"] 42 | }, 43 | "verify": { 44 | "type": ["boolean", "string"] 45 | } 46 | }, 47 | "required": [ 48 | "url" 49 | ], 50 | "additionalProperties": false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Thank you for contributing to DrHeader ! 2 | 3 | To help keep changes flowing in and avoid potential merging headaches we have created this form. 4 | 5 | Please fill it in if you can or feel free to ask questions , we don't want this to be a barrier. 6 | 7 | Please trim above here. 8 | 9 | ## Proposed changes 10 | 11 | Please describe your changes here 12 | 13 | ## Type of change 14 | 15 | What types of changes do you want to introduce to DrHeader? 16 | 17 | - [ ] Bugfix (non-breaking change which fixes an issue) 18 | - [ ] New feature (non-breaking change which adds functionality) 19 | - [ ] Breaking change 20 | - [ ] Documentation Update 21 | - [ ] Test Update 22 | - [ ] Rules Update 23 | - [ ] Other (please describe) 24 | 25 | Please ensure your pull request adheres to the following guidelines: 26 | - [ ] A Github Issue that explains the work. 27 | - [ ] The changes are in a branch that is reasonably up to date 28 | - [ ] Tests are provided to reasonably cover new or altered functionality. 29 | - [ ] Documentation is provided for the new or altered functionality. 30 | - [ ] You have the legal right to give us this code. 31 | - [ ] You have adhered to the CoC 32 | 33 | ## Link to the github issue 34 | 35 | https://github.com/Santandersecurityresearch/DrHeader/issues/ 36 | 37 | ## Tests you have added 38 | 39 | testclass.method_name() 40 | 41 | ## Tests you have altered 42 | 43 | testclass.method_name() 44 | 45 | ## Anything Else 46 | 47 | 48 | Thanks for getting this far ! 49 | -------------------------------------------------------------------------------- /drheader/resources/rules.yml: -------------------------------------------------------------------------------- 1 | Cache-Control: 2 | Required: True 3 | Value: 4 | - no-store 5 | - max-age=0 6 | Content-Security-Policy: 7 | Required: True 8 | Must-Avoid: 9 | - unsafe-inline 10 | - unsafe-eval 11 | Directives: 12 | default-src: 13 | Required: True 14 | Value-One-Of: 15 | - none 16 | - self 17 | Cross-Origin-Embedder-Policy: 18 | Required: True 19 | Value: require-corp 20 | Cross-Origin-Opener-Policy: 21 | Required: True 22 | Value: same-origin 23 | Pragma: 24 | Required: True 25 | Value: no-cache 26 | Referrer-Policy: 27 | Required: True 28 | Value-One-Of: 29 | - strict-origin 30 | - strict-origin-when-cross-origin 31 | - no-referrer 32 | Server: 33 | Required: False 34 | Set-Cookie: 35 | Required: Optional 36 | Must-Contain: 37 | - HttpOnly 38 | - Secure 39 | Strict-Transport-Security: 40 | Required: True 41 | Value: 42 | - max-age=31536000 43 | - includeSubDomains 44 | User-Agent: 45 | Required: False 46 | X-AspNet-Version: 47 | Required: False 48 | X-Client-IP: 49 | Required: False 50 | X-Content-Type-Options: 51 | Required: True 52 | Value: nosniff 53 | X-Forwarded-For: 54 | Required: False 55 | X-Frame-Options: 56 | Required: True 57 | Value-One-Of: 58 | - DENY 59 | - SAMEORIGIN 60 | X-Generator: 61 | Required: False 62 | X-Powered-By: 63 | Required: False 64 | X-XSS-Protection: 65 | Required: True 66 | Value: 0 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ vars.PYTHON_VERSION }} 23 | 24 | - name: Load cached Poetry installation 25 | id: cached-poetry-installation 26 | uses: actions/cache@v4 27 | with: 28 | path: | 29 | ~/.local 30 | ~/.config/pypoetry 31 | key: poetry-${{ vars.POETRY_VERSION }} 32 | 33 | - name: Install Poetry if not cached installation 34 | if: steps.cached-poetry-installation.outputs.cache-hit != 'true' 35 | uses: snok/install-poetry@v1 36 | with: 37 | version: ${{ vars.POETRY_VERSION }} 38 | virtualenvs-create: true 39 | 40 | - name: Build release artifacts 41 | run: poetry build 42 | 43 | - name: Get release version 44 | run: echo "VERSION"=$(grep -i 'version = ' pyproject.toml | head -1 | tr -d 'version = "') >> $GITHUB_ENV 45 | 46 | - name: Create release 47 | uses: ncipollo/release-action@v1 48 | with: 49 | artifacts: dist/* 50 | tag: v${{ env.VERSION }} 51 | generateReleaseNotes: true 52 | 53 | - name: Publish to PyPI 54 | env: 55 | POETRY_HTTP_BASIC_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} 56 | POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 57 | run: poetry publish 58 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | reports/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | 105 | # jetbrains 106 | .idea/ 107 | -------------------------------------------------------------------------------- /tests/test_resources/headers_bulk_ok.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://example.com", 4 | "headers": { 5 | "Cache-Control": "no-store, max-age=0", 6 | "Content-Security-Policy": "default-src 'self'", 7 | "Pragma": "no-cache", 8 | "Referrer-Policy": "no-referrer", 9 | "Set-Cookie": [ 10 | "session_id=2943020342; Max-Age=2592000; Domain=.example.com; HttpOnly; Secure" 11 | ], 12 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 13 | "X-Content-Type-Options": "nosniff", 14 | "X-Frame-Options": "DENY", 15 | "X-XSS-Protection": "0" 16 | } 17 | }, 18 | { 19 | "url": "https://example.net", 20 | "headers": { 21 | "Cache-Control": "no-store, max-age=0", 22 | "Content-Security-Policy": "default-src 'none'; script-src 'self'", 23 | "Pragma": "no-cache", 24 | "Referrer-Policy": "strict-origin", 25 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 26 | "X-Content-Type-Options": "nosniff", 27 | "X-Frame-Options": "DENY", 28 | "X-XSS-Protection": "0" 29 | } 30 | }, 31 | { 32 | "url": "https://example.org", 33 | "headers": { 34 | "Cache-Control": "no-store, max-age=0", 35 | "Content-Security-Policy": "default-src 'none'", 36 | "Pragma": "no-cache", 37 | "Referrer-Policy": "no-referrer", 38 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 39 | "X-Content-Type-Options": "nosniff", 40 | "X-Frame-Options": "DENY", 41 | "X-XSS-Protection": "0" 42 | } 43 | } 44 | ] 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | docs/_static/* 68 | !docs/_static/.keep 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # jetbrains 107 | .idea/ 108 | 109 | # junit reports folder 110 | reports 111 | -------------------------------------------------------------------------------- /drheader/cli/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for cli module.""" 2 | 3 | import os 4 | 5 | import junit_xml 6 | import tabulate 7 | from junit_xml import TestSuite, TestCase 8 | 9 | from drheader import utils 10 | 11 | 12 | def get_rules(rules_file=None, rules_uri=None, merge_default=False): 13 | if rules_file or rules_uri: 14 | return utils.load_rules(rules_file=rules_file, rules_uri=rules_uri, merge_default=merge_default) 15 | else: 16 | return utils.default_rules() 17 | 18 | 19 | def tabulate_report(report): 20 | rows = [] 21 | final_string = '' 22 | 23 | for validation_error in report: 24 | values = [[k, v] for k, v in validation_error.items()] 25 | rows.append(values) 26 | for validation_error in rows: 27 | final_string += '----\n' 28 | final_string += tabulate.tabulate(validation_error, tablefmt='presto') + '\n' 29 | 30 | return final_string 31 | 32 | 33 | def file_junit_report(rules, report): 34 | """Generates a JUnit XML report from a scan result. 35 | 36 | Args: 37 | rules (dict): The rules used to perform the scan. 38 | report (list): The report generated from the scan. 39 | """ 40 | test_cases = [] 41 | 42 | for header in rules: 43 | test_case = None 44 | for validation_error in report: 45 | if (title := validation_error.get('rule')).startswith(header): 46 | validation_error = {k: v for k, v in validation_error.items() if k != 'rule'} 47 | test_case = TestCase(name=title) 48 | test_case.add_failure_info(message=validation_error.pop('message'), output=validation_error) 49 | test_cases.append(test_case) 50 | if not test_case: 51 | test_case = TestCase(name=header) 52 | test_cases.append(test_case) 53 | 54 | os.makedirs('reports', exist_ok=True) 55 | test_suite = TestSuite(name='drHEADer', test_cases=test_cases) 56 | 57 | with open('reports/junit.xml', 'w') as junit_report: 58 | junit_xml.to_xml_report_file(junit_report, [test_suite]) 59 | junit_report.close() 60 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install drHEADer, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip3 install https://github.com/Santandersecurityresearch/DrHeader/releases/download/v1.0.0/drheader-1.0.0-py2.py3-none-any.whl --user 16 | 17 | This is the easiet method to install drHEADer from a wheel built from the 1.0.0 release. 18 | 19 | on a Linux or MacOS system you can add an alias to make it easy to access drheader 20 | 21 | .. code-block: console 22 | 23 | $ alias drheader='python3 -m drheader.cli' 24 | 25 | on a Windows 26 | 27 | $ doskey drheader=python3 -m drheader.cli 28 | 29 | 30 | In future we will upload releases to pip and update these instructions. 31 | All releases of DrHeader can be found at https://github.com/Santandersecurityresearch/DrHeader/releases 32 | 33 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 34 | you through the process. 35 | 36 | .. _pip: https://pip.pypa.io 37 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 38 | .. _releases: https://github.com/Santandersecurityresearch/DrHeader/releases 39 | 40 | From sources 41 | ------------ 42 | 43 | The sources for drHEADer core can be downloaded from the `Github repo`_. 44 | 45 | You can either clone the public repository: 46 | 47 | .. code-block:: console 48 | 49 | $ git clone git://github.com/Santandersecurityresearch/DrHeader 50 | 51 | Or download a zip file containing the current master: 52 | 53 | .. code-block:: console 54 | 55 | $ curl -OL https://github.com/Santandersecurityresearch/DrHeader/archive/master.zip 56 | 57 | Once you have a copy of the source, you can install it with: 58 | 59 | .. code-block:: console 60 | 61 | $ python3 setup.py install --user 62 | 63 | 64 | .. _Github repo: https://github.com/Santandersecurityresearch/DrHeader/ 65 | .. _tarball: https://github.com/Santandersecurityresearch/DrHeader/tarball/master 66 | .. _zipfile: https://github.com/Santandersecurityresearch/DrHeader/archive/master.zip 67 | -------------------------------------------------------------------------------- /tests/integration_tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import yaml 5 | 6 | from drheader import core 7 | 8 | 9 | def get_headers(): 10 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/headers_ok.json')) as headers: 11 | return json.load(headers) 12 | 13 | 14 | def add_or_modify_header(header_name, update_value): 15 | headers = get_headers() 16 | headers[header_name] = update_value 17 | return headers 18 | 19 | 20 | def delete_headers(*args): 21 | headers = get_headers() 22 | for header_name in args: 23 | headers.pop(header_name, None) 24 | return headers 25 | 26 | 27 | def process_test(headers=None, url=None, cross_origin_isolated=False): 28 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as rules: 29 | rules = yaml.safe_load(rules.read()) 30 | 31 | drheader = core.Drheader(headers=headers, url=url) 32 | return drheader.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) 33 | 34 | 35 | def build_error_message(report, expected=None, rule=None): 36 | unexpected_items = [] 37 | for item in report: 38 | if item != expected: 39 | if rule and item['rule'].startswith(rule): 40 | unexpected_items.append(item) 41 | elif not rule: 42 | unexpected_items.append(item) 43 | 44 | error_message = '\n' 45 | if len(unexpected_items) > 0: 46 | error_message += '\nThe following items were found but were not expected in the report:\n' 47 | error_message += json.dumps(unexpected_items, indent=2) 48 | if expected and expected not in report: 49 | error_message += '\n\nThe following was not found but was expected in the report:\n' 50 | error_message += json.dumps(expected, indent=2) 51 | return error_message 52 | 53 | 54 | def reset_default_rules(): 55 | with open(os.path.join(os.path.dirname(__file__), '../../drheader/resources/rules.yml')) as rules, \ 56 | open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml'), 'w') as default_rules: 57 | rules = yaml.safe_load(rules.read()) 58 | yaml.dump(rules, default_rules, indent=2, sort_keys=False) 59 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Scan pull request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - dev 7 | - master 8 | 9 | jobs: 10 | scan: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: 15 | - '3.8' 16 | - '3.9' 17 | - '3.10' 18 | - '3.11' 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Load cached Poetry installation 30 | id: cached-poetry-installation 31 | uses: actions/cache@v4 32 | with: 33 | path: | 34 | ~/.local 35 | ~/.config/pypoetry 36 | key: poetry-${{ vars.POETRY_VERSION }} 37 | 38 | - name: Install Poetry if not cached installation 39 | if: steps.cached-poetry-installation.outputs.cache-hit != 'true' 40 | uses: snok/install-poetry@v1 41 | with: 42 | version: ${{ vars.POETRY_VERSION }} 43 | virtualenvs-create: true 44 | virtualenvs-in-project: true 45 | 46 | - name: Verify lock file 47 | run: poetry check --no-interaction 48 | 49 | - name: Load cached venv 50 | id: cached-poetry-dependencies 51 | uses: actions/cache@v4 52 | with: 53 | path: .venv 54 | key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 55 | 56 | - name: Install dependencies if not cached venv 57 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 58 | run: poetry install --no-interaction --no-root 59 | 60 | - name: Load cached tox 61 | uses: actions/cache@v4 62 | with: 63 | path: .tox 64 | key: tox-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 65 | 66 | - name: Run unit tests 67 | env: 68 | version: ${{ matrix.python-version }} 69 | run: poetry run tox run -e py$version -- -- --junitxml $version.results.xml 70 | 71 | - name: Upload test results 72 | uses: actions/upload-artifact@master 73 | with: 74 | name: Results - ${{ matrix.python-version }} 75 | path: ${{ matrix.python-version }}.results.xml 76 | 77 | - name: Run lint scan 78 | run: poetry run tox run -e lint 79 | 80 | - name: Run SAST scan 81 | run: poetry run tox run -e sast 82 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master, develop ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '24 23 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'python' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4.1.1 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v3 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v3 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v3 68 | -------------------------------------------------------------------------------- /drheader/report.py: -------------------------------------------------------------------------------- 1 | """Primary module for report generation and storage.""" 2 | from enum import Enum 3 | from typing import NamedTuple 4 | 5 | 6 | class Reporter: 7 | """Class to generate and store reports from a scan. 8 | 9 | Attributes: 10 | report (list): The report detailing validation failures encountered during a scan. 11 | """ 12 | 13 | def __init__(self): 14 | """Initialises a Reporter instance with an empty report.""" 15 | self.report = [] 16 | 17 | def add_item(self, item): 18 | """Adds a validation failure to the report. 19 | 20 | Args: 21 | item (ReportItem): The validation failure to be added. 22 | """ 23 | finding = {} 24 | if item.directive: 25 | finding['rule'] = f'{item.header} - {item.directive}' 26 | finding['message'] = item.error_type.value.format('Directive') 27 | elif item.cookie: 28 | finding['rule'] = f'{item.header} - {item.cookie}' 29 | finding['message'] = item.error_type.value.format('Cookie') 30 | else: 31 | finding['rule'] = item.header 32 | finding['message'] = item.error_type.value.format('Header') 33 | 34 | finding['severity'] = item.severity 35 | 36 | if item.value: 37 | finding['value'] = item.value 38 | if item.expected: 39 | finding['expected'] = item.expected 40 | if len(item.expected) > 1 and item.delimiter: 41 | finding['delimiter'] = item.delimiter 42 | elif item.avoid: 43 | finding['avoid'] = item.avoid 44 | if item.anomalies: 45 | finding['anomalies'] = item.anomalies 46 | self.report.append(finding) 47 | 48 | 49 | class ErrorType(Enum): 50 | AVOID = 'Must-Avoid directive included' 51 | CONTAIN = 'Must-Contain directive missed' 52 | CONTAIN_ONE = 'Must-Contain-One directive missed. At least one of the expected items was expected' 53 | DISALLOWED = '{} should not be returned' 54 | REQUIRED = '{} not included in response' 55 | VALUE = 'Value does not match security policy' 56 | VALUE_ANY = 'Value does not match security policy. At least one of the expected items was expected' 57 | VALUE_ONE = 'Value does not match security policy. Exactly one of the expected items was expected' 58 | 59 | 60 | class ReportItem(NamedTuple): 61 | severity: str 62 | error_type: ErrorType 63 | header: str 64 | directive: str = None 65 | cookie: str = None 66 | value: str = None 67 | avoid: list = None 68 | expected: list = None 69 | anomalies: list = None 70 | delimiter: str = None 71 | -------------------------------------------------------------------------------- /tests/test_resources/report.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "rule": "Cache-Control", 4 | "message": "Value does not match security policy", 5 | "severity": "high", 6 | "value": "public, must-revalidate", 7 | "expected": [ 8 | "no-store", 9 | "max-age=0" 10 | ], 11 | "delimiter": "," 12 | }, 13 | { 14 | "rule": "Content-Security-Policy - style-src", 15 | "message": "Must-Avoid directive included", 16 | "severity": "high", 17 | "value": "'unsafe-inline'", 18 | "avoid": [ 19 | "unsafe-inline", 20 | "unsafe-eval" 21 | ], 22 | "anomalies": [ 23 | "unsafe-inline" 24 | ] 25 | }, 26 | { 27 | "rule": "Content-Security-Policy - script-src", 28 | "message": "Must-Avoid directive included", 29 | "severity": "high", 30 | "value": "'self' 'unsafe-eval'", 31 | "avoid": [ 32 | "unsafe-inline", 33 | "unsafe-eval" 34 | ], 35 | "anomalies": [ 36 | "unsafe-eval" 37 | ] 38 | }, 39 | { 40 | "rule": "Content-Security-Policy - default-src", 41 | "message": "Directive not included in response", 42 | "severity": "high", 43 | "expected": [ 44 | "none", 45 | "self" 46 | ] 47 | }, 48 | { 49 | "rule": "Referrer-Policy", 50 | "message": "Value does not match security policy. Exactly one of the expected items was expected", 51 | "severity": "high", 52 | "value": "origin", 53 | "expected": [ 54 | "strict-origin", 55 | "strict-origin-when-cross-origin", 56 | "no-referrer" 57 | ] 58 | }, 59 | { 60 | "rule": "Server", 61 | "message": "Header should not be returned", 62 | "severity": "high" 63 | }, 64 | { 65 | "rule": "Set-Cookie - session_id", 66 | "message": "Must-Contain directive missed", 67 | "severity": "high", 68 | "value": "2943020342; Max-Age=2592000; Domain=.example.com; Secure", 69 | "expected": [ 70 | "HttpOnly", 71 | "Secure" 72 | ], 73 | "delimiter": ";", 74 | "anomalies": [ 75 | "HttpOnly" 76 | ] 77 | }, 78 | { 79 | "rule": "Strict-Transport-Security", 80 | "message": "Header not included in response", 81 | "severity": "high", 82 | "expected": [ 83 | "max-age=31536000", 84 | "includeSubDomains" 85 | ], 86 | "delimiter": ";" 87 | }, 88 | { 89 | "rule": "X-XSS-Protection", 90 | "message": "Value does not match security policy", 91 | "severity": "high", 92 | "value": "1; mode=block", 93 | "expected": [ 94 | "0" 95 | ] 96 | } 97 | ] 98 | -------------------------------------------------------------------------------- /drheader/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utility functions for core module.""" 3 | import io 4 | import os 5 | from typing import NamedTuple 6 | 7 | import requests 8 | import yaml 9 | 10 | 11 | class KeyValueDirective(NamedTuple): 12 | key: str 13 | value: list 14 | raw_value: str = None 15 | 16 | 17 | def default_rules(): 18 | """Returns the drHEADer default ruleset.""" 19 | with open(os.path.join(os.path.dirname(__file__), 'resources/rules.yml'), 'r') as rules: 20 | rules = yaml.safe_load(rules.read()) 21 | return rules 22 | 23 | 24 | def load_rules(rules_file=None, rules_uri=None, merge_default=False): 25 | """Returns a drHEADer ruleset from a file. 26 | 27 | The loaded ruleset can be configured to be merged with the default drHEADer rules. If a rule exists in both the 28 | custom rules and default rules, the custom one will take priority and override the default one. Otherwise, the new 29 | custom rule will be appended to the default rules. If no file is provided, the default rules will be returned. 30 | 31 | Args: 32 | rules_file (file): (optional) The YAML file containing the ruleset. 33 | rules_uri (str): (optional) The YAML file containing the ruleset. 34 | merge_default (bool): (optional) Merge the custom ruleset with the drHEADer default ruleset. Default is False. 35 | 36 | Returns: 37 | A dict containing the loaded rules. 38 | """ 39 | if not rules_file: 40 | if not rules_uri: 41 | raise ValueError("No resource provided. Either 'rules_file' or 'rules_uri' must be defined.") 42 | else: 43 | rules_file = get_rules_from_uri(rules_uri) 44 | 45 | rules = yaml.safe_load(rules_file.read()) 46 | return _merge_with_default_rules(rules) if merge_default else rules 47 | 48 | 49 | def parse_policy(policy, item_delimiter=None, key_value_delimiter=None, value_delimiter=None, strip_chars=None, keys_only=False): # noqa: E501 50 | """Parses a policy string into a list of individual directives. 51 | 52 | Args: 53 | policy (str): The policy to be parsed. 54 | item_delimiter (str): (optional) The character that delimits individual directives. 55 | key_value_delimiter (str): (optional) The character that delimits keys and values in key-value directives. 56 | value_delimiter (str): (optional) The character that delimits individual values in key-value directives. 57 | strip_chars (str): (optional) A string of characters to strip from directive values. 58 | keys_only (bool): (optional) A flag to return only keys from key-value directives. Default is False. 59 | 60 | Returns: 61 | A list of directives. 62 | """ 63 | if not item_delimiter: 64 | return [policy.strip(strip_chars)] 65 | if not key_value_delimiter: 66 | return [item.strip(strip_chars) for item in policy.strip().split(item_delimiter)] 67 | 68 | directives = [] 69 | for item in policy.strip().split(item_delimiter): 70 | directives.append(item.strip()) 71 | split_item = item.strip(key_value_delimiter).split(key_value_delimiter, 1) 72 | if len(split_item) == 2: 73 | if keys_only: 74 | directives.append(split_item[0].strip()) 75 | else: 76 | key_value_directive = _extract_key_value_directive(split_item, value_delimiter, strip_chars) 77 | directives.append(key_value_directive) 78 | return directives 79 | 80 | 81 | def get_rules_from_uri(uri): 82 | response = requests.get(uri, timeout=5) 83 | response.raise_for_status() 84 | return io.BytesIO(response.content) 85 | 86 | 87 | def _merge_with_default_rules(rules): 88 | merged_ruleset = default_rules() 89 | for rule in rules: 90 | merged_ruleset[rule] = rules[rule] 91 | return merged_ruleset 92 | 93 | 94 | def _extract_key_value_directive(directive, value_delimiter, strip_chars): 95 | if value_delimiter: 96 | value_items = list(filter(lambda s: s.strip(), directive[1].split(value_delimiter))) 97 | value = [item.strip(strip_chars) for item in value_items] 98 | else: 99 | value = [directive[1].strip(strip_chars)] 100 | return KeyValueDirective(directive[0].strip(), value, directive[1]) 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub release](https://img.shields.io/github/release/Santandersecurityresearch/DrHeader.svg)](https://GitHub.com/Santandersecurityresearch/DrHeader/releases/) 2 | [![Github all releases](https://img.shields.io/github/downloads/Santandersecurityresearch/DrHeader/total.svg)](https://GitHub.com/Santandersecurityresearch/DrHeader/releases/) 3 | [![HitCount](https://hits.dwyl.com/Santandersecurityresearch/DrHeader.svg)](https://hits.dwyl.com/Santandersecurityresearch/DrHeader) 4 | [![MIT license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) 5 | 6 | ![drHEADer](assets/img/hero.png) 7 | 8 | # Welcome to drHEADer 9 | 10 | There are a number of HTTP headers which enhance the security of a website when used. Often ignored, or unknown, these HTTP security headers help prevent common web application vulnerabilities when used. 11 | 12 | drHEADer helps with the audit of security headers received in response to a single request or a list of requests. 13 | 14 | When combined with the OWASP [Application Security Verification Standard](https://github.com/OWASP/ASVS/blob/master/4.0/en/0x22-V14-Config.md) (ASVS) 4.0, it is a useful tool to include as part of an automated CI/CD pipeline which checks for missing HTTP headers. 15 | 16 | ## Installation 17 | To run drheader, you must have Python 3.8+ installed. The easiest way to install drheader is using pip: 18 | 19 | ```sh 20 | $ pip install drheader 21 | ``` 22 | 23 | ## How Do I Use It? 24 | There are two ways you could use drHEADer, depending on what you want to achieve. The easiest way is using the CLI. 25 | 26 | ### CLI 27 | For details on using the CLI, see [CLI.md](CLI.md) 28 | 29 | ### In a Project 30 | It is also possible to call drHEADer from within an existing project, and this is achieved like so: 31 | 32 | ```python 33 | from drheader import Drheader 34 | 35 | scanner = Drheader(headers={'X-XSS-Protection': '1; mode=block'}) 36 | 37 | report = scanner.analyze() 38 | ``` 39 | 40 | #### Customize HTTP request 41 | By default, the tool uses **HEAD** method when making a request, but you can change that by supplying the `method` argument like this: 42 | 43 | ```python 44 | from drheader import Drheader 45 | 46 | scanner = Drheader(url='https://example.com', method='POST') 47 | ``` 48 | 49 | ##### Other `requests` arguments 50 | You can use any other arguments that are supported by `requests` to customise the HTTP request: 51 | 52 | ```python 53 | from drheader import Drheader 54 | 55 | scanner = Drheader(url='https://example.com', headers={'X-API-Key': '726204fe-8a3a-4478-ae8f-4fb216a8c4ba'}) 56 | ``` 57 | 58 | ```python 59 | from drheader import Drheader 60 | 61 | scanner = Drheader(url='https://example.com', verify=False) 62 | ``` 63 | 64 | #### Cross-Origin Isolation 65 | The default rules in drHEADer support cross-origin isolation via the `Cross-Origin-Embedder-Policy` and 66 | `Cross-Origin-Opener-Policy` headers. Due to the potential for this to break websites that have not yet properly 67 | configured their sub-resources for cross-origin isolation, these validations are opt-in at analysis time. If you want to 68 | enforce these cross-origin isolation validations, you must pass the `cross_origin_isolated` flag. 69 | 70 | In a project: 71 | ```python 72 | from drheader import Drheader 73 | 74 | scanner = Drheader(url='https://example.com') 75 | scanner.analyze(cross_origin_isolated=True) 76 | ``` 77 | 78 | ## How Do I Customise drHEADer Rules? 79 | 80 | drHEADer relies on a yaml file that defines the policy it will use when auditing security headers. The file is located at `./drheader/resources/rules.yml`, and you can customise it to fit your particular needs. Please follow this [link](RULES.md) if you want to know more. 81 | 82 | ## Notes 83 | 84 | * On ubuntu systems you may need to install libyaml-dev to avoid errors related to a missing yaml.h. 85 | 86 | ### Roadmap 87 | 88 | We have a lot of ideas for drHEADer, and will push often as a result. Some of the things you'll see shortly are: 89 | 90 | * Building on the Python library to make it easier to embed in your own projects. 91 | * Releasing the API, which is separate from the core library - the API allows you to hit URLs or endpoints at scale 92 | * Better integration into MiTM proxies. 93 | 94 | ## Who Is Behind It? 95 | 96 | drHEADer was developed by the Santander UK Security Engineering team, who are: 97 | 98 | * David Albone 99 | * [Javier Domínguez Ruiz](https://github.com/javixeneize) 100 | * Fernando Cabrerizo 101 | * [James Morris](https://github.com/actuallyjamez) 102 | -------------------------------------------------------------------------------- /tests/unit_tests/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `utils.py` file.""" 5 | 6 | import os 7 | 8 | import responses 9 | import unittest 10 | import yaml 11 | 12 | from drheader import utils 13 | 14 | 15 | class TestUtils(unittest.TestCase): 16 | 17 | def setUp(self): 18 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as default_rules: 19 | self.default_rules = yaml.safe_load(default_rules.read()) 20 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules.yml')) as custom_rules: 21 | self.custom_rules = yaml.safe_load(custom_rules.read()) 22 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules_merged.yml')) as custom_rules_merged: 23 | self.custom_rules_merged = yaml.safe_load(custom_rules_merged.read()) 24 | 25 | def test_parse_policy__should_extract_standalone_directive(self): 26 | policy = "default-src 'none'; upgrade-insecure-requests" 27 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ') 28 | 29 | self.assertIn('upgrade-insecure-requests', directives_list) 30 | 31 | def test_parse_policy__should_extract_key_value_directive(self): 32 | policy = "default-src 'none'; upgrade-insecure-requests" 33 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ') 34 | 35 | expected = utils.KeyValueDirective('default-src', ["'none'"], "'none'") 36 | self.assertIn(expected, directives_list) 37 | 38 | def test_parse_policy__should_extract_raw_key_value_directive(self): 39 | policy = "default-src 'none'; upgrade-insecure-requests" 40 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ') 41 | 42 | self.assertIn("default-src 'none'", directives_list) 43 | 44 | def test_parse_policy__should_extract_all_values_for_key_value_directive_with_multiple_values(self): 45 | policy = "default-src 'none'; script-src https: 'unsafe-inline'" 46 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ') 47 | 48 | expected = utils.KeyValueDirective('script-src', ['https:', "'unsafe-inline'"], "https: 'unsafe-inline'") 49 | self.assertIn(expected, directives_list) 50 | 51 | def test_parse_policy__should_extract_keys_from_key_value_directives_when_keys_only_is_true(self): 52 | policy = "default-src 'none'" 53 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ', keys_only=True) 54 | 55 | self.assertIn('default-src', directives_list) 56 | 57 | def test_parse_policy__should_remove_strip_characters_from_directive_values(self): 58 | policy = "default-src 'none'; script-src 'unsafe-inline'" 59 | directives_list = utils.parse_policy(policy, ';', key_value_delimiter=' ', value_delimiter=' ', strip_chars='\' ') 60 | 61 | expected = utils.KeyValueDirective('default-src', ['none'], "'none'") 62 | self.assertIn(expected, directives_list) 63 | 64 | def test_parse_policy__should_handle_repeated_delimiters(self): 65 | policy = "default-src 'none';; ;; script-src 'self' 'unsafe-inline';;" 66 | directives_list = utils.parse_policy(policy, ';', ' ', value_delimiter=' ') 67 | 68 | expected = utils.KeyValueDirective('script-src', ["'self'", "'unsafe-inline'"], " 'self' 'unsafe-inline'") 69 | self.assertIn(expected, directives_list) 70 | 71 | def test_load_rules__merge_enabled__should_merge_custom_rules_with_default_rules(self): 72 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules.yml')) as custom_rules: 73 | response = utils.load_rules(rules_file=custom_rules, merge_default=True) 74 | 75 | self.assertEqual(response, self.custom_rules_merged) 76 | 77 | @responses.activate 78 | def test_load_rules__valid_rules_uri__should_load_rules_from_uri(self): 79 | uri = 'http://localhost:8080/custom.yml' 80 | responses.add(responses.GET, uri, json=self.custom_rules, status=200) 81 | 82 | rules = utils.load_rules(rules_uri=uri) 83 | self.assertEqual(rules, self.custom_rules) 84 | 85 | @responses.activate 86 | def test_load_rules__no_content_from_uri__should_raise_an_error(self): 87 | uri = 'http://mydomain.com/custom.yml' 88 | responses.add(responses.GET, uri, status=404) 89 | 90 | with self.assertRaises(Exception): 91 | utils.get_rules_from_uri(uri) 92 | -------------------------------------------------------------------------------- /drheader/validators/cookie_validator.py: -------------------------------------------------------------------------------- 1 | """Validator module for cookies.""" 2 | from drheader import utils 3 | from drheader.report import ErrorType, ReportItem 4 | from drheader.validators import base 5 | 6 | _DELIMITER_TYPE = 'item_delimiter' 7 | 8 | 9 | class CookieValidator(base.ValidatorBase): 10 | """Validator class for validating cookies. 11 | 12 | Attributes: 13 | cookies (CaseInsensitiveDict): The cookies to analyse. 14 | """ 15 | 16 | def __init__(self, cookies): 17 | """Initialises a CookieValidator instance with cookies.""" 18 | self.cookies = cookies 19 | 20 | def exists(self, config, header, **kwargs): 21 | """See base class.""" 22 | cookie = kwargs['cookie'] 23 | 24 | if cookie not in self.cookies: 25 | severity = config.get('severity', 'high') 26 | error_type = ErrorType.REQUIRED 27 | return ReportItem(severity, error_type, header, cookie=cookie) 28 | 29 | def not_exists(self, config, header, **kwargs): 30 | """See base class.""" 31 | cookie = kwargs['cookie'] 32 | 33 | if cookie in self.cookies: 34 | severity = config.get('severity', 'high') 35 | error_type = ErrorType.DISALLOWED 36 | return ReportItem(severity, error_type, header, cookie=cookie) 37 | 38 | def value(self, config, header, **kwargs): 39 | """Method not supported. 40 | 41 | Raises: 42 | UnsupportedValidationError: If the method is called. 43 | """ 44 | raise base.UnsupportedValidationError("'Value' validations are not supported for cookies") 45 | 46 | def value_any_of(self, config, header, **kwargs): 47 | """Method not supported. 48 | 49 | Raises: 50 | UnsupportedValidationError: If the method is called. 51 | """ 52 | raise base.UnsupportedValidationError("'Value-Any-Of' validations are not supported for cookies") 53 | 54 | def value_one_of(self, config, header, **kwargs): 55 | """Method not supported. 56 | 57 | Raises: 58 | UnsupportedValidationError: If the method is called. 59 | """ 60 | raise base.UnsupportedValidationError("'Value-One-Of' validations are not supported for cookies") 61 | 62 | def must_avoid(self, config, header, **kwargs): 63 | """See base class.""" 64 | cookie = kwargs['cookie'] 65 | 66 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 67 | disallowed = base.get_expected_values(config, 'must-avoid', delimiter) 68 | 69 | cookie_value = self.cookies[cookie] 70 | cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) 71 | cookie_items = {str(item).lower() for item in cookie_items} 72 | 73 | anomalies = [] 74 | for avoid in disallowed: 75 | if avoid.lower() in cookie_items: 76 | anomalies.append(avoid) 77 | 78 | if anomalies: 79 | severity = config.get('severity', 'high') 80 | error_type = ErrorType.AVOID 81 | return ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, avoid=disallowed, anomalies=anomalies) # noqa:E501 82 | 83 | def must_contain(self, config, header, **kwargs): 84 | """See base class.""" 85 | cookie = kwargs['cookie'] 86 | 87 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 88 | expected = base.get_expected_values(config, 'must-contain', delimiter) 89 | 90 | cookie_value = self.cookies[cookie] 91 | cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) 92 | cookie_items = {str(item).lower() for item in cookie_items} 93 | 94 | anomalies = [] 95 | for contain in expected: 96 | if contain.lower() not in cookie_items: 97 | anomalies.append(contain) 98 | 99 | if anomalies: 100 | severity = config.get('severity', 'high') 101 | error_type = ErrorType.CONTAIN 102 | return ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, expected=expected, anomalies=anomalies, delimiter=delimiter) # noqa:E501 103 | 104 | def must_contain_one(self, config, header, **kwargs): 105 | """See base class.""" 106 | cookie = kwargs['cookie'] 107 | 108 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 109 | expected = base.get_expected_values(config, 'must-contain-one', delimiter) 110 | 111 | cookie_value = self.cookies[cookie] 112 | cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) 113 | cookie_items = {str(item).lower() for item in cookie_items} 114 | 115 | if not any(contain.lower() in cookie_items for contain in expected): 116 | severity = config.get('severity', 'high') 117 | error_type = ErrorType.CONTAIN_ONE 118 | return ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, expected=expected) 119 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ![drHEADer](assets/img/hero.png) 2 | 3 | # Contributing 4 | 5 | Contributions are welcome and greatly appreciated! Every little bit helps and credit will always be given. You can 6 | contribute in many ways: 7 | 8 | ### Report a bug 9 | 10 | Report a bug on the [issue tracker](https://github.com/santandersecurityresearch/drheader/issues). 11 | 12 | Please include in the report: 13 | 14 | - Your operating system name and version 15 | - Any details about your local setup that might be helpful in troubleshooting 16 | - Detailed steps to reproduce the bug 17 | 18 | ### Fix a bug 19 | 20 | Look through the [bug tracker](https://github.com/santandersecurityresearch/drheader/labels/bug) for open issues. 21 | Anything tagged with `bug` and `help wanted` is open to whoever wants to fix it. 22 | 23 | ### Implement a new feature 24 | 25 | Look through the [issue tracker](https://github.com/santandersecurityresearch/drheader/labels/enhancement) for open 26 | feature requests. Anything tagged with `enhancement` and `help wanted` is open to whoever wants to implement it. 27 | 28 | ### Write documentation 29 | 30 | drheader documentation can always be enhanced, whether as part of the official drheader docs, in docstrings, or even on 31 | the web such as in blog posts and articles. 32 | 33 | ### Submit feedback 34 | 35 | The best way to send feedback is to open an issue on the 36 | [issue tracker](https://github.com/santandersecurityresearch/drheader/issues). 37 | 38 | If you are proposing a feature: 39 | 40 | - Explain in detail how it would work 41 | - Keep the scope as narrow as possible to make it easier to implement 42 | - Remember that this is a volunteer-driven project, and that contributions are welcome 43 | 44 | ## Get Started! 45 | 46 | Ready to contribute? This section walks through how to set up drheader for local development and prepare a pull request. 47 | Any steps that display an input symbols icon ( :symbols: ) require you to insert some user-specific information into the 48 | command before running it. 49 | 50 | #### Pre-requisites 51 | 52 | drheader is built using Python 3.8 and Poetry. If you already have these installed, skip ahead to step 3. 53 | 54 | 1. Install [Python 3.8+](https://www.python.org/downloads) 55 | 56 | 2. Install [Poetry](https://python-poetry.org/docs/#installation) 57 | 58 | 3. Fork drheader into your GitHub account 59 | 60 | 4. Clone your fork locally :symbols: 61 | ```shell 62 | $ git clone git@github.com:/drheader.git` 63 | ``` 64 | 65 | 5. Set up a virtual environment in your local repo 66 | ```shell 67 | $ python -m venv venv 68 | ``` 69 | 70 | 6. Activate the virtual environment using the appropriate activation command from 71 | [here](https://docs.python.org/3/library/venv.html#how-venvs-work) :symbols: 72 | ```shell 73 | $ source venv/bin/activate 74 | ``` 75 | 76 | 7. Install the project dependencies into your local environment 77 | ```shell 78 | $ poetry install --all-extras --no-root 79 | ``` 80 | 81 | 8. Create a branch for local development :symbols: 82 | ```shell 83 | $ git checkout -b 84 | ``` 85 | 86 | 9. After making your changes, verify that the tests and required checks are passing (see [running tox](#running-tox)) 87 | ```shell 88 | $ tox 89 | ``` 90 | 91 | 10. Commit your changes and push your branch :symbols: 92 | ```shell 93 | $ git add . 94 | $ git commit -m '' 95 | $ git push origin 96 | ``` 97 | 98 | 11. Submit a pull request at 99 | 100 | ## Pull Request Guidelines 101 | 102 | When submitting a pull request, please ensure that: 103 | 104 | 1. The existing tests are passing, and new functionality is adequately covered with new tests 105 | 2. The relevant documentation e.g. `README.md`, `RULES.md`, `CLI.md` is updated to reflect new or changed functionality 106 | 3. The code works for Python >= 3.8 107 | 4. The pull request is submitted against the `develop` branch with no merge conflicts 108 | 5. The pull request pipeline has succeeded 109 | 110 | #### Running tox 111 | 112 | You can use tox to replicate the workflow environment on your local machine prior to submitting a pull request. This 113 | allows you run exactly the same steps that will run in the pipeline, and ensure that the pull request is ready to be 114 | merged. 115 | 116 | ```shell 117 | $ tox 118 | ``` 119 | 120 | This will do the following: 121 | 122 | - Run the tests against all supported Python versions ** and verify that test coverage is at least 80% 123 | - Run a static security scan 124 | - Run all required linters 125 | - Verify that `poetry.lock` is up-to-date 126 | 127 | ** When testing against a specific Python version, tox expects an interpreter that satisfies the version requirement to 128 | be installed already and visible to tox. If it cannot find a suitable interpreter, the environment will be skipped. To 129 | efficiently manage multiple Python versions, you can use [pyenv](https://github.com/pyenv/pyenv). 130 | -------------------------------------------------------------------------------- /drheader/validators/base.py: -------------------------------------------------------------------------------- 1 | """Base module for validators.""" 2 | from abc import ABC, abstractmethod 3 | 4 | 5 | class ValidatorBase(ABC): 6 | """Base class for validators.""" 7 | 8 | @abstractmethod 9 | def exists(self, config, header, **kwargs): 10 | """Validates that a header, directive or cookie exists in a set of headers. 11 | 12 | Args: 13 | config (CaseInsensitiveDict): The configuration of the exists rule. 14 | header (str): The header to validate. 15 | 16 | Keyword Args: 17 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 18 | directive (str): A named directive in {header} to validate. 19 | """ 20 | 21 | @abstractmethod 22 | def not_exists(self, config, header, **kwargs): 23 | """Validates that a header, directive or cookie does not exist in a set of headers. 24 | 25 | Args: 26 | config (CaseInsensitiveDict): The configuration of the exists rule. 27 | header (str): The header to validate. 28 | 29 | Keyword Args: 30 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 31 | directive (str): A named directive in {header} to validate. 32 | """ 33 | 34 | @abstractmethod 35 | def value(self, config, header, **kwargs): 36 | """Validates that a header or directive matches a single expected value. 37 | 38 | Args: 39 | config (CaseInsensitiveDict): The configuration of the exists rule. 40 | header (str): The header to validate. 41 | 42 | Keyword Args: 43 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 44 | directive (str): A named directive in {header} to validate. 45 | """ 46 | 47 | @abstractmethod 48 | def value_any_of(self, config, header, **kwargs): 49 | """Validates that a header or directive matches one or more of a list of expected values. 50 | 51 | Args: 52 | config (CaseInsensitiveDict): The configuration of the exists rule. 53 | header (str): The header to validate. 54 | 55 | Keyword Args: 56 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 57 | directive (str): A named directive in {header} to validate. 58 | """ 59 | 60 | @abstractmethod 61 | def value_one_of(self, config, header, **kwargs): 62 | """Validates that a header or directive matches one of a list of expected values. 63 | 64 | Args: 65 | config (CaseInsensitiveDict): The configuration of the exists rule. 66 | header (str): The header to validate. 67 | 68 | Keyword Args: 69 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 70 | directive (str): A named directive in {header} to validate. 71 | """ 72 | 73 | @abstractmethod 74 | def must_avoid(self, config, header, **kwargs): 75 | """Validates that a header, directive or cookie does not contain any of a list of disallowed values. 76 | 77 | Args: 78 | config (CaseInsensitiveDict): The configuration of the exists rule. 79 | header (str): The header to validate. 80 | 81 | Keyword Args: 82 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 83 | directive (str): A named directive in {header} to validate. 84 | """ 85 | 86 | @abstractmethod 87 | def must_contain(self, config, header, **kwargs): 88 | """Validates that a header, directive or cookie contains all of a list of expected values. 89 | 90 | Args: 91 | config (CaseInsensitiveDict): The configuration of the exists rule. 92 | header (str): The header to validate. 93 | 94 | Keyword Args: 95 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 96 | directive (str): A named directive in {header} to validate. 97 | """ 98 | 99 | @abstractmethod 100 | def must_contain_one(self, config, header, **kwargs): 101 | """Validates that a header, directive or cookie contains one or more of a list of expected values. 102 | 103 | Args: 104 | config (CaseInsensitiveDict): The configuration of the exists rule. 105 | header (str): The header to validate. 106 | 107 | Keyword Args: 108 | cookie (str): A named cookie in {header} to validate (only applicable for 'set-cookie'). 109 | directive (str): A named directive in {header} to validate. 110 | """ 111 | 112 | 113 | class UnsupportedValidationError(Exception): 114 | """Exception to be raised when an unsupported validation is called. 115 | 116 | Attributes: 117 | message (string): A message describing the error. 118 | """ 119 | 120 | def __init__(self, message): 121 | """Initialises an UnsupportedValidationError instance with a message.""" 122 | self.message = message 123 | 124 | 125 | def get_delimiter(config, delimiter_type): 126 | if delimiters := config.get('delimiters'): 127 | return delimiters.get(delimiter_type) 128 | 129 | 130 | def get_expected_values(config, key, delimiter): 131 | if isinstance(config[key], list): 132 | return [str(item).strip() for item in config[key]] 133 | else: 134 | return [item.strip() for item in str(config[key]).split(delimiter)] 135 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at amias.channer@gruposantander.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # drheader documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | 24 | sys.path.insert(0, os.path.abspath('..')) 25 | 26 | import drheader 27 | 28 | source_suffix = { 29 | '.rst': 'restructuredtext', 30 | '.md': 'markdown' 31 | } 32 | # -- General configuration --------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 40 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'm2r2'] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'drHEADer' 56 | author = u"Santander UK" 57 | 58 | # The version info for the project you're documenting, acts as replacement 59 | # for |version| and |release|, also used in various other places throughout 60 | # the built documents. 61 | # 62 | # The short X.Y version. 63 | version = drheader.__version__ 64 | # The full version, including alpha/beta/rc tags. 65 | release = drheader.__version__ 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = None 73 | 74 | # List of patterns, relative to source directory, that match files and 75 | # directories to ignore when looking for source files. 76 | # This patterns also effect to html_static_path and html_extra_path 77 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 78 | 79 | # The name of the Pygments (syntax highlighting) style to use. 80 | pygments_style = 'sphinx' 81 | 82 | # If true, `todo` and `todoList` produce output, else they produce nothing. 83 | todo_include_todos = False 84 | 85 | # -- Options for HTML output ------------------------------------------- 86 | 87 | # The theme to use for HTML and HTML Help pages. See the documentation for 88 | # a list of builtin themes. 89 | # 90 | html_theme = 'alabaster' 91 | # html_theme = 'sphinx_rtd_theme' 92 | 93 | # Theme options are theme-specific and customize the look and feel of a 94 | # theme further. For a list of options available for each theme, see the 95 | # documentation. 96 | # 97 | # html_theme_options = {} 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ['_static'] 103 | 104 | # -- Options for HTMLHelp output --------------------------------------- 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'drheaderdoc' 108 | 109 | # -- Options for LaTeX output ------------------------------------------ 110 | 111 | latex_elements = { 112 | # The paper size ('letterpaper' or 'a4paper'). 113 | # 114 | # 'papersize': 'letterpaper', 115 | 116 | # The font size ('10pt', '11pt' or '12pt'). 117 | # 118 | # 'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | # 122 | # 'preamble': '', 123 | 124 | # Latex figure (float) alignment 125 | # 126 | # 'figure_align': 'htbp', 127 | } 128 | 129 | # Grouping the document tree into LaTeX files. List of tuples 130 | # (source start file, target name, title, author, documentclass 131 | # [howto, manual, or own class]). 132 | latex_documents = [ 133 | (master_doc, 'drheader.tex', 134 | u'drHEADer Documentation', 135 | u'James Morris', 'manual'), 136 | ] 137 | 138 | # -- Options for manual page output ------------------------------------ 139 | 140 | # One entry per manual page. List of tuples 141 | # (source start file, name, description, authors, manual section). 142 | man_pages = [ 143 | (master_doc, 'drheader', 144 | u'drHEADer Documentation', 145 | [author], 1) 146 | ] 147 | 148 | # -- Options for Texinfo output ---------------------------------------- 149 | 150 | # Grouping the document tree into Texinfo files. List of tuples 151 | # (source start file, target name, title, author, 152 | # dir menu entry, description, category) 153 | texinfo_documents = [ 154 | (master_doc, 'drheader', 155 | u'drHEADer Documentation', 156 | author, 157 | 'drheader', 158 | 'One line description of project.', 159 | 'Miscellaneous'), 160 | ] 161 | 162 | 163 | def skip(app, what, name, obj, would_skip, options): 164 | if name == "__init__": 165 | return False 166 | return would_skip 167 | 168 | 169 | def setup(app): 170 | app.connect("autodoc-skip-member", skip) 171 | -------------------------------------------------------------------------------- /tests/integration_tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import copy 3 | import os 4 | from unittest import TestCase 5 | 6 | import responses 7 | from click.testing import CliRunner 8 | from responses import matchers 9 | 10 | from drheader.cli import cli 11 | 12 | _RESOURCES_DIR = os.path.join(os.path.dirname(__file__), '../test_resources') 13 | 14 | 15 | # noinspection PyTypeChecker 16 | class TestCli(TestCase): 17 | 18 | def setUp(self): 19 | responses.head('https://example.com') 20 | responses.head('https://example.net') 21 | responses.head('https://example.org') 22 | 23 | with open(os.path.join(_RESOURCES_DIR, 'bulk_scan.json')) as bulk_scan: 24 | self.bulk_scan = json.load(bulk_scan) 25 | self.bulk_scan_reset = copy.deepcopy(self.bulk_scan) 26 | 27 | def tearDown(self): 28 | with open(os.path.join(_RESOURCES_DIR, 'bulk_scan.json'), 'w') as bulk_scan: 29 | json.dump(self.bulk_scan_reset, bulk_scan, indent=4) 30 | 31 | @responses.activate 32 | def test_scan_single__should_process_request_method(self): 33 | mock = responses.post('https://example.com') 34 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--method', 'post']) 35 | 36 | assert mock.call_count == 1 37 | 38 | @responses.activate 39 | def test_scan_single__should_process_request_headers(self): 40 | headers = {'Authorization': 'Bearer YWxh2GGVuc2VzYW1l'} 41 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.header_matcher(headers)]) 42 | 43 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--headers', json.dumps(headers)]) 44 | 45 | assert mock.call_count == 1 46 | 47 | @responses.activate 48 | def test_scan_single__should_process_request_params(self): 49 | params = {'username': 'h_simpson', 'password': 'doughnuts'} 50 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.query_param_matcher(params)]) 51 | 52 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--params', json.dumps(params)]) 53 | 54 | assert mock.call_count == 1 55 | 56 | @responses.activate 57 | def test_scan_single__should_process_request_body(self): 58 | body = {'First Name': 'Homer', 'Last Name': 'Simpson'} 59 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.json_params_matcher(body)]) 60 | 61 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--json', json.dumps(body)]) 62 | 63 | assert mock.call_count == 1 64 | 65 | @responses.activate 66 | def test_scan_single__should_process_no_verify(self): 67 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.request_kwargs_matcher({'verify': False})]) 68 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--verify', 'false']) 69 | 70 | assert mock.call_count == 1 71 | 72 | @responses.activate 73 | def test_scan_single__should_process_timeout(self): 74 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.request_kwargs_matcher({'timeout': 30})]) 75 | CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com', '--timeout', '30']) 76 | 77 | assert mock.call_count == 1 78 | 79 | @responses.activate 80 | def test_scan_bulk__should_process_request_method(self): 81 | self._modify_bulk_scan('method', 'POST') 82 | mock = responses.post('https://example.com') 83 | 84 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 85 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 86 | 87 | assert mock.call_count == 1 88 | 89 | @responses.activate 90 | def test_scan_bulk__should_process_request_headers(self): 91 | self._modify_bulk_scan('headers', headers := {'Authorization': 'Bearer YWxh2GGVuc2VzYW1l'}) 92 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.header_matcher(headers)]) 93 | 94 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 95 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 96 | 97 | assert mock.call_count == 1 98 | 99 | @responses.activate 100 | def test_scan_bulk__should_process_request_params(self): 101 | self._modify_bulk_scan('params', params := {'username': 'h_simpson', 'password': 'doughnuts'}) 102 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.query_param_matcher(params)]) 103 | 104 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 105 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 106 | 107 | assert mock.call_count == 1 108 | 109 | @responses.activate 110 | def test_scan_bulk__should_process_request_body(self): 111 | self._modify_bulk_scan('json', body := {'First Name': 'Homer', 'Last Name': 'Simpson'}) 112 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.json_params_matcher(body)]) 113 | 114 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 115 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 116 | 117 | assert mock.call_count == 1 118 | 119 | @responses.activate 120 | def test_scan_bulk__should_process_no_verify(self): 121 | self._modify_bulk_scan('verify', False) 122 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.request_kwargs_matcher({'verify': False})]) 123 | 124 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 125 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 126 | 127 | assert mock.call_count == 1 128 | 129 | @responses.activate 130 | def test_scan_bulk__should_process_timeout(self): 131 | self._modify_bulk_scan('timeout', 30) 132 | mock = responses.upsert(responses.HEAD, 'https://example.com', match=[matchers.request_kwargs_matcher({'timeout': 30})]) 133 | 134 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 135 | CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 136 | 137 | assert mock.call_count == 1 138 | 139 | def _modify_bulk_scan(self, key, value): 140 | with open(os.path.join(_RESOURCES_DIR, 'bulk_scan.json'), 'w') as bulk_scan: 141 | self.bulk_scan[0][key] = value 142 | json.dump(self.bulk_scan, bulk_scan, indent=4) 143 | -------------------------------------------------------------------------------- /drheader/validators/directive_validator.py: -------------------------------------------------------------------------------- 1 | """Validator module for directives.""" 2 | from drheader import utils 3 | from drheader.report import ErrorType, ReportItem 4 | from drheader.validators import base 5 | 6 | _DELIMITER_TYPE = 'value_delimiter' 7 | 8 | 9 | class DirectiveValidator(base.ValidatorBase): 10 | """Validator class for validating directives. 11 | 12 | Attributes: 13 | headers (CaseInsensitiveDict): The headers to analyse. 14 | """ 15 | 16 | def __init__(self, headers): 17 | """Initialises a DirectiveValidator instance with headers.""" 18 | self.headers = headers 19 | 20 | def exists(self, config, header, **kwargs): 21 | """See base class.""" 22 | directive = kwargs['directive'] 23 | 24 | directives = utils.parse_policy(self.headers[header], **config['delimiters'], keys_only=True) 25 | directives = {str(item).lower() for item in directives} 26 | 27 | if directive.lower() not in directives: 28 | severity = config.get('severity', 'high') 29 | error_type = ErrorType.REQUIRED 30 | if 'value' in config: 31 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 32 | expected = base.get_expected_values(config, 'value', delimiter) 33 | return ReportItem(severity, error_type, header, directive=directive, expected=expected, delimiter=delimiter) # noqa:E501 34 | elif 'value-any-of' in config: 35 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 36 | expected = base.get_expected_values(config, 'value-any-of', delimiter) 37 | return ReportItem(severity, error_type, header, directive=directive, expected=expected, delimiter=delimiter) # noqa:E501 38 | elif 'value-one-of' in config: 39 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 40 | expected = base.get_expected_values(config, 'value-one-of', delimiter) 41 | return ReportItem(severity, error_type, header, directive=directive, expected=expected) 42 | else: 43 | return ReportItem(severity, error_type, header, directive=directive) 44 | 45 | def not_exists(self, config, header, **kwargs): 46 | """See base class.""" 47 | directive = kwargs['directive'] 48 | 49 | directives = utils.parse_policy(self.headers[header], **config['delimiters'], keys_only=True) 50 | directives = {str(item).lower() for item in directives} 51 | 52 | if directive.lower() in directives: 53 | severity = config.get('severity', 'high') 54 | error_type = ErrorType.DISALLOWED 55 | return ReportItem(severity, error_type, header, directive=directive) 56 | 57 | def value(self, config, header, **kwargs): 58 | """See base class.""" 59 | directive = kwargs['directive'] 60 | 61 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 62 | expected = base.get_expected_values(config, 'value', delimiter) 63 | 64 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 65 | kvd = _get_key_value_directive(directive, directives) 66 | directive_items = {str(item).lower() for item in kvd.value} 67 | 68 | if directive_items != {item.lower() for item in expected}: 69 | severity = config.get('severity', 'high') 70 | error_type = ErrorType.VALUE 71 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, expected=expected, delimiter=delimiter) # noqa:E501 72 | 73 | def value_any_of(self, config, header, **kwargs): 74 | """See base class.""" 75 | directive = kwargs['directive'] 76 | 77 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 78 | accepted = base.get_expected_values(config, 'value-any-of', delimiter) 79 | 80 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 81 | kvd = _get_key_value_directive(directive, directives) 82 | directive_items = {str(item).lower() for item in kvd.value} 83 | 84 | anomalies = [] 85 | accepted_lower = [item.lower() for item in accepted] 86 | for item in directive_items: 87 | if item not in accepted_lower: 88 | anomalies.append(item) 89 | 90 | if anomalies: 91 | severity = config.get('severity', 'high') 92 | error_type = ErrorType.VALUE_ANY 93 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, expected=accepted, anomalies=anomalies, delimiter=delimiter) # noqa:E501 94 | 95 | def value_one_of(self, config, header, **kwargs): 96 | """See base class.""" 97 | directive = kwargs['directive'] 98 | 99 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 100 | accepted = base.get_expected_values(config, 'value-one-of', delimiter) 101 | 102 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 103 | kvd = _get_key_value_directive(directive, directives) 104 | directive_value = kvd.value[0] 105 | 106 | if directive_value not in {item.lower() for item in accepted}: 107 | severity = config.get('severity', 'high') 108 | error_type = ErrorType.VALUE_ONE 109 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, expected=accepted) 110 | 111 | def must_avoid(self, config, header, **kwargs): 112 | """See base class.""" 113 | directive = kwargs['directive'] 114 | 115 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 116 | disallowed = base.get_expected_values(config, 'must-avoid', delimiter) 117 | 118 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 119 | kvd = _get_key_value_directive(directive, directives) 120 | directive_items = {str(item).lower() for item in kvd.value} 121 | 122 | anomalies = [] 123 | for avoid in disallowed: 124 | if avoid.lower() in directive_items: 125 | anomalies.append(avoid) 126 | 127 | if anomalies: 128 | severity = config.get('severity', 'high') 129 | error_type = ErrorType.AVOID 130 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, avoid=disallowed, anomalies=anomalies) # noqa:E501 131 | 132 | def must_contain(self, config, header, **kwargs): 133 | """See base class.""" 134 | directive = kwargs['directive'] 135 | 136 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 137 | expected = base.get_expected_values(config, 'must-contain', delimiter) 138 | 139 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 140 | kvd = _get_key_value_directive(directive, directives) 141 | directive_items = {str(item).lower() for item in kvd.value} 142 | 143 | anomalies = [] 144 | for contain in expected: 145 | if contain.lower() not in directive_items: 146 | anomalies.append(contain) 147 | 148 | if anomalies: 149 | severity = config.get('severity', 'high') 150 | error_type = ErrorType.CONTAIN 151 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, expected=expected, anomalies=anomalies, delimiter=delimiter) # noqa:E501 152 | 153 | def must_contain_one(self, config, header, **kwargs): 154 | """See base class.""" 155 | directive = kwargs['directive'] 156 | 157 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 158 | expected = base.get_expected_values(config, 'must-contain-one', delimiter) 159 | 160 | directives = utils.parse_policy(self.headers[header], **config['delimiters']) 161 | kvd = _get_key_value_directive(directive, directives) 162 | directive_items = {str(item).lower() for item in kvd.value} 163 | 164 | if not any(contain.lower() in directive_items for contain in expected): 165 | severity = config.get('severity', 'high') 166 | error_type = ErrorType.CONTAIN_ONE 167 | return ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, expected=expected) 168 | 169 | 170 | def _get_key_value_directive(directive_name, directives_list): 171 | for directive in directives_list: 172 | if isinstance(directive, utils.KeyValueDirective) and directive.key.lower() == directive_name.lower(): 173 | return directive 174 | -------------------------------------------------------------------------------- /drheader/core.py: -------------------------------------------------------------------------------- 1 | """Main module and entry point for analysis.""" 2 | import json 3 | import logging 4 | import os 5 | 6 | import requests 7 | from requests.structures import CaseInsensitiveDict 8 | 9 | from drheader import utils 10 | from drheader.report import Reporter 11 | from drheader.validators.cookie_validator import CookieValidator 12 | from drheader.validators.directive_validator import DirectiveValidator 13 | from drheader.validators.header_validator import HeaderValidator 14 | 15 | _ALLOWED_HTTP_METHODS = ['delete', 'get', 'head', 'options', 'patch', 'post', 'put'] 16 | _CROSS_ORIGIN_HEADERS = ['cross-origin-embedder-policy', 'cross-origin-opener-policy'] 17 | 18 | with open(os.path.join(os.path.dirname(__file__), 'resources/delimiters.json')) as delimiters: 19 | _DELIMITERS = CaseInsensitiveDict(json.load(delimiters)) 20 | 21 | 22 | class Drheader: 23 | """Main class and entry point for analysis. 24 | 25 | Attributes: 26 | headers (CaseInsensitiveDict): The headers to analyse. 27 | cookies (CaseInsensitiveDict): The cookies to analyse. 28 | reporter (Reporter): Reporter instance that generates and holds the final report. 29 | """ 30 | 31 | def __init__(self, headers=None, url=None, **kwargs): 32 | """Initialises a Drheader instance. 33 | 34 | At least one of and must be defined. The value passed in is treated differently 35 | depending on whether a URL is provided. If a URL is provided, the headers passed are treated as request headers 36 | and are sent with the HTTP request to . Otherwise, they are the raw headers that are analysed. 37 | 38 | Args: 39 | headers (dict): Either headers to analyse or a dict of headers to send with the request to . 40 | url (str): URL from which to retrieve the headers to analyse. 41 | 42 | Raises: 43 | ValueError: If neither headers nor url is provided, or if url is not a valid URL. 44 | """ 45 | if not url: 46 | if not headers: 47 | raise ValueError("Nothing provided for analysis. Either 'headers' or 'url' must be defined") 48 | else: 49 | headers_to_analyse = json.loads(headers) if isinstance(headers, str) else headers 50 | else: 51 | headers_to_analyse = _get_headers_from_url(url, headers=headers, **kwargs) 52 | 53 | self.cookies = CaseInsensitiveDict() 54 | self.headers = CaseInsensitiveDict(headers_to_analyse) 55 | self.reporter = Reporter() 56 | 57 | for cookie in self.headers.get('set-cookie', []): 58 | cookie = cookie.split('=', 1) 59 | self.cookies[cookie[0]] = cookie[1] 60 | 61 | def analyze(self, rules=None, cross_origin_isolated=False): 62 | """Analyses headers against a drHEADer ruleset. 63 | 64 | Args: 65 | rules (dict): (optional) The rules against which to assess the headers. Default rules are used if undefined. 66 | cross_origin_isolated (bool): (optional) A flag to enable cross-origin isolation rules. Default is False. 67 | 68 | Returns: 69 | A list containing all the rule violations found during analysis. The report consists of individual dict 70 | items per header and rule. Each item in the report will detail the non-compliant header, the rule violated 71 | and its associated severity, and, if applicable, the observed value of the header, any expected, disallowed 72 | or anomalous values, and the correct delimiter. For example: 73 | { 74 | 'rule': 'Referrer-Policy', 75 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 76 | 'severity': 'high', 77 | 'value': 'origin-when-cross-origin' 78 | 'expected': ['same-origin', 'strict-origin-when-cross-origin'] 79 | } 80 | """ 81 | if not rules: 82 | rules = _translate_to_case_insensitive_dict(utils.default_rules()) 83 | else: 84 | rules = _translate_to_case_insensitive_dict(rules) 85 | 86 | header_validator = HeaderValidator(self.headers) 87 | directive_validator = DirectiveValidator(self.headers) 88 | cookie_validator = CookieValidator(self.cookies) 89 | 90 | for header, rule_config in rules.items(): 91 | if header.lower() in _CROSS_ORIGIN_HEADERS and not cross_origin_isolated: 92 | logging.info(f"Cross-origin isolation validations are not enabled. Skipping header '{header}'") 93 | continue 94 | 95 | if header.lower() != 'set-cookie': 96 | self._validate_rules(rule_config, header_validator, header) 97 | elif header in self.headers: # Validates global rules for cookies e.g. all cookies must contain 'secure' 98 | for cookie in self.cookies: 99 | self._validate_rules(rule_config, cookie_validator, header, cookie=cookie) 100 | 101 | if 'directives' in rule_config and header in self.headers: 102 | for directive, directive_config in rule_config['directives'].items(): 103 | self._validate_rules(directive_config, directive_validator, header, directive=directive) 104 | if 'cookies' in rule_config and header.lower() == 'set-cookie': # Validates individual rules for cookies e.g. cookie session_id must contain 'samesite=strict' # noqa:E501 105 | for cookie, cookie_config in rule_config['cookies'].items(): 106 | self._validate_rules(cookie_config, cookie_validator, header, cookie=cookie) 107 | return self.reporter.report 108 | 109 | def _validate_rules(self, config, validator, header, **kwargs): 110 | """Validates rules for a single header, directive or cookie.""" 111 | config['delimiters'] = _DELIMITERS.get(header) 112 | required = str(config['required']).strip().lower() 113 | 114 | if required == 'true': 115 | if report_item := validator.exists(config, header, **kwargs): 116 | self._add_to_report(report_item) 117 | else: 118 | self._validate_value_rules(config, validator, header, **kwargs) 119 | elif required == 'false': 120 | if report_item := validator.not_exists(config, header, **kwargs): 121 | self._add_to_report(report_item) 122 | elif required == 'optional': 123 | if cookie := kwargs.get('cookie'): 124 | is_present = cookie in self.cookies 125 | elif directive := kwargs.get('directive'): 126 | is_present = directive in utils.parse_policy(self.headers[header], **_DELIMITERS[header], keys_only=True) # noqa: E501 127 | else: 128 | is_present = header in self.headers 129 | 130 | if is_present: 131 | self._validate_value_rules(config, validator, header, **kwargs) 132 | 133 | def _validate_value_rules(self, config, validator, header, **kwargs): 134 | """Validates rules for a single header, directive or cookie.""" 135 | if 'value' in config: 136 | if report_item := validator.value(config, header, **kwargs): 137 | self._add_to_report(report_item) 138 | elif 'value-any-of' in config: 139 | if report_item := validator.value_any_of(config, header, **kwargs): 140 | self._add_to_report(report_item) 141 | elif 'value-one-of' in config: 142 | if report_item := validator.value_one_of(config, header, **kwargs): 143 | self._add_to_report(report_item) 144 | else: 145 | if 'must-avoid' in config: 146 | if report_item := validator.must_avoid(config, header, **kwargs): 147 | self._add_to_report(report_item) 148 | if 'must-contain' in config: 149 | if report_item := validator.must_contain(config, header, **kwargs): 150 | self._add_to_report(report_item) 151 | if 'must-contain-one' in config: 152 | if report_item := validator.must_contain_one(config, header, **kwargs): 153 | self._add_to_report(report_item) 154 | 155 | def _add_to_report(self, report_item): 156 | """Adds a finding or list of findings to the final report.""" 157 | try: 158 | self.reporter.add_item(report_item) 159 | except AttributeError: # For must-avoid rules on policy headers (CSP, Permissions-Policy) 160 | for item in report_item: # A separate report item is created for each directive that violates the must-avoid rule e.g. multiple directives containing 'unsafe-inline' # noqa:E501 161 | self.reporter.add_item(item) 162 | 163 | 164 | def _get_headers_from_url(url, method='head', **kwargs): 165 | """Retrieves headers from a URL.""" 166 | if method.strip().lower() not in _ALLOWED_HTTP_METHODS: 167 | raise ValueError(f"'{method}' is not an allowed HTTP method") 168 | 169 | if 'timeout' not in kwargs: 170 | kwargs['timeout'] = 5 171 | if 'allow_redirects' not in kwargs: 172 | kwargs['allow_redirects'] = True 173 | 174 | response = requests.request(method, url, **kwargs) 175 | response_headers = response.headers 176 | response_headers['set-cookie'] = response.raw.headers.getlist('Set-Cookie') 177 | return response_headers 178 | 179 | 180 | def _translate_to_case_insensitive_dict(dict_to_translate): 181 | """Recursively transforms a dict into a case-insensitive dict.""" 182 | for key, value in dict_to_translate.items(): 183 | if isinstance(value, dict): 184 | dict_to_translate[key] = _translate_to_case_insensitive_dict(value) 185 | return CaseInsensitiveDict(dict_to_translate) 186 | -------------------------------------------------------------------------------- /CLI.md: -------------------------------------------------------------------------------- 1 | ![drHEADer](assets/img/hero.png) 2 | 3 | # CLI Usage 4 | This page describes how to use the drHEADer command line interface. 5 | 6 | ## Contents 7 | * [Analysing Headers Locally](#analysing-headers-locally) 8 | * [Bulk Scanning](#bulk-scanning) 9 | * [Scan Options](#scan-options) 10 | * [Scanning Remote Endpoints](#scanning-remote-endpoints) 11 | * [Configuring the HTTP Request](#configuring-the-http-request) 12 | * [Bulk Scanning](#bulk-scanning-1) 13 | * [File Input Format](#file-input-format) 14 | * [Configuring the HTTP Request](#configuring-the-http-request-1) 15 | * [Scan Options](#scan-options-1) 16 | * [Report Output](#report-output) 17 | 18 | ## Analysing Headers Locally 19 | You can validate a set of headers against a drHEADer ruleset using the `compare single` command: 20 | ```shell 21 | $ drheader compare single [SCAN_OPTIONS] FILE 22 | ``` 23 | 24 | This will parse the contents of `FILE`, analyse the headers within it against a drHEADer ruleset, and report back any 25 | rule violations. `FILE` is the path to a JSON file containing the headers to be analysed. 26 | 27 | ### Bulk Scanning 28 | You can validate several sets of headers at once using the `compare bulk` command: 29 | ```shell 30 | $ drheader compare bulk [SCAN_OPTIONS] FILE 31 | ``` 32 | 33 | The contents of `FILE` must be a JSON array with each item specifying a set of headers to be analysed and an associated 34 | endpoint: 35 | 36 | ```json 37 | [ 38 | { 39 | "url": "https://example.com", 40 | "headers": { 41 | "Cache-Control": "private, must-revalidate", 42 | "Content-Security-Policy": "default-src 'self'; script-src 'unsafe-inline'" 43 | } 44 | }, 45 | { 46 | "url": "https://example.net", 47 | "headers": { 48 | "Referrer-Policy": "strict-origin", 49 | "Strict-Transport-Security": "max-age=31536000; preload", 50 | "X-Content-Type-Options": "nosniff" 51 | } 52 | } 53 | ] 54 | ``` 55 | 56 | You can view the JSON schema for bulk scanning with `compare` [here](drheader/resources/cli/bulk_compare_schema.json). 57 | 58 | ### Scan Options 59 | This table lists the scan options available when using the `compare` command: 60 | 61 | | Option | Shorthand Option | Description | 62 | |:---------------------------|:-----------------|:------------------------------------------------------------| 63 | | `--cross-origin-isolated` | `-co` | Enable cross-origin isolation validations ¹ | 64 | | `--debug` | `-d` | Enable debug logging | 65 | | `--junit` | `-j` | Generate a JUnit report *(not available for bulk scanning)* | 66 | | `--merge` | `-m` | Merge a custom ruleset with the default rules | 67 | | `--output [json \| table]` | `-o` | Report output format [default: table] | 68 | | `--rules-file FILENAME` | `-rf` | Use a custom ruleset, loaded from file ² | 69 | | `--rules-uri URI` | `-ru` | Use a custom ruleset, downloaded from URI ² | 70 | 71 | ¹ Cross-origin isolation validations are opt-in. See [Cross-Origin Isolation](README.md#cross-origin-isolation). 72 | 73 | ² For information on defining a custom ruleset see [RULES](RULES.md). 74 | 75 | ## Scanning Remote Endpoints 76 | You can scan a remote endpoint using the `scan single` command: 77 | 78 | ```shell 79 | $ drheader scan single [SCAN_OPTIONS] TARGET_URL [REQUEST_ARGS] 80 | ``` 81 | 82 | This will send an HTTP `HEAD` request to `TARGET_URL`, validate the headers that are returned in the response and report 83 | back any rule violations. 84 | 85 | ### Configuring the HTTP Request 86 | Internally, drHEADer uses the [`requests`](https://requests.readthedocs.io/en/latest/) package to make the HTTP call, 87 | and you can customise the request with the same configuration options that can be processed by this package. Any 88 | arguments received in `[REQUEST_ARGS]` will be propagated to the HTTP call. This means, you can for instance... 89 | 90 | * Change the request method: 91 | ```shell 92 | $ drheader scan single https://example.com --method POST 93 | ``` 94 | 95 | * Send request parameters: 96 | ```shell 97 | $ drheader scan single https://example.com --params '{"name": "h_simpson", "department": "Sector 7G"}' 98 | ``` 99 | 100 | * Send data in the request body: 101 | ```shell 102 | $ drheader scan single https://example.com --json '{"number": "42", "street": "Wallaby Way", "city": "Sydney"}' 103 | ``` 104 | 105 | * Configure the timeout on the request: 106 | ```shell 107 | $ drheader scan single https://example.com --timeout 30 108 | $ drheader scan single https://example.com --timeout '(5, 30)' 109 | ``` 110 | 111 | * Configure SSL verification: 112 | ```shell 113 | $ drheader scan single https://example.com --verify false 114 | $ drheader scan single https://example.com --verify 'path/to/ca/bundle' 115 | ``` 116 | 117 | See the [requests](https://requests.readthedocs.io/en/latest/_modules/requests/api/#request) documentation for a full 118 | list of options. 119 | 120 | ### Bulk Scanning 121 | You can scan several endpoints at once using the `scan bulk` command: 122 | ```shell 123 | $ drheader scan bulk [SCAN_OPTIONS] FILE 124 | ``` 125 | 126 | The contents of `FILE` must be a JSON array with each item specifying a URL to target and additional request args: 127 | ```json 128 | [ 129 | { 130 | "url": "https://example.com" 131 | }, 132 | { 133 | "url": "https://example.net" 134 | }, 135 | { 136 | "url": "https://example.org" 137 | } 138 | ] 139 | ``` 140 | 141 | You can view the JSON schema for bulk scanning with `scan` [here](drheader/resources/cli/bulk_scan_schema.json). See 142 | [configuring the HTTP request](#configuring-the-http-request-1) for details on request args. 143 | 144 | #### File Input Format 145 | The default input format for `FILE` is JSON, which allows you to configure more complex HTTP requests (see 146 | [configuring the HTTP request](#configuring-the-http-request-1)). If you only want to target each endpoint with a basic 147 | HTTP HEAD request and no additional configuration, you can choose to pass the input file in a simpler `.txt` format by 148 | specifying the `-ff` (file format) option: 149 | 150 | ```shell 151 | $ drheader scan bulk -ff txt FILE 152 | ``` 153 | 154 | Each endpoint must be on a separate line: 155 | ``` 156 | https://example.com 157 | https://example.net 158 | https://example.org 159 | ``` 160 | 161 | #### Configuring the HTTP Request 162 | For bulk scanning, [request args](#configuring-the-http-request) are configured on a per-target basis in the JSON file: 163 | ```json 164 | [ 165 | { 166 | "url": "https://example.com", 167 | "method": "POST" 168 | }, 169 | { 170 | "url": "https://example.net", 171 | "params": { 172 | "name": "h_simpson", 173 | "department": "Sector 7G" 174 | } 175 | }, 176 | { 177 | "url": "https://example.org", 178 | "json": { 179 | "number": "42", 180 | "street": "Wallaby Way", 181 | "city": "Sydney" 182 | }, 183 | "timeout": 30, 184 | "verify": false 185 | } 186 | ] 187 | ``` 188 | 189 | See the [requests](https://requests.readthedocs.io/en/latest/_modules/requests/api/#request) documentation for a full 190 | list of options. 191 | 192 | ### Scan Options 193 | This table lists the scan options available when using the `scan` command: 194 | 195 | | Option | Shorthand Option | Description | 196 | |:------------------------------|:-----------------|:------------------------------------------------------------| 197 | | `--cross-origin-isolated` | `-co` | Enable cross-origin isolation validations ¹ | 198 | | `--debug` | `-d` | Enable debug logging | 199 | | `--file-format [json \| txt]` | `-ff` | FILE input format [default: json] *(bulk scanning only)* | 200 | | `--junit` | `-j` | Generate a JUnit report *(not available for bulk scanning)* | 201 | | `--merge` | `-m` | Merge a custom ruleset with the default rules | 202 | | `--output [json \| table]` | `-o` | Report output format [default: table] | 203 | | `--rules-file FILENAME` | `-rf` | Use a custom ruleset, loaded from file ² | 204 | | `--rules-uri URI` | `-ru` | Use a custom ruleset, downloaded from URI ² | 205 | 206 | ¹ Cross-origin isolation validations are opt-in. See [Cross-Origin Isolation](README.md#cross-origin-isolation). 207 | 208 | ² For information on defining a custom ruleset see [RULES](RULES.md). 209 | 210 | ## Report Output 211 | By default, results will be output in a tabulated format: 212 | 213 | ``` 214 | https://example.com: 9 issues found 215 | ---- 216 | rule | Cache-Control 217 | message | Value does not match security policy 218 | severity | high 219 | value | max-age=604800 220 | expected | ['no-store', 'max-age=0'] 221 | delimiter | , 222 | ---- 223 | rule | Content-Security-Policy 224 | message | Header not included in response 225 | severity | high 226 | ---- 227 | rule | Pragma 228 | message | Header not included in response 229 | severity | high 230 | expected | ['no-cache'] 231 | ``` 232 | 233 | You can change this using the `--output` option. Currently, the only other supported option is JSON: 234 | ```sh 235 | $ drheader scan single --output json 236 | ``` 237 | 238 | In order to save scan results, you can pipe the JSON output to [jq](https://stedolan.github.io/jq/), which is a 239 | lightweight and flexible command-line JSON processor: 240 | ```sh 241 | $ drheader scan single --output json https://example.com | jq '.' 242 | ``` 243 | -------------------------------------------------------------------------------- /tests/unit_tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import os.path 4 | from unittest import mock, TestCase 5 | 6 | import yaml 7 | from click.testing import CliRunner 8 | from xmlunittest import XmlTestMixin 9 | 10 | from drheader.cli import cli, utils 11 | 12 | _RESOURCES_DIR = os.path.join(os.path.dirname(__file__), '../test_resources') 13 | 14 | 15 | # noinspection PyTypeChecker 16 | class TestCli(TestCase): 17 | 18 | def setUp(self): 19 | with open(os.path.join(_RESOURCES_DIR, 'report.json')) as report: 20 | self.report = json.load(report) 21 | 22 | @mock.patch('drheader.cli.cli.Drheader') 23 | def test_compare_single__should_return_exit_code_0_on_clean_report(self, drheader_mock): 24 | drheader_mock.return_value.analyze.return_value = [] 25 | 26 | file = os.path.join(_RESOURCES_DIR, 'headers_ok.json') 27 | response = CliRunner().invoke(cli.main, ['compare', 'single', file]) 28 | 29 | assert response.exit_code == 0 30 | 31 | @mock.patch('drheader.cli.cli.Drheader') 32 | def test_compare_single__should_return_exit_code_70_on_rule_violation(self, drheader_mock): 33 | drheader_mock.return_value.analyze.return_value = self.report 34 | 35 | file = os.path.join(_RESOURCES_DIR, 'headers_ko.json') 36 | response = CliRunner().invoke(cli.main, ['compare', 'single', file]) 37 | 38 | assert response.exit_code == 70 39 | 40 | @mock.patch('drheader.cli.cli.Drheader') 41 | def test_compare_single__should_enable_cross_origin_isolation(self, drheader_mock): 42 | file = os.path.join(_RESOURCES_DIR, 'headers_ko.json') 43 | CliRunner().invoke(cli.main, ['compare', 'single', '--cross-origin-isolated', file]) 44 | 45 | drheader_mock.return_value.analyze.assert_called_once_with(rules=mock.ANY, cross_origin_isolated=True) 46 | 47 | @mock.patch('drheader.cli.cli.Drheader') 48 | def test_compare_single__should_output_json(self, drheader_mock): 49 | drheader_mock.return_value.analyze.return_value = self.report 50 | 51 | file = os.path.join(_RESOURCES_DIR, 'headers_ko.json') 52 | response = CliRunner().invoke(cli.main, ['compare', 'single', '--output', 'json', file]) 53 | 54 | assert json.loads(response.output) == self.report 55 | 56 | @mock.patch('drheader.cli.cli.Drheader') 57 | def test_compare_bulk__should_return_exit_code_0_on_clean_report(self, drheader_mock): 58 | drheader_mock.return_value.analyze.return_value = [] 59 | 60 | file = os.path.join(_RESOURCES_DIR, 'headers_bulk_ok.json') 61 | response = CliRunner().invoke(cli.main, ['compare', 'bulk', file]) 62 | 63 | assert response.exit_code == 0 64 | 65 | @mock.patch('drheader.cli.cli.Drheader') 66 | def test_compare_bulk__should_return_exit_code_70_on_rule_violation(self, drheader_mock): 67 | drheader_mock.return_value.analyze.return_value = self.report 68 | 69 | file = os.path.join(_RESOURCES_DIR, 'headers_bulk_ko.json') 70 | response = CliRunner().invoke(cli.main, ['compare', 'bulk', file]) 71 | 72 | assert response.exit_code == 70 73 | 74 | @mock.patch('drheader.cli.cli.Drheader') 75 | def test_compare_bulk__should_output_json(self, drheader_mock): 76 | drheader_mock.return_value.analyze.return_value = self.report 77 | 78 | file = os.path.join(_RESOURCES_DIR, 'headers_bulk_ko.json') 79 | response = CliRunner().invoke(cli.main, ['compare', 'bulk', '--output', 'json', file]) 80 | 81 | assert json.loads(response.output)[0]['report'] == self.report 82 | 83 | @mock.patch('drheader.cli.cli.Drheader') 84 | def test_scan_single__should_return_exit_code_0_on_clean_report(self, drheader_mock): 85 | drheader_mock.return_value.analyze.return_value = [] 86 | response = CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com']) 87 | 88 | assert response.exit_code == 0 89 | 90 | @mock.patch('drheader.cli.cli.Drheader') 91 | def test_scan_single__should_return_exit_code_70_on_rule_violation(self, drheader_mock): 92 | drheader_mock.return_value.analyze.return_value = self.report 93 | response = CliRunner().invoke(cli.main, ['scan', 'single', 'https://example.com']) 94 | 95 | assert response.exit_code == 70 96 | 97 | @mock.patch('drheader.cli.cli.Drheader') 98 | def test_scan_single__should_enable_cross_origin_isolation(self, drheader_mock): 99 | CliRunner().invoke(cli.main, ['scan', 'single', '--cross-origin-isolated', 'https://example.com']) 100 | 101 | drheader_mock.return_value.analyze.assert_called_once_with(rules=mock.ANY, cross_origin_isolated=True) 102 | 103 | @mock.patch('drheader.cli.cli.Drheader') 104 | def test_scan_single__should_output_json(self, drheader_mock): 105 | drheader_mock.return_value.analyze.return_value = self.report 106 | response = CliRunner().invoke(cli.main, ['scan', 'single', '--output', 'json', 'https://example.com']) 107 | 108 | assert json.loads(response.output) == self.report 109 | 110 | @mock.patch('drheader.cli.cli.Drheader') 111 | def test_scan_bulk__should_return_exit_code_0_on_clean_report(self, drheader_mock): 112 | drheader_mock.return_value.analyze.return_value = [] 113 | 114 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 115 | response = CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 116 | 117 | assert response.exit_code == 0 118 | 119 | @mock.patch('drheader.cli.cli.Drheader') 120 | def test_scan_bulk__should_return_exit_code_70_on_rule_violation(self, drheader_mock): 121 | drheader_mock.return_value.analyze.return_value = self.report 122 | 123 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 124 | response = CliRunner().invoke(cli.main, ['scan', 'bulk', file]) 125 | 126 | assert response.exit_code == 70 127 | 128 | @mock.patch('drheader.cli.cli.Drheader') 129 | def test_scan_bulk__should_handle_json_file_type(self, drheader_mock): 130 | drheader_mock.return_value.analyze.return_value = self.report 131 | 132 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 133 | CliRunner().invoke(cli.main, ['scan', 'bulk', '-ff', 'json', file]) 134 | 135 | assert drheader_mock.call_args_list == [ 136 | mock.call(url='https://example.com'), 137 | mock.call(url='https://example.net'), 138 | mock.call(url='https://example.org') 139 | ] 140 | 141 | @mock.patch('drheader.cli.cli.Drheader') 142 | def test_scan_bulk__should_handle_txt_file_type(self, drheader_mock): 143 | drheader_mock.return_value.analyze.return_value = self.report 144 | 145 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.txt') 146 | CliRunner().invoke(cli.main, ['scan', 'bulk', '-ff', 'txt', file]) 147 | 148 | assert drheader_mock.call_args_list == [ 149 | mock.call(url='https://example.com'), 150 | mock.call(url='https://example.net'), 151 | mock.call(url='https://example.org') 152 | ] 153 | 154 | @mock.patch('drheader.cli.cli.Drheader') 155 | def test_scan_bulk__should_output_json(self, drheader_mock): 156 | drheader_mock.return_value.analyze.return_value = self.report 157 | 158 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 159 | response = CliRunner().invoke(cli.main, ['scan', 'bulk', '--output', 'json', file]) 160 | 161 | assert json.loads(response.output)[0]['report'] == self.report 162 | 163 | @mock.patch('drheader.cli.cli.Drheader') 164 | def test_scan_bulk__should_not_fail_on_error(self, drheader_mock): 165 | def raise_error(url): 166 | if url == 'https://example.net': 167 | raise ValueError('Error retrieving headers') 168 | else: 169 | return drheader_mock 170 | 171 | drheader_mock.analyze.return_value = self.report 172 | drheader_mock.side_effect = raise_error 173 | 174 | file = os.path.join(_RESOURCES_DIR, 'bulk_scan.json') 175 | response = CliRunner().invoke(cli.main, ['scan', 'bulk', '--output', 'json', file]) 176 | 177 | assert json.loads(response.output)[1]['error'] == 'Error retrieving headers' 178 | 179 | 180 | class TestUtils(TestCase, XmlTestMixin): 181 | 182 | def setUp(self): 183 | with open(os.path.join(_RESOURCES_DIR, 'report.json')) as report: 184 | self.report = json.load(report) 185 | with open(os.path.join(_RESOURCES_DIR, 'default_rules.yml')) as rules: 186 | self.rules = yaml.safe_load(rules) 187 | 188 | utils.file_junit_report(self.rules, self.report) 189 | with open('reports/junit.xml') as junit_report: 190 | self.junit_xml = junit_report.read() 191 | 192 | def test_get_rules__should_return_default_rules_when_no_rules_provided(self): 193 | rules = utils.get_rules() 194 | assert rules == self.rules 195 | 196 | def test_file_junit_report__should_create_test_case_for_header(self): 197 | assert any(item['rule'] == 'Cache-Control' for item in self.report) 198 | 199 | xml_tree = self.assertXmlDocument(self.junit_xml) 200 | self.assertXpathsExist(xml_tree, ['./testsuite/testcase[@name="Cache-Control"]']) 201 | 202 | def test_file_junit_report__should_create_test_case_for_directive(self): 203 | assert any(item['rule'] == 'Content-Security-Policy - default-src' for item in self.report) 204 | 205 | xml_tree = self.assertXmlDocument(self.junit_xml) 206 | self.assertXpathsExist(xml_tree, ['./testsuite/testcase[@name="Content-Security-Policy - default-src"]']) 207 | 208 | def test_file_junit_report__should_create_test_case_for_cookie(self): 209 | assert any(item['rule'] == 'Set-Cookie - session_id' for item in self.report) 210 | 211 | xml_tree = self.assertXmlDocument(self.junit_xml) 212 | self.assertXpathsExist(xml_tree, ['./testsuite/testcase[@name="Set-Cookie - session_id"]']) 213 | 214 | def test_file_junit_report__should_create_test_failure_for_each_report_item(self): 215 | assert len(self.report) > 0 216 | 217 | xml_tree = self.assertXmlDocument(self.junit_xml) 218 | self.assertXmlHasAttribute(xml_tree, 'failures', expected_value=str(len(self.report))) 219 | -------------------------------------------------------------------------------- /tests/unit_tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import unittest 4 | from requests import structures 5 | 6 | from drheader import report, utils 7 | from drheader.validators.cookie_validator import CookieValidator 8 | from drheader.validators.header_validator import HeaderValidator 9 | 10 | 11 | class TestBase(unittest.TestCase): 12 | 13 | def assert_report_items_equal(self, expected_report_item, observed_report_item, msg=None): 14 | does_validate = True 15 | 16 | for field in expected_report_item._asdict(): 17 | expected = getattr(expected_report_item, field) 18 | observed = getattr(observed_report_item, field) 19 | if not expected == observed: 20 | msg += f"\tNon-matching values for field '{field}'. Expected: '{expected}'; Observed: '{observed}'\n" 21 | does_validate = False 22 | if not does_validate: 23 | raise self.failureException(msg) 24 | 25 | 26 | class TestCookieValidator(TestBase): 27 | 28 | def setUp(self): 29 | self.validator = CookieValidator(cookies=structures.CaseInsensitiveDict()) 30 | self.addTypeEqualityFunc(report.ReportItem, super().assert_report_items_equal) 31 | 32 | @mock.patch('drheader.utils.parse_policy') 33 | def test_validate_must_avoid__should_validate_named_cookie(self, parse_policy_mock): 34 | config = structures.CaseInsensitiveDict({ 35 | 'required': True, 36 | 'must-avoid': ['samesite=lax'], 37 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': '='} 38 | }) 39 | self.validator.cookies['session'] = '657488329; samesite=lax' 40 | parse_policy_mock.return_value = ['657488329', 'samesite=lax', 'samesite'] 41 | 42 | response = self.validator.must_avoid(config, 'set-cookie', cookie='session') 43 | expected = report.ReportItem('high', report.ErrorType.AVOID, 'set-cookie', cookie='session', value='657488329; samesite=lax', avoid=['samesite=lax'], anomalies=['samesite=lax']) 44 | self.assertEqual(expected, response, msg='The report items are not equal:\n') 45 | 46 | @mock.patch('drheader.utils.parse_policy') 47 | def test_validate_must_contain__should_validate_named_cookie(self, parse_policy_mock): 48 | config = structures.CaseInsensitiveDict({ 49 | 'required': True, 50 | 'must-contain': ['httponly', 'secure'], 51 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': '='} 52 | }) 53 | self.validator.cookies['session'] = '657488329; httponly; samesite=strict' 54 | parse_policy_mock.return_value = ['657488329', 'httponly', 'samesite=strict', 'samesite'] 55 | 56 | response = self.validator.must_contain(config, 'set-cookie', cookie='session') 57 | expected = report.ReportItem('high', report.ErrorType.CONTAIN, 'set-cookie', cookie='session', value='657488329; httponly; samesite=strict', expected=['httponly', 'secure'], delimiter=';', anomalies=['secure']) 58 | self.assertEqual(expected, response, msg='The report items are not equal:\n') 59 | 60 | @mock.patch('drheader.utils.parse_policy') 61 | def test_validate_must_contain_one__should_validate_named_cookie(self, parse_policy_mock): 62 | config = structures.CaseInsensitiveDict({ 63 | 'required': True, 64 | 'must-contain-one': ['expires', 'max-age'], 65 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': '='} 66 | }) 67 | self.validator.cookies['session'] = '657488329; httponly; samesite=strict' 68 | parse_policy_mock.return_value = ['657488329', 'samesite=strict', 'samesite'] 69 | 70 | response = self.validator.must_contain_one(config, 'set-cookie', cookie='session') 71 | expected = report.ReportItem('high', report.ErrorType.CONTAIN_ONE, 'set-cookie', cookie='session', value='657488329; httponly; samesite=strict', expected=['expires', 'max-age']) 72 | self.assertEqual(expected, response, msg='The report items are not equal:\n') 73 | 74 | 75 | class TestHeaderValidator(TestBase): 76 | 77 | def setUp(self): 78 | self.validator = HeaderValidator(headers=structures.CaseInsensitiveDict()) 79 | self.addTypeEqualityFunc(report.ReportItem, super().assert_report_items_equal) 80 | 81 | @mock.patch('drheader.utils.parse_policy') 82 | def test_validate_value__should_enforce_order_when_preserve_order_is_true(self, parse_policy_mock): 83 | config = structures.CaseInsensitiveDict({ 84 | 'required': True, 85 | 'value': ['no-referrer', 'strict-origin-when-cross-origin'], 86 | 'delimiters': {'item_delimiter': ','}, 87 | 'preserve-order': True 88 | }) 89 | self.validator.headers['referrer-policy'] = 'strict-origin-when-cross-origin, no-referrer' 90 | parse_policy_mock.return_value = ['strict-origin-when-cross-origin', 'no-referrer'] 91 | 92 | response = self.validator.value(config, 'referrer-policy') 93 | expected = report.ReportItem('high', report.ErrorType.VALUE, 'referrer-policy', value='strict-origin-when-cross-origin, no-referrer', expected=['no-referrer', 'strict-origin-when-cross-origin'], delimiter=',') 94 | self.assertEqual(expected, response, msg='The report items are not equal:\n') 95 | 96 | @mock.patch('drheader.utils.parse_policy') 97 | def test_validate_must_avoid_for_policy_header__should_validate_standalone_directive(self, parse_policy_mock): 98 | config = structures.CaseInsensitiveDict({ 99 | 'required': True, 100 | 'must-avoid': ['block-all-mixed-content'], 101 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} 102 | }) 103 | self.validator.headers['content-security-policy'] = "default-src 'none'; block-all-mixed-content" 104 | 105 | parse_policy_mock.return_value = [ 106 | "default-src 'none'", 107 | utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), 108 | 'block-all-mixed-content' 109 | ] 110 | 111 | response = self.validator.must_avoid(config, 'content-security-policy') 112 | expected = report.ReportItem('high', report.ErrorType.AVOID, 'content-security-policy', value="default-src 'none'; block-all-mixed-content", avoid=['block-all-mixed-content'], anomalies=['block-all-mixed-content']) 113 | self.assertEqual(expected, response[0], msg='The report items are not equal:\n') 114 | 115 | @mock.patch('drheader.utils.parse_policy') 116 | def test_validate_must_avoid_for_policy_header__should_validate_key_value_directive(self, parse_policy_mock): 117 | config = structures.CaseInsensitiveDict({ 118 | 'required': True, 119 | 'must-avoid': ['script-src'], 120 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} 121 | }) 122 | self.validator.headers['content-security-policy'] = "default-src 'none'; script-src https://example.com" 123 | 124 | parse_policy_mock.return_value = [ 125 | "default-src 'none'", 126 | utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), 127 | 'script-src https://example.com', 128 | utils.KeyValueDirective(key='script-src', value=['https://example.com'], raw_value='https://example.com') 129 | ] 130 | 131 | response = self.validator.must_avoid(config, 'content-security-policy') 132 | expected = report.ReportItem('high', report.ErrorType.AVOID, 'content-security-policy', value="default-src 'none'; script-src https://example.com", avoid=['script-src'], anomalies=['script-src']) 133 | self.assertEqual(expected, response[0], msg='The report items are not equal:\n') 134 | 135 | @mock.patch('drheader.utils.parse_policy') 136 | def test_validate_must_avoid_for_policy_header__should_validate_keyword_value(self, parse_policy_mock): 137 | config = structures.CaseInsensitiveDict({ 138 | 'required': True, 139 | 'must-avoid': ['unsafe-inline'], 140 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} 141 | }) 142 | self.validator.headers['content-security-policy'] = "default-src 'none'; script-src 'unsafe-inline'" 143 | 144 | parse_policy_mock.return_value = [ 145 | "default-src 'none'", 146 | utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), 147 | "script-src 'unsafe-inline'", 148 | utils.KeyValueDirective(key='script-src', value=['unsafe-inline'], raw_value="'unsafe-inline'") 149 | ] 150 | 151 | response = self.validator.must_avoid(config, 'content-security-policy') 152 | expected = report.ReportItem('high', report.ErrorType.AVOID, 'content-security-policy', directive='script-src', value="'unsafe-inline'", avoid=['unsafe-inline'], anomalies=['unsafe-inline']) 153 | self.assertEqual(expected, response[0], msg='The report items are not equal:\n') 154 | 155 | @mock.patch('drheader.utils.parse_policy') 156 | def test_validate_must_avoid_for_policy_header__should_report_all_non_compliant_directives(self, parse_policy_mock): 157 | config = structures.CaseInsensitiveDict({ 158 | 'required': True, 159 | 'must-avoid': ['unsafe-inline'], 160 | 'delimiters': {'item_delimiter': ';', 'key_value_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} 161 | }) 162 | self.validator.headers['content-security-policy'] = "script-src 'unsafe-inline'; object-src 'unsafe-inline'" 163 | 164 | parse_policy_mock.return_value = [ 165 | "script-src 'unsafe-inline'", 166 | utils.KeyValueDirective(key='script-src', value=['unsafe-inline'], raw_value="'unsafe-inline'"), 167 | "object-src 'unsafe-inline'", 168 | utils.KeyValueDirective(key='object-src', value=['unsafe-inline'], raw_value="'unsafe-inline'") 169 | ] 170 | 171 | response = self.validator.must_avoid(config, 'content-security-policy') 172 | self.assertEqual('script-src', response[0].directive) 173 | self.assertEqual('object-src', response[1].directive) 174 | -------------------------------------------------------------------------------- /drheader/validators/header_validator.py: -------------------------------------------------------------------------------- 1 | """Validator module for headers.""" 2 | from drheader import utils 3 | from drheader.report import ErrorType, ReportItem 4 | from drheader.validators import base 5 | 6 | _DELIMITER_TYPE = 'item_delimiter' 7 | _POLICY_HEADERS = ['content-security-policy', 'feature-policy', 'permissions-policy'] 8 | _STRIP_HEADERS = ['clear-site-data'] 9 | 10 | 11 | class HeaderValidator(base.ValidatorBase): 12 | """Validator class for validating headers. 13 | 14 | Attributes: 15 | headers (CaseInsensitiveDict): The headers to analyse. 16 | """ 17 | 18 | def __init__(self, headers): 19 | """Initialises a HeaderValidator instance with headers.""" 20 | self.headers = headers 21 | 22 | def exists(self, config, header, **kwargs): 23 | """See base class.""" 24 | if header not in self.headers: 25 | severity = config.get('severity', 'high') 26 | error_type = ErrorType.REQUIRED 27 | if 'value' in config: 28 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 29 | expected = base.get_expected_values(config, 'value', delimiter) 30 | return ReportItem(severity, error_type, header, expected=expected, delimiter=delimiter) 31 | elif 'value-any-of' in config: 32 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 33 | expected = base.get_expected_values(config, 'value-any-of', delimiter) 34 | return ReportItem(severity, error_type, header, expected=expected, delimiter=delimiter) 35 | elif 'value-one-of' in config: 36 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 37 | expected = base.get_expected_values(config, 'value-one-of', delimiter) 38 | return ReportItem(severity, error_type, header, expected=expected) 39 | else: 40 | return ReportItem(severity, error_type, header) 41 | 42 | def not_exists(self, config, header, **kwargs): 43 | """See base class.""" 44 | if header in self.headers: 45 | severity = config.get('severity', 'high') 46 | error_type = ErrorType.DISALLOWED 47 | return ReportItem(severity, error_type, header) 48 | 49 | def value(self, config, header, **kwargs): 50 | """See base class.""" 51 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 52 | expected = base.get_expected_values(config, 'value', delimiter) 53 | 54 | header_value = self.headers[header] 55 | strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None 56 | header_items = utils.parse_policy(header_value, item_delimiter=delimiter, strip_chars=strip_chars) 57 | 58 | if config.get('preserve-order'): 59 | header_items = [item.lower() for item in header_items] 60 | expected_lower = [item.lower() for item in expected] 61 | else: 62 | header_items = {item.lower() for item in header_items} 63 | expected_lower = {item.lower() for item in expected} 64 | 65 | if header_items != expected_lower: 66 | severity = config.get('severity', 'high') 67 | error_type = ErrorType.VALUE 68 | return ReportItem(severity, error_type, header, value=header_value, expected=expected, delimiter=delimiter) 69 | 70 | def value_any_of(self, config, header, **kwargs): 71 | """See base class.""" 72 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 73 | accepted = base.get_expected_values(config, 'value-any-of', delimiter) 74 | 75 | header_value = self.headers[header] 76 | strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None 77 | header_items = utils.parse_policy(header_value, item_delimiter=delimiter, strip_chars=strip_chars) 78 | 79 | anomalies = [] 80 | accepted_lower = [item.lower() for item in accepted] 81 | for item in header_items: 82 | if item not in accepted_lower: 83 | anomalies.append(item) 84 | 85 | if anomalies: 86 | severity = config.get('severity', 'high') 87 | error_type = ErrorType.VALUE_ANY 88 | return ReportItem(severity, error_type, header, value=header_value, expected=accepted, anomalies=anomalies, delimiter=delimiter) # noqa:E501 89 | 90 | def value_one_of(self, config, header, **kwargs): 91 | """See base class.""" 92 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 93 | accepted = base.get_expected_values(config, 'value-one-of', delimiter) 94 | 95 | header_value = self.headers[header] 96 | strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None 97 | 98 | if header_value.strip(strip_chars).lower() not in {item.lower() for item in accepted}: 99 | severity = config.get('severity', 'high') 100 | error_type = ErrorType.VALUE_ONE 101 | return ReportItem(severity, error_type, header, value=header_value, expected=accepted) 102 | 103 | def must_avoid(self, config, header, **kwargs): 104 | """See base class.""" 105 | if header.lower() in _POLICY_HEADERS: 106 | return self._validate_must_avoid_for_policy_header(config, header) 107 | 108 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 109 | disallowed = base.get_expected_values(config, 'must-avoid', delimiter) 110 | 111 | header_value = self.headers[header] 112 | header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) 113 | header_items = {str(item).lower() for item in header_items} 114 | 115 | anomalies = [] 116 | for avoid in disallowed: 117 | if avoid.lower() in header_items: 118 | anomalies.append(avoid) 119 | 120 | if anomalies: 121 | severity = config.get('severity', 'high') 122 | error_type = ErrorType.AVOID 123 | return ReportItem(severity, error_type, header, value=header_value, avoid=disallowed, anomalies=anomalies) 124 | 125 | def must_contain(self, config, header, **kwargs): 126 | """See base class.""" 127 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 128 | expected = base.get_expected_values(config, 'must-contain', delimiter) 129 | 130 | header_value = self.headers[header] 131 | header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) 132 | header_items = {str(item).lower() for item in header_items} 133 | 134 | anomalies = [] 135 | for contain in expected: 136 | if contain.lower() not in header_items: 137 | anomalies.append(contain) 138 | 139 | if anomalies: 140 | severity = config.get('severity', 'high') 141 | error_type = ErrorType.CONTAIN 142 | return ReportItem(severity, error_type, header, value=header_value, expected=expected, anomalies=anomalies, delimiter=delimiter) # noqa:E501 143 | 144 | def must_contain_one(self, config, header, **kwargs): 145 | """See base class.""" 146 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 147 | expected = base.get_expected_values(config, 'must-contain-one', delimiter) 148 | 149 | header_value = self.headers[header] 150 | header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) 151 | header_items = {str(item).lower() for item in header_items} 152 | 153 | if not any(contain.lower() in header_items for contain in expected): 154 | severity = config.get('severity', 'high') 155 | error_type = ErrorType.CONTAIN_ONE 156 | return ReportItem(severity, error_type, header, value=header_value, expected=expected) 157 | 158 | def _validate_must_avoid_for_policy_header(self, config, header): 159 | delimiter = base.get_delimiter(config, _DELIMITER_TYPE) 160 | disallowed = base.get_expected_values(config, 'must-avoid', delimiter) 161 | 162 | header_value = self.headers[header] 163 | header_items = [] 164 | 165 | directives = utils.parse_policy(header_value, **config['delimiters']) 166 | for directive in directives: 167 | try: 168 | header_items.append(directive.key) 169 | header_items += [value for value in directive.value] 170 | except AttributeError: 171 | header_items.append(directive) 172 | header_items = {str(item).lower() for item in header_items} 173 | 174 | anomalies, ncd_items, report_items = [], {}, [] 175 | for avoid in disallowed: 176 | if avoid.lower() in header_items: 177 | non_compliant_directives = [] 178 | for directive in directives: 179 | try: 180 | if avoid in directive.value: 181 | non_compliant_directives.append(directive) 182 | except AttributeError: 183 | pass 184 | 185 | if not non_compliant_directives: 186 | anomalies.append(avoid) 187 | else: 188 | for ncd in non_compliant_directives: 189 | directive, value = ncd.key, ncd.raw_value 190 | ncd_item = { 191 | 'value': value, 192 | 'anomalies': ncd_items.get(directive, {}).get('anomalies', []) + [avoid] 193 | } 194 | ncd_items[directive] = ncd_item 195 | 196 | severity = config.get('severity', 'high') 197 | error_type = ErrorType.AVOID 198 | 199 | if anomalies: 200 | item = ReportItem(severity, error_type, header, value=header_value, avoid=disallowed, anomalies=anomalies) 201 | report_items.append(item) 202 | if ncd_items: 203 | for directive in ncd_items: 204 | value, anomalies = ncd_items[directive]['value'], ncd_items[directive]['anomalies'] 205 | item = ReportItem(severity, error_type, header, directive=directive, value=value, avoid=disallowed, anomalies=anomalies) # noqa:E501 206 | report_items.append(item) 207 | return report_items 208 | -------------------------------------------------------------------------------- /drheader/cli/cli.py: -------------------------------------------------------------------------------- 1 | """Console script for drheader.""" 2 | import ast 3 | import json 4 | import logging 5 | import os 6 | import sys 7 | from json import JSONDecodeError 8 | 9 | import click 10 | import jsonschema 11 | from click import Choice, File, ParamType 12 | 13 | from drheader import Drheader 14 | from drheader.cli import utils 15 | 16 | _OUTPUT_TYPES = ['json', 'table'] 17 | 18 | 19 | class URLParamType(ParamType): 20 | 21 | name = 'URL' 22 | 23 | def convert(self, value, param, ctx): 24 | if not value.startswith('http'): 25 | scheme = click.prompt('Please select a scheme', type=Choice(['http', 'https'], case_sensitive=False)) 26 | value = f'{scheme}://{value}' 27 | return value 28 | 29 | 30 | @click.group(context_settings={'show_default': True}) 31 | @click.version_option() 32 | def main(): 33 | """Console script for drheader.""" 34 | 35 | 36 | @main.group() 37 | def compare(): 38 | """Compare headers with drheader.""" 39 | 40 | 41 | @main.group() 42 | def scan(): 43 | """Scan endpoints with drheader.""" 44 | 45 | 46 | @compare.command(context_settings={ 47 | 'default_map': { 48 | 'output': 'table' 49 | } 50 | }) 51 | @click.argument('file', type=File(), required=True) 52 | @click.option('--cross-origin-isolated', '-co', is_flag=True, help='Enable cross-origin isolation validations') 53 | @click.option('--debug', '-d', is_flag=True, help='Enable debug logging') 54 | @click.option('--junit', '-j', is_flag=True, help='Generate a JUnit report') 55 | @click.option('--merge', '-m', is_flag=True, help='Merge a custom ruleset with the default rules') 56 | @click.option('--output', '-o', type=Choice(_OUTPUT_TYPES, case_sensitive=False), help='Report output format') 57 | @click.option('--rules-file', '-rf', type=File(), help='Use a custom ruleset, loaded from file') 58 | @click.option('--rules-uri', '-ru', metavar='URI', help='Use a custom ruleset, downloaded from URI') 59 | def single(file, cross_origin_isolated, debug, junit, merge, output, rules_file, rules_uri): 60 | if debug: 61 | logging.basicConfig(level=logging.DEBUG) 62 | 63 | scanner = Drheader(headers=json.loads(file.read())) 64 | rules = utils.get_rules(rules_file=rules_file, rules_uri=rules_uri, merge_default=merge) 65 | report = scanner.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) 66 | 67 | if output == 'json': 68 | click.echo(json.dumps(report, indent=4)) 69 | else: 70 | click.echo() 71 | if not report: 72 | click.echo('No issues found!') 73 | else: 74 | click.echo(f'{len(report)} issues found') 75 | click.echo(utils.tabulate_report(report)) 76 | if junit: 77 | utils.file_junit_report(rules, report) 78 | 79 | sys.exit(os.EX_SOFTWARE if report else os.EX_OK) 80 | 81 | 82 | @compare.command(context_settings={ 83 | 'default_map': { 84 | 'output': 'table' 85 | } 86 | }) 87 | @click.argument('file', type=File(), required=True) 88 | @click.option('--cross-origin-isolated', '-co', is_flag=True, help='Enable cross-origin isolation validations') 89 | @click.option('--debug', '-d', is_flag=True, help='Enable debug logging') 90 | @click.option('--merge', '-m', is_flag=True, help='Merge a custom ruleset with the default rules') 91 | @click.option('--output', '-o', type=Choice(_OUTPUT_TYPES, case_sensitive=False), help='Report output format') 92 | @click.option('--rules-file', '-rf', type=File(), help='Use a custom ruleset, loaded from file') 93 | @click.option('--rules-uri', '-ru', metavar='URI', help='Use a custom ruleset, downloaded from URI') 94 | def bulk(file, cross_origin_isolated, debug, merge, output, rules_file, rules_uri): 95 | if debug: 96 | logging.basicConfig(level=logging.DEBUG) 97 | 98 | data = json.loads(file.read()) 99 | with open(os.path.join(os.path.dirname(__file__), '../resources/cli/bulk_compare_schema.json')) as schema: 100 | schema = json.load(schema) 101 | jsonschema.validate(instance=data, schema=schema) 102 | 103 | audit = [] 104 | rules = utils.get_rules(rules_file=rules_file, rules_uri=rules_uri, merge_default=merge) 105 | for target in data: 106 | try: 107 | scanner = Drheader(headers=target['headers']) 108 | report = scanner.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) 109 | audit.append({'url': target['url'], 'report': report}) 110 | except Exception as e: 111 | audit.append({'url': target['url'], 'report': [], 'error': str(e)}) 112 | 113 | if output == 'json': 114 | click.echo(json.dumps(audit, indent=4)) 115 | else: 116 | for target in audit: 117 | click.echo() 118 | if target.get('error'): 119 | click.echo(f"{target['url']}: {target['error']}") 120 | elif not target['report']: 121 | click.echo(f"{target['url']}: No issues found!") 122 | else: 123 | click.echo(f"{target['url']}: {len(target['report'])} issues found") 124 | click.echo(utils.tabulate_report(target['report'])) 125 | 126 | for target in audit: 127 | if target.get('report') or target.get('error'): 128 | sys.exit(os.EX_SOFTWARE) 129 | else: 130 | sys.exit(os.EX_OK) 131 | 132 | 133 | @scan.command(context_settings={ 134 | 'default_map': { 135 | 'output': 'table' 136 | }, 137 | 'ignore_unknown_options': True 138 | }) 139 | @click.argument('target_url', type=URLParamType(), required=True) 140 | @click.argument('request_args', type=click.UNPROCESSED, nargs=-1) 141 | @click.option('--cross-origin-isolated', '-co', is_flag=True, help='Enable cross-origin isolation validations') 142 | @click.option('--debug', '-d', is_flag=True, help='Enable debug logging') 143 | @click.option('--junit', '-j', is_flag=True, help='Generate a JUnit report') 144 | @click.option('--merge', '-m', is_flag=True, help='Merge a custom ruleset with the default rules') 145 | @click.option('--output', '-o', type=Choice(_OUTPUT_TYPES, case_sensitive=False), help='Report output format') 146 | @click.option('--rules-file', '-rf', type=File(), help='Use a custom ruleset, loaded from file') 147 | @click.option('--rules-uri', '-ru', metavar='URI', help='Use a custom ruleset, downloaded from URI') 148 | def single(target_url, request_args, cross_origin_isolated, debug, junit, merge, output, rules_file, rules_uri): # noqa: E501, F811 149 | if debug: 150 | logging.basicConfig(level=logging.DEBUG) 151 | 152 | kwargs = {} 153 | for i in range(0, len(request_args), 2): 154 | key = request_args[i].strip('-').replace('-', '_') 155 | try: 156 | kwargs[key] = json.loads(request_args[i + 1]) 157 | except JSONDecodeError: 158 | try: 159 | kwargs[key] = ast.literal_eval(request_args[i + 1]) # This handles bytes and tuples 160 | except (SyntaxError, ValueError): 161 | kwargs[key] = request_args[i + 1] 162 | 163 | scanner = Drheader(url=target_url, **kwargs) 164 | rules = utils.get_rules(rules_file=rules_file, rules_uri=rules_uri, merge_default=merge) 165 | report = scanner.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) 166 | 167 | if output == 'json': 168 | click.echo(json.dumps(report, indent=4)) 169 | else: 170 | click.echo() 171 | if not report: 172 | click.echo('No issues found!') 173 | else: 174 | click.echo(f"{target_url}: {len(report)} issues found") 175 | click.echo(utils.tabulate_report(report)) 176 | if junit: 177 | utils.file_junit_report(rules, report) 178 | 179 | sys.exit(os.EX_SOFTWARE if report else os.EX_OK) 180 | 181 | 182 | @scan.command(context_settings={ 183 | 'default_map': { 184 | 'file_format': 'json', 185 | 'output': 'table' 186 | } 187 | }) 188 | @click.argument('file', type=File(), required=True) 189 | @click.option('--cross-origin-isolated', '-co', is_flag=True, help='Enable cross-origin isolation validations') 190 | @click.option('--debug', '-d', is_flag=True, help='Enable debug logging') 191 | @click.option('--file-format', '-ff', type=Choice(['json', 'txt'], case_sensitive=False), help='FILE input format') 192 | @click.option('--merge', '-m', is_flag=True, help='Merge a custom ruleset with the default rules') 193 | @click.option('--output', '-o', type=Choice(_OUTPUT_TYPES, case_sensitive=False), help='Report output format') 194 | @click.option('--rules-file', '-rf', type=File(), help='Use a custom ruleset, loaded from file') 195 | @click.option('--rules-uri', '-ru', metavar='URI', help='Use a custom ruleset, downloaded from URI') 196 | def bulk(file, cross_origin_isolated, debug, file_format, merge, output, rules_file, rules_uri): # noqa: F811 197 | if debug: 198 | logging.basicConfig(level=logging.DEBUG) 199 | 200 | if file_format == 'txt': 201 | urls = [{'url': url} for url in list(filter(None, file.read().splitlines()))] 202 | else: 203 | urls = json.loads(file.read()) 204 | with open(os.path.join(os.path.dirname(__file__), '../resources/cli/bulk_scan_schema.json')) as schema: 205 | schema = json.load(schema) 206 | jsonschema.validate(instance=urls, schema=schema) 207 | 208 | audit = [] 209 | rules = utils.get_rules(rules_file=rules_file, rules_uri=rules_uri, merge_default=merge) 210 | for target in urls: 211 | for key, value in target.items(): 212 | try: 213 | target[key] = ast.literal_eval(value) # This handles bytes and tuples 214 | except (SyntaxError, ValueError): 215 | target[key] = value 216 | 217 | try: 218 | scanner = Drheader(**target) 219 | report = scanner.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) 220 | audit.append({'url': target['url'], 'report': report}) 221 | except Exception as e: 222 | audit.append({'url': target['url'], 'report': [], 'error': str(e)}) 223 | 224 | if output == 'json': 225 | click.echo(json.dumps(audit, indent=4)) 226 | else: 227 | for target in audit: 228 | click.echo() 229 | if target.get('error'): 230 | click.echo(f"{target['url']}: {target['error']}") 231 | elif not target['report']: 232 | click.echo(f"{target['url']}: No issues found!") 233 | else: 234 | click.echo(f"{target['url']}: {len(target['report'])} issues found") 235 | click.echo(utils.tabulate_report(target['report'])) 236 | 237 | for target in audit: 238 | if target.get('report') or target.get('error'): 239 | sys.exit(os.EX_SOFTWARE) 240 | else: 241 | sys.exit(os.EX_OK) 242 | 243 | 244 | def start(): 245 | try: 246 | main() 247 | except Exception as e: 248 | click.secho(str(e), fg='red') 249 | 250 | 251 | if __name__ == '__main__': 252 | start() 253 | -------------------------------------------------------------------------------- /RULES.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This document describes the format of the `rules.yml` file, which defines the policy drHEADer uses to audit your 4 | security headers. It also documents how to make changes to it so that you can configure your custom policy based on 5 | your specific requirements. 6 | 7 | ## Contents 8 | 9 | * [Sample Policy](#sample-policy) 10 | * [File Structure](#file-structure) 11 | * [Expected and Disallowed Values](#expected-and-disallowed-values) 12 | * [Permissible Values](#permissible-values) 13 | * [Validation Order](#validation-order) 14 | * [Validating Policy Headers](#validating-policy-headers) 15 | * [Validating Directives](#validating-directives) 16 | * [Validating Cookies](#validating-cookies) 17 | * [Validating Cookies Globally](#validating-cookies-globally) 18 | * [Validating Named Cookies](#validating-named-cookies) 19 | * [Validating Custom Headers](#validating-custom-headers) 20 | * [Example Use Cases](#example-use-cases) 21 | * [Hardening the CSP](#hardening-the-csp) 22 | * [Securing Cookies](#securing-cookies) 23 | * [Preventing Caching](#preventing-caching) 24 | * [Enforcing Cross-Origin Isolation](#enforcing-cross-origin-isolation) 25 | * [Enforcing a Fallback Referrer Policy](#enforcing-a-fallback-referrer-policy) 26 | 27 | ## Sample Policy 28 | 29 | drHEADer policy is defined in a yaml file. An example policy is given below: 30 | 31 | ```yaml 32 | Cache-Control: 33 | Required: True 34 | Value: 35 | - no-store 36 | - max-age=0 37 | Content-Security-Policy: 38 | Required: True 39 | Must-Avoid: 40 | - block-all-mixed-content 41 | - referrer 42 | - unsafe-inline 43 | - unsafe-eval 44 | Directives: 45 | Default-Src: 46 | Required: True 47 | Value-One-Of: 48 | - none 49 | - self 50 | Severity: Critical 51 | Report-To: 52 | Required: Optional 53 | Value: /_/csp_report 54 | Referrer-Policy: 55 | Required: True 56 | Value-One-Of: 57 | - no-referrer 58 | - same-origin 59 | - strict-origin 60 | Server: 61 | Required: False 62 | Severity: Warning 63 | Set-Cookie: 64 | Required: Optional 65 | Must-Contain: 66 | - HttpOnly 67 | - Secure 68 | Must-Contain-One: 69 | - Expires 70 | - Max-Age 71 | X-Frame-Options: 72 | Required: True 73 | Value-One-Of: 74 | - DENY 75 | - SAMEORIGIN 76 | X-XSS-Protection: 77 | Required: True 78 | Value: 0 79 | ``` 80 | 81 | ## File Structure 82 | 83 | The yaml file structure for drHEADer is described below. All elements are case-insensitive, and all checks against 84 | expected and disallowed values are case-insensitive. 85 | 86 | * There can be as many elements as headers you want to audit *(e.g. Content-Security-Policy, Set-Cookie)* 87 | 88 | * Each header must specify whether the header is required via the `required` element. It can take the following values: 89 | * `True`: The item must be present in the HTTP response 90 | * `False`: The item must not be present in the HTTP response 91 | * `Optional`: The header may be present in the HTTP response, but it is not mandatory 92 | 93 | * For items that are set as required or optional, the following additional rules may also be set. The checks will only 94 | run if the item is present in the HTTP response: 95 | * `Value`: The item value must be an exact match with the expected value 96 | * `Value-One-Of`: The item value must be an exact match with exactly one of the expected values 97 | * `Value-Any-Of`: The item value must be an exact match with one or more of the expected values 98 | * `Must-Avoid`: The item value must not contain any of the disallowed values 99 | * `Must-Contain`: The item value must contain all the expected values 100 | * `Must-Contain-One`: The item value must contain one or more of the expected values 101 | 102 | * You can override the default severity for an item by providing a custom severity in the `severity` element 103 | 104 | Within each header element, rules can be set for individual directives via the `directives` element. There can be as 105 | many directive elements as directives you want to audit *(e.g. default-src, script-src)*. The same validations 106 | as above are available for individual directives. 107 | 108 | ### Expected and Disallowed Values 109 | 110 | For elements that define expected or disallowed values, those values can be given either as a list or a string. The two 111 | elements shown below are equivalent: 112 | 113 | ```yaml 114 | Value: 115 | - max-age=31536000 116 | - includeSubDomains 117 | ``` 118 | 119 | ```yaml 120 | Value: max-age=31536000; includeSubDomains 121 | ``` 122 | 123 | If given as a string, individual items must be separated with the correct item delimiter for the header or directive 124 | being evaluated. Therefore, for expected or disallowed values that specify multiple items, giving them as a list is 125 | generally preferred. 126 | 127 | #### Permissible Values 128 | 129 | For checks that define expected or disallowed values, these values can take a number of different formats to cover 130 | various scenarios that you might want to enforce: 131 | 132 | * Enforce or disallow standalone directives or values 133 | 134 | ```yaml 135 | Value: no-store 136 | ``` 137 | 138 | * Enforce or disallow entire key-value directives 139 | 140 | ```yaml 141 | Value: max-age=0 142 | ``` 143 | 144 | * Enforce or disallow specific keys for key-value directives, without stipulating the value 145 | 146 | ```yaml 147 | Value: max-age 148 | ``` 149 | 150 | You can also specify keyword values *(e.g. unsafe-eval, unsafe-inline)* as valid disallowed values for must-avoid checks 151 | when validating policy headers *(see [validating policy headers](#validating-policy-headers))*. 152 | 153 | The validations will match the expected or disallowed values against the whole item value *(standalone directive/value, 154 | entire key-value directive, or key for key-value directive)*. If a value is typically declared in quotation marks, 155 | such as those for [`Clear-Site-Data`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data), or 156 | keywords for policy headers, you must omit the quotation marks: 157 | 158 | ```yaml 159 | Clear-Site-Data: 160 | Required: True 161 | Value: 162 | - cache 163 | - storage 164 | ``` 165 | 166 | #### Validation Order 167 | 168 | By default, order is not preserved when validating. That is, both values shown below are valid for the `Cache-Control` 169 | rule in the sample policy at the beginning of this document: 170 | 171 | ```json 172 | { 173 | "Cache-Control": "no-store; max-age=0" 174 | } 175 | ``` 176 | 177 | ```json 178 | { 179 | "Cache-Control": "max-age=0; no-store" 180 | } 181 | ``` 182 | 183 | There may be scenarios in which you want to preserve order, such as when specifying a fallback policy for 184 | [`Referrer-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#specify_a_fallback_policy). 185 | In such scenarios, you can set `preserve-order` to `True`: 186 | 187 | ```yaml 188 | Value: 189 | - no-referrer 190 | - strict-origin-when-cross-origin 191 | Preserve-Order: True 192 | ``` 193 | 194 | This option is only supported by the `value` validation for headers. Directive and cookie validations are not supported. 195 | 196 | ### Validating Policy Headers 197 | 198 | Policy headers are those that generally follow the syntax `; ` where 199 | `` consists of ` ` and `` can consist of multiple items and keywords. 200 | Currently, this covers 201 | [`Content-Security-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) and 202 | [`Feature-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy). 203 | 204 | You can define disallowed values in must-avoid checks that will be searched for in the values of all key-value 205 | directives. The below will report back all directives in the CSP that contain `unsafe-eval` or `unsafe-inline` as 206 | non-compliant: 207 | 208 | ```yaml 209 | Content-Security-Policy: 210 | Required: True 211 | Must-Avoid: 212 | - unsafe-eval 213 | - unsafe-inline 214 | ``` 215 | 216 | The quotation marks around keywords such as `'none'`, `'self'` and `'unsafe-inline'` in policy headers must not be 217 | included in expected or disallowed values. The quotation marks are stripped from these values in HTTP responses before 218 | they are compared to the expected and disallowed values. The exception to this is if you're enforcing an exact value for 219 | the policy header (i.e. using the `value`, `value-any-of` or `value-one-of` validation), in which case you must keep the 220 | quotation marks around keywords: 221 | 222 | ```yaml 223 | Content-Security-Policy: 224 | Required: True 225 | Value: default-src 'none'; script-src 'self'; style-src 'unsafe-inline' 226 | ``` 227 | 228 | ### Validating Directives 229 | 230 | The mechanism for validating directives is the same as that for validating headers, and all the same validations are 231 | available. You can use it to validate any directive that is declared in a key-value format for any header. Each 232 | directive to be audited needs to be specified as an element under the `directives` element: 233 | 234 | ```yaml 235 | Content-Security-Policy: 236 | Required: True 237 | Directives: 238 | Default-Src: 239 | Required: True 240 | Value-One-Of: 241 | - none 242 | - self 243 | Style-Src: 244 | Required: True 245 | Must-Contain: https://stylesheet-url.com 246 | ``` 247 | 248 | Note that if you want to enforce exists or not-exists validations for a directive, without enforcing any validations on 249 | its value, it is generally simpler to do so using contain and avoid validations respectively at the header level: 250 | 251 | ```yaml 252 | Content-Security-Policy: 253 | Required: True 254 | Must-Contain: 255 | - default-src 256 | Must-Avoid: 257 | - frame-src 258 | ``` 259 | 260 | ### Validating Cookies 261 | 262 | Cookie validations are defined in the `set-cookie` element. The validations for cookies work slightly differently to 263 | those for headers and directives. 264 | 265 | When defining a rule for a cookie, you have two options: 266 | 267 | 1. [Apply the rule globally to all cookies](#validating-cookies-globally) 268 | 2. [Apply the rule only to a specific named cookie](#validating-named-cookies) 269 | 270 | #### Validating Cookies Globally 271 | 272 | To validate cookies globally, you must define the rule under the `set-cookie` element as you would for other headers. 273 | 274 | ```yaml 275 | Set-Cookie: 276 | Required: Optional 277 | Must-Contain: 278 | - HttpOnly 279 | - Secure 280 | ``` 281 | 282 | This example will enforce that all cookies returned set the `HttpOnly` and `Secure` flags. Global validations support 283 | only `must-avoid`, `must-contain` and `must-contain-one` rules. 284 | 285 | #### Validating Named Cookies 286 | 287 | To validate a named cookie, you must specify the cookie to be validated as an element under the `cookies` element. You 288 | can then define validation rules per cookie. DrHEADer will search for a cookie matching the named one and apply the 289 | validations only to that cookie. 290 | 291 | ```yaml 292 | Set-Cookie: 293 | Required: True 294 | Cookies: 295 | Session-Id: 296 | Required: True 297 | Must-Contain: Max-Age 298 | ``` 299 | 300 | The cookie validation mechanism for a named cookie assumes the following: 301 | 302 | * The cookie name and value are declared as the first attribute in the format `=;` 303 | * The cookie name does contain an equals sign `=` 304 | * The cookie value does contain a semicolon `;` 305 | 306 | The `value`, `value-any-of` and `value-one-of` validations are not supported for named cookies. The `directives` 307 | element is also not supported for named cookies. 308 | 309 | ### Validating Custom Headers 310 | 311 | You can include custom headers for validation, and run the same validations on them, as you would any standard headers. 312 | If providing multiple expected or disallowed values for value, avoid or contain checks, you need to specify the relevant 313 | delimiters in the `item-delimiter`, `key-delimiter` and `value-delimiter` elements: 314 | 315 | ```yaml 316 | X-Custom-Header: 317 | Required: True 318 | Must-Contain: 319 | - item_value_1 320 | - item_value_2 321 | Item-Delimiter: ; 322 | Key-Delimiter: = 323 | Value-Delimiter: , 324 | ``` 325 | 326 | For example, the above rule would identify the directives `item_1 = value_1, value_2`, `item_2 = value_1` and `item_3` 327 | from the header given below: 328 | 329 | ```json 330 | { 331 | "X-Custom-Header": "item_1 = value_1, value_2; item_2 = value_1; item_3" 332 | } 333 | ``` 334 | 335 | If the directives are not declared in a key-value format, or the value does not support multiple items, you can omit the 336 | `key-delimiter` and `value-delimiter` elements respectively. 337 | 338 | ## Example Use Cases 339 | 340 | ### Hardening the CSP 341 | 342 | ```yaml 343 | Content-Security-Policy: 344 | Required: True 345 | Must-Avoid: 346 | - unsafe-inline 347 | - unsafe-eval 348 | - unsafe-hashes 349 | Directives: 350 | Default-Src: 351 | Required: True 352 | Must-Contain: 'https:' 353 | Script-Src: 354 | Required: True 355 | Value: self 356 | ``` 357 | 358 | ### Securing Cookies 359 | 360 | ```yaml 361 | Set-Cookie: 362 | Required: Optional 363 | Must-Contain: 364 | - HttpOnly 365 | - SameSite=Strict 366 | - Secure 367 | Must-Contain-One: 368 | - Max-Age 369 | - Expires 370 | ``` 371 | 372 | ### Preventing Caching 373 | 374 | ```yaml 375 | Cache-Control: 376 | Required: True 377 | Value: 378 | - no-store 379 | - max-age=0 380 | Pragma: 381 | Required: True 382 | Value: no-cache 383 | ``` 384 | 385 | ### Enforcing Cross-Origin Isolation 386 | 387 | ```yaml 388 | Cross-Origin-Embedder-Policy: 389 | Required: True 390 | Value: require-corp 391 | Cross-Origin-Opener-Policy: 392 | Required: True 393 | Value: same-origin 394 | ``` 395 | 396 | ** Note that cross-origin isolation validations are opt-in *(see 397 | [cross-origin isolation](README.md#cross-origin-isolation))* 398 | 399 | ### Enforcing a Fallback Referrer Policy 400 | 401 | ```yaml 402 | Referrer-Policy: 403 | Required: True 404 | Value: 405 | - no-referrer 406 | - strict-origin-when-cross-origin 407 | Preserve-Order: True 408 | ``` 409 | -------------------------------------------------------------------------------- /tests/integration_tests/test_rules.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tests.integration_tests import utils 4 | 5 | 6 | class TestDefaultRules(unittest.TestCase): 7 | 8 | def tearDown(self): 9 | utils.reset_default_rules() 10 | 11 | def test__should_validate_all_rules_for_valid_headers(self): 12 | headers = utils.get_headers() 13 | 14 | report = utils.process_test(headers=headers) 15 | self.assertEqual(len(report), 0, msg=utils.build_error_message(report)) 16 | 17 | def test_cache_control__should_exist(self): 18 | headers = utils.delete_headers('Cache-Control') 19 | 20 | report = utils.process_test(headers=headers) 21 | expected = { 22 | 'rule': 'Cache-Control', 23 | 'message': 'Header not included in response', 24 | 'severity': 'high', 25 | 'expected': ['no-store', 'max-age=0'], 26 | 'delimiter': ',' 27 | } 28 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 29 | 30 | def test_cache_control__should_disable_caching(self): 31 | headers = utils.add_or_modify_header('Cache-Control', 'no-cache') 32 | 33 | report = utils.process_test(headers=headers) 34 | expected = { 35 | 'rule': 'Cache-Control', 36 | 'message': 'Value does not match security policy', 37 | 'severity': 'high', 38 | 'value': 'no-cache', 39 | 'expected': ['no-store', 'max-age=0'], 40 | 'delimiter': ',' 41 | } 42 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 43 | 44 | def test_csp__should_exist(self): 45 | headers = utils.delete_headers('Content-Security-Policy') 46 | 47 | report = utils.process_test(headers=headers) 48 | expected = { 49 | 'rule': 'Content-Security-Policy', 50 | 'message': 'Header not included in response', 51 | 'severity': 'high' 52 | } 53 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 54 | 55 | def test_csp__should_enforce_default_src(self): 56 | headers = utils.add_or_modify_header('Content-Security-Policy', 'default-src https://example.com') 57 | 58 | report = utils.process_test(headers=headers) 59 | expected = { 60 | 'rule': 'Content-Security-Policy - default-src', 61 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 62 | 'severity': 'high', 63 | 'value': 'https://example.com', 64 | 'expected': ['none', 'self'] 65 | } 66 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 67 | 68 | def test_coep__should_exist_when_cross_origin_isolated_is_true(self): 69 | headers = utils.delete_headers('Cross-Origin-Embedder-Policy') 70 | 71 | report = utils.process_test(headers=headers, cross_origin_isolated=True) 72 | expected = { 73 | 'rule': 'Cross-Origin-Embedder-Policy', 74 | 'message': 'Header not included in response', 75 | 'severity': 'high', 76 | 'expected': ['require-corp'] 77 | } 78 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Embedder-Policy')) 79 | 80 | def test_coep__should_enforce_require_corp_when_cross_origin_isolated_is_true(self): 81 | headers = utils.add_or_modify_header('Cross-Origin-Embedder-Policy', 'unsafe-none') 82 | 83 | report = utils.process_test(headers=headers, cross_origin_isolated=True) 84 | expected = { 85 | 'rule': 'Cross-Origin-Embedder-Policy', 86 | 'message': 'Value does not match security policy', 87 | 'severity': 'high', 88 | 'value': 'unsafe-none', 89 | 'expected': ['require-corp'] 90 | } 91 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Embedder-Policy')) 92 | 93 | def test_coop__should_exist_when_cross_origin_isolated_is_true(self): 94 | headers = utils.delete_headers('Cross-Origin-Opener-Policy') 95 | 96 | report = utils.process_test(headers=headers, cross_origin_isolated=True) 97 | expected = { 98 | 'rule': 'Cross-Origin-Opener-Policy', 99 | 'message': 'Header not included in response', 100 | 'severity': 'high', 101 | 'expected': ['same-origin'] 102 | } 103 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Opener-Policy')) 104 | 105 | def test_coop__should_enforce_same_origin_when_cross_origin_isolated_is_true(self): 106 | headers = utils.add_or_modify_header('Cross-Origin-Opener-Policy', 'same-origin-allow-popups') 107 | 108 | report = utils.process_test(headers=headers, cross_origin_isolated=True) 109 | expected = { 110 | 'rule': 'Cross-Origin-Opener-Policy', 111 | 'message': 'Value does not match security policy', 112 | 'severity': 'high', 113 | 'value': 'same-origin-allow-popups', 114 | 'expected': ['same-origin'] 115 | } 116 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Opener-Policy')) 117 | 118 | def test_pragma__should_exist(self): 119 | headers = utils.delete_headers('Pragma') 120 | 121 | report = utils.process_test(headers=headers) 122 | expected = { 123 | 'rule': 'Pragma', 124 | 'message': 'Header not included in response', 125 | 'severity': 'high', 126 | 'expected': ['no-cache'] 127 | } 128 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Pragma')) 129 | 130 | def test_referrer_policy__should_exist(self): 131 | headers = utils.delete_headers('Referrer-Policy') 132 | 133 | report = utils.process_test(headers=headers) 134 | expected = { 135 | 'rule': 'Referrer-Policy', 136 | 'message': 'Header not included in response', 137 | 'severity': 'high', 138 | 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] 139 | } 140 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Referrer-Policy')) 141 | 142 | def test_referrer_policy__should_enforce_strict_policy(self): 143 | headers = utils.add_or_modify_header('Referrer-Policy', 'same-origin') 144 | 145 | report = utils.process_test(headers=headers) 146 | expected = { 147 | 'rule': 'Referrer-Policy', 148 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 149 | 'severity': 'high', 150 | 'value': 'same-origin', 151 | 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] 152 | } 153 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Referrer-Policy')) 154 | 155 | def test_server__should_not_exist(self): 156 | headers = utils.add_or_modify_header('Server', 'Apache/2.4.1 (Unix)') 157 | 158 | report = utils.process_test(headers=headers) 159 | expected = { 160 | 'rule': 'Server', 161 | 'message': 'Header should not be returned', 162 | 'severity': 'high' 163 | } 164 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Server')) 165 | 166 | def test_set_cookie__should_enforce_secure_for_all_cookies(self): 167 | headers = utils.add_or_modify_header('Set-Cookie', ['session_id=585733723; HttpOnly; SameSite=Strict']) 168 | 169 | report = utils.process_test(headers=headers) 170 | expected = { 171 | 'rule': 'Set-Cookie - session_id', 172 | 'message': 'Must-Contain directive missed', 173 | 'severity': 'high', 174 | 'value': '585733723; HttpOnly; SameSite=Strict', 175 | 'expected': ['HttpOnly', 'Secure'], 176 | 'delimiter': ';', 177 | 'anomalies': ['Secure'] 178 | } 179 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 180 | 181 | def test_set_cookie__should_enforce_httponly_for_all_cookies(self): 182 | headers = utils.add_or_modify_header('Set-Cookie', ['session_id=585733723; Secure; SameSite=Strict']) 183 | 184 | report = utils.process_test(headers=headers) 185 | expected = { 186 | 'rule': 'Set-Cookie - session_id', 187 | 'message': 'Must-Contain directive missed', 188 | 'severity': 'high', 189 | 'value': '585733723; Secure; SameSite=Strict', 190 | 'expected': ['HttpOnly', 'Secure'], 191 | 'delimiter': ';', 192 | 'anomalies': ['HttpOnly'] 193 | } 194 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 195 | 196 | def test_strict_transport_security__should_exist(self): 197 | headers = utils.delete_headers('Strict-Transport-Security') 198 | 199 | report = utils.process_test(headers=headers) 200 | expected = { 201 | 'rule': 'Strict-Transport-Security', 202 | 'message': 'Header not included in response', 203 | 'severity': 'high', 204 | 'expected': ['max-age=31536000', 'includeSubDomains'], 205 | 'delimiter': ';' 206 | } 207 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Strict-Transport-Security')) 208 | 209 | def test_user_agent__should_not_exist(self): 210 | headers = utils.add_or_modify_header('User-Agent', 'Dalvik/2.1.0 (Linux; U; Android 6.0.1; Nexus Player)') 211 | 212 | report = utils.process_test(headers=headers) 213 | expected = { 214 | 'rule': 'User-Agent', 215 | 'message': 'Header should not be returned', 216 | 'severity': 'high' 217 | } 218 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'User-Agent')) 219 | 220 | def test_x_aspnet_version__should_not_exist(self): 221 | headers = utils.add_or_modify_header('X-AspNet-Version', '2.0.50727') 222 | 223 | report = utils.process_test(headers=headers) 224 | expected = { 225 | 'rule': 'X-AspNet-Version', 226 | 'message': 'Header should not be returned', 227 | 'severity': 'high' 228 | } 229 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-AspNet-Version')) 230 | 231 | def test_x_client_ip__should_not_exist(self): 232 | headers = utils.add_or_modify_header('X-Client-IP', '27.59.32.182') 233 | 234 | report = utils.process_test(headers=headers) 235 | expected = { 236 | 'rule': 'X-Client-IP', 237 | 'message': 'Header should not be returned', 238 | 'severity': 'high' 239 | } 240 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Client-IP')) 241 | 242 | def test_x_content_type_options__should_exist(self): 243 | headers = utils.delete_headers('X-Content-Type-Options') 244 | 245 | report = utils.process_test(headers=headers) 246 | expected = { 247 | 'rule': 'X-Content-Type-Options', 248 | 'message': 'Header not included in response', 249 | 'severity': 'high', 250 | 'expected': ['nosniff'] 251 | } 252 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Content-Type-Options')) 253 | 254 | def test_x_frame_options__should_exist(self): 255 | headers = utils.delete_headers('X-Frame-Options') 256 | 257 | report = utils.process_test(headers=headers) 258 | expected = { 259 | 'rule': 'X-Frame-Options', 260 | 'message': 'Header not included in response', 261 | 'severity': 'high', 262 | 'expected': ['DENY', 'SAMEORIGIN'] 263 | } 264 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Frame-Options')) 265 | 266 | def test_x_frame_options__should_disable_allow_from(self): 267 | headers = utils.add_or_modify_header('X-Frame-Options', 'ALLOW-FROM https//example.com') 268 | 269 | report = utils.process_test(headers=headers) 270 | expected = { 271 | 'rule': 'X-Frame-Options', 272 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 273 | 'severity': 'high', 274 | 'value': 'ALLOW-FROM https//example.com', 275 | 'expected': ['DENY', 'SAMEORIGIN'] 276 | } 277 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Frame-Options')) 278 | 279 | def test_x_forwarded_for__should_not_exist(self): 280 | headers = utils.add_or_modify_header('X-Forwarded-For', '2001:db8:85a3:8d3:1319:8a2e:370:7348') 281 | 282 | report = utils.process_test(headers=headers) 283 | expected = { 284 | 'rule': 'X-Forwarded-For', 285 | 'message': 'Header should not be returned', 286 | 'severity': 'high' 287 | } 288 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Forwarded-For')) 289 | 290 | def test_x_generator__should_not_exist(self): 291 | headers = utils.add_or_modify_header('X-Generator', 'Drupal 7 (http://drupal.org)') 292 | 293 | report = utils.process_test(headers=headers) 294 | expected = { 295 | 'rule': 'X-Generator', 296 | 'message': 'Header should not be returned', 297 | 'severity': 'high' 298 | } 299 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Generator')) 300 | 301 | def test_x_powered_by__should_not_exist(self): 302 | headers = utils.add_or_modify_header('X-Powered-By', 'ASP.NET') 303 | 304 | report = utils.process_test(headers=headers) 305 | expected = { 306 | 'rule': 'X-Powered-By', 307 | 'message': 'Header should not be returned', 308 | 'severity': 'high' 309 | } 310 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Powered-By')) 311 | 312 | def test_x_xss_protection__should_exist(self): 313 | headers = utils.delete_headers('X-XSS-Protection') 314 | 315 | report = utils.process_test(headers=headers) 316 | expected = { 317 | 'rule': 'X-XSS-Protection', 318 | 'message': 'Header not included in response', 319 | 'severity': 'high', 320 | 'expected': ['0'] 321 | } 322 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-XSS-Protection')) 323 | 324 | def test_x_xss_protection__should_disable_filter(self): 325 | headers = utils.add_or_modify_header('X-XSS-Protection', '1; mode=block') 326 | 327 | report = utils.process_test(headers=headers) 328 | expected = { 329 | 'rule': 'X-XSS-Protection', 330 | 'message': 'Value does not match security policy', 331 | 'severity': 'high', 332 | 'value': '1; mode=block', 333 | 'expected': ['0'] 334 | } 335 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-XSS-Protection')) 336 | -------------------------------------------------------------------------------- /tests/integration_tests/test_drheader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import unittest 4 | import yaml 5 | 6 | from tests.integration_tests import utils 7 | 8 | 9 | class TestDrHeader(unittest.TestCase): 10 | 11 | def tearDown(self): 12 | utils.reset_default_rules() 13 | 14 | def test__should_get_headers_from_url(self): 15 | report = utils.process_test(url='https://google.com') 16 | self.assertIsNotNone(report) 17 | 18 | def test_header__should_handle_case_insensitive_header_names(self): 19 | modify_rule('Content-Security-Policy', {'Required': True}) 20 | headers = utils.add_or_modify_header('Content-Security-Policy', "default-src 'none'") 21 | headers['CONTENT-SECURITY-POLICY'] = headers.pop('Content-Security-Policy') 22 | 23 | report = utils.process_test(headers=headers) 24 | self.assertEqual(0, len(report), msg=utils.build_error_message(report)) 25 | 26 | def test_header__should_handle_case_insensitive_header_values(self): 27 | modify_rule('Content-Security-Policy', {'Required': True, 'Must-Contain': ['default-src']}) 28 | headers = utils.add_or_modify_header('Content-Security-Policy', "default-src 'none'") 29 | headers['Content-Security-Policy'] = headers.pop('Content-Security-Policy').upper() 30 | 31 | report = utils.process_test(headers=headers) 32 | self.assertEqual(0, len(report), msg=utils.build_error_message(report)) 33 | 34 | def test_header__should_not_run_cross_origin_validations_when_cross_origin_isolated_is_false(self): 35 | headers = utils.delete_headers('Cross-Origin-Embedder-Policy', 'Cross-Origin-Opener-Policy') 36 | 37 | report = utils.process_test(headers=headers, cross_origin_isolated=False) 38 | self.assertEqual(0, len(report), msg=utils.build_error_message(report)) 39 | 40 | def test_header__should_not_validate_an_optional_header_that_is_not_present(self): 41 | modify_rule('Cache-Control', {'Required': 'Optional', 'Value': ['no-store']}) 42 | headers = utils.delete_headers('Cache-Control') 43 | 44 | report = utils.process_test(headers=headers) 45 | self.assertEqual(0, len(report), msg=utils.build_error_message(report)) 46 | 47 | def test_header__should_validate_an_optional_header_that_is_present(self): 48 | modify_rule('Cache-Control', {'Required': 'Optional', 'Value': 'no-store'}) 49 | headers = utils.add_or_modify_header('Cache-Control', 'no-cache') 50 | 51 | report = utils.process_test(headers=headers) 52 | expected = { 53 | 'rule': 'Cache-Control', 54 | 'message': 'Value does not match security policy', 55 | 'severity': 'high', 56 | 'value': 'no-cache', 57 | 'expected': ['no-store'] 58 | } 59 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 60 | 61 | def test_header__exists_validation_ko(self): 62 | modify_rule('Cache-Control', {'Required': True}) 63 | headers = utils.delete_headers('Cache-Control') 64 | 65 | report = utils.process_test(headers=headers) 66 | expected = { 67 | 'rule': 'Cache-Control', 68 | 'message': 'Header not included in response', 69 | 'severity': 'high' 70 | } 71 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 72 | 73 | def test_header__not_exists_validation_ko(self): 74 | modify_rule('Cache-Control', {'Required': False}) 75 | headers = utils.add_or_modify_header('Cache-Control', 'private, public') 76 | 77 | report = utils.process_test(headers=headers) 78 | expected = { 79 | 'rule': 'Cache-Control', 80 | 'message': 'Header should not be returned', 81 | 'severity': 'high' 82 | } 83 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 84 | 85 | def test_header__value_validation_ko(self): 86 | modify_rule('Cache-Control', {'Required': True, 'Value': ['no-store']}) 87 | headers = utils.add_or_modify_header('Cache-Control', 'no-cache') 88 | 89 | report = utils.process_test(headers=headers) 90 | expected = { 91 | 'rule': 'Cache-Control', 92 | 'message': 'Value does not match security policy', 93 | 'severity': 'high', 94 | 'value': 'no-cache', 95 | 'expected': ['no-store'] 96 | } 97 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 98 | 99 | def test_header__value_any_of_validation_ko(self): 100 | modify_rule('Cache-Control', {'Required': True, 'Value-Any-Of': ['private', 'no-cache', 'no-transform']}) 101 | headers = utils.add_or_modify_header('Cache-Control', 'private, public, no-transform') 102 | 103 | report = utils.process_test(headers=headers) 104 | expected = { 105 | 'rule': 'Cache-Control', 106 | 'message': 'Value does not match security policy. At least one of the expected items was expected', 107 | 'severity': 'high', 108 | 'value': 'private, public, no-transform', 109 | 'expected': ['private', 'no-cache', 'no-transform'], 110 | 'delimiter': ',', 111 | 'anomalies': ['public'] 112 | } 113 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 114 | 115 | def test_header__value_one_of_validation_ko(self): 116 | modify_rule('Cache-Control', {'Required': True, 'Value-One-Of': ['no-cache', 'no-store']}) 117 | headers = utils.add_or_modify_header('Cache-Control', 'private, must-revalidate') 118 | 119 | report = utils.process_test(headers=headers) 120 | expected = { 121 | 'rule': 'Cache-Control', 122 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 123 | 'severity': 'high', 124 | 'value': 'private, must-revalidate', 125 | 'expected': ['no-cache', 'no-store'] 126 | } 127 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 128 | 129 | def test_header__must_avoid_validation_ko(self): 130 | modify_rule('Cache-Control', {'Required': True, 'Must-Avoid': ['private', 'public']}) 131 | headers = utils.add_or_modify_header('Cache-Control', 'private, must-understand') 132 | 133 | report = utils.process_test(headers=headers) 134 | expected = { 135 | 'rule': 'Cache-Control', 136 | 'message': 'Must-Avoid directive included', 137 | 'severity': 'high', 138 | 'value': 'private, must-understand', 139 | 'avoid': ['private', 'public'], 140 | 'anomalies': ['private'] 141 | } 142 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 143 | 144 | def test_header__must_contain_validation_ko(self): 145 | modify_rule('Cache-Control', {'Required': True, 'Must-Contain': ['must-revalidate']}) 146 | headers = utils.add_or_modify_header('Cache-Control', 'private') 147 | 148 | report = utils.process_test(headers=headers) 149 | expected = { 150 | 'rule': 'Cache-Control', 151 | 'message': 'Must-Contain directive missed', 152 | 'severity': 'high', 153 | 'value': 'private', 154 | 'expected': ['must-revalidate'], 155 | 'anomalies': ['must-revalidate'] 156 | } 157 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 158 | 159 | def test_header__must_contain_one_validation_ko(self): 160 | modify_rule('Cache-Control', {'Required': True, 'Must-Contain-One': ['must-revalidate', 'no-cache']}) 161 | headers = utils.add_or_modify_header('Cache-Control', 'private') 162 | 163 | report = utils.process_test(headers=headers) 164 | expected = { 165 | 'rule': 'Cache-Control', 166 | 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', 167 | 'severity': 'high', 168 | 'value': 'private', 169 | 'expected': ['must-revalidate', 'no-cache'] 170 | } 171 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) 172 | 173 | def test_directive__value_validation_ko(self): 174 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value': ['self']}}}) 175 | headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') 176 | 177 | report = utils.process_test(headers=headers) 178 | expected = { 179 | 'rule': 'Content-Security-Policy - style-src', 180 | 'message': 'Value does not match security policy', 181 | 'severity': 'high', 182 | 'value': 'https://example.com', 183 | 'expected': ['self'] 184 | } 185 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 186 | 187 | def test_directive__value_any_of_validation_ko(self): 188 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value-Any-Of': ['https://example1.com', 'https://example2.com']}}}) 189 | headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') 190 | 191 | report = utils.process_test(headers=headers) 192 | expected = { 193 | 'rule': 'Content-Security-Policy - style-src', 194 | 'message': 'Value does not match security policy. At least one of the expected items was expected', 195 | 'severity': 'high', 196 | 'value': 'https://example.com', 197 | 'expected': ['https://example1.com', 'https://example2.com'], 198 | 'delimiter': ' ', 199 | 'anomalies': ['https://example.com'] 200 | } 201 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 202 | 203 | def test_directive__value_one_of_validation_ko(self): 204 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value-One-Of': ['none', 'self']}}}) 205 | headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') 206 | 207 | report = utils.process_test(headers=headers) 208 | expected = { 209 | 'rule': 'Content-Security-Policy - style-src', 210 | 'message': 'Value does not match security policy. Exactly one of the expected items was expected', 211 | 'severity': 'high', 212 | 'value': 'https://example.com', 213 | 'expected': ['none', 'self'] 214 | } 215 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 216 | 217 | def test_directive__must_avoid_validation_ko(self): 218 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Avoid': ['unsafe-inline']}}}) 219 | headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'unsafe-inline'") 220 | 221 | report = utils.process_test(headers=headers) 222 | expected = { 223 | 'rule': 'Content-Security-Policy - style-src', 224 | 'message': 'Must-Avoid directive included', 225 | 'severity': 'high', 226 | 'value': "'unsafe-inline'", 227 | 'avoid': ['unsafe-inline'], 228 | 'anomalies': ['unsafe-inline'] 229 | } 230 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 231 | 232 | def test_directive__must_contain_validation_ko(self): 233 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Contain': ['https://example.com']}}}) 234 | headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'self'") 235 | 236 | report = utils.process_test(headers=headers) 237 | expected = { 238 | 'rule': 'Content-Security-Policy - style-src', 239 | 'message': 'Must-Contain directive missed', 240 | 'severity': 'high', 241 | 'value': "'self'", 242 | 'expected': ['https://example.com'], 243 | 'anomalies': ['https://example.com'] 244 | } 245 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 246 | 247 | def test_directive__must_contain_one_validation_ko(self): 248 | modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Contain-One': ['https://example1.com', 'https://example2.com']}}}) 249 | headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'self'") 250 | 251 | report = utils.process_test(headers=headers) 252 | expected = { 253 | 'rule': 'Content-Security-Policy - style-src', 254 | 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', 255 | 'severity': 'high', 256 | 'value': "'self'", 257 | 'expected': ['https://example1.com', 'https://example2.com'] 258 | } 259 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) 260 | 261 | def test_cookie__exists_validation_ko(self): 262 | modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True}}}) 263 | headers = utils.add_or_modify_header('Set-Cookie', ['tracker=657488329; HttpOnly; Secure']) 264 | 265 | report = utils.process_test(headers=headers) 266 | expected = { 267 | 'rule': 'Set-Cookie - session', 268 | 'message': 'Cookie not included in response', 269 | 'severity': 'high' 270 | } 271 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 272 | 273 | def test_cookie__not_exists_validation_ko(self): 274 | modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': False}}}) 275 | headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure']) 276 | 277 | report = utils.process_test(headers=headers) 278 | expected = { 279 | 'rule': 'Set-Cookie - session', 280 | 'message': 'Cookie should not be returned', 281 | 'severity': 'high' 282 | } 283 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 284 | 285 | def test_cookie__must_avoid_validation_ko(self): 286 | modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Avoid': ['Path']}}}) 287 | headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure; Path=/docs']) 288 | 289 | report = utils.process_test(headers=headers) 290 | expected = { 291 | 'rule': 'Set-Cookie - session', 292 | 'message': 'Must-Avoid directive included', 293 | 'severity': 'high', 294 | 'value': '657488329; HttpOnly; Secure; Path=/docs', 295 | 'avoid': ['Path'], 296 | 'anomalies': ['Path'] 297 | } 298 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 299 | 300 | def test_cookie__must_contain_validation_ko(self): 301 | modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Contain': ['HttpOnly', 'SameSite=Strict', 'Secure']}}}) 302 | headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; SameSite=Lax; Secure']) 303 | 304 | report = utils.process_test(headers=headers) 305 | expected = { 306 | 'rule': 'Set-Cookie - session', 307 | 'message': 'Must-Contain directive missed', 308 | 'severity': 'high', 309 | 'value': '657488329; HttpOnly; SameSite=Lax; Secure', 310 | 'expected': ['HttpOnly', 'SameSite=Strict', 'Secure'], 311 | 'delimiter': ';', 312 | 'anomalies': ['SameSite=Strict'] 313 | } 314 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 315 | 316 | def test_cookie__must_contain_one_validation_ko(self): 317 | modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Contain-One': ['Expires', 'Max-Age']}}}) 318 | headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure']) 319 | 320 | report = utils.process_test(headers=headers) 321 | expected = { 322 | 'rule': 'Set-Cookie - session', 323 | 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', 324 | 'severity': 'high', 325 | 'value': '657488329; HttpOnly; Secure', 326 | 'expected': ['Expires', 'Max-Age'] 327 | } 328 | self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) 329 | 330 | 331 | def modify_rule(rule_name, rule_value): 332 | with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml'), 'w') as rules: 333 | modified_rule = {rule_name: rule_value} 334 | yaml.dump(modified_rule, rules, sort_keys=False) 335 | --------------------------------------------------------------------------------