diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f0ee97b..0b90c6a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,9 +3,20 @@ updates: - package-ecosystem: "pip" directory: "/" schedule: - interval: "daily" + interval: "daily" target-branch: "develop" reviewers: - "danielcuthbert" - "javixeneize" - "pealtrufo" + - "emilejq" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + reviewers: + - "danielcuthbert" + - "javixeneize" + - "pealtrufo" + - "emilejq" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 5f012a0..fb449fa 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -17,10 +17,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install PIP Dependencies - run: pip install -r requirements_dev.txt + run: | + pip install -r requirements.txt + pip install -r requirements_dev.txt - - name: Test with Nosetests - run: python -m nose --with-xunit --xunit-file=${{ matrix.python-version }}.results.xml + - name: Test with pytest + run: python -m pytest --junitxml ${{ matrix.python-version }}.results.xml - name: Upload Test results uses: actions/upload-artifact@master @@ -36,7 +38,7 @@ jobs: - name: Safety dependency scan run: python -m safety check - + - name: Checkout origin branch if PR 'to-branch' is master if: github.base_ref == 'master' uses: actions/checkout@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc1be51..bb585d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Dr Header Release Task -on: +on: push: branches: [ master ] @@ -12,19 +12,21 @@ jobs: matrix: python-version: [3.7] steps: - - name: Checkout Code + - name: Checkout Code uses: actions/checkout@v2 - + - name: Set up Python uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -r requirements_dev.txt - - - name: Test with Nosetests - run: python -m nose --with-xunit --xunit-file=${{ matrix.python-version }}.results.xml + run: | + pip install -r requirements.txt + pip install -r requirements_dev.txt + + - name: Test with pytest + run: python -m pytest --junitxml ${{ matrix.python-version }}.results.xml - name: Flake8 styles run: python -m flake8 drheader @@ -38,12 +40,12 @@ jobs: - name: Make Wheel run: | python3 setup.py sdist bdist_wheel - + - name: Dump build info for release run: | git log --pretty=oneline > changelog - python3 setup.py --version > version - + python3 setup.py --version > version + - name: Get bumpversion run: echo "VERSION"=$(grep -i 'current_version = ' setup.cfg | head -1 | tr -d 'current_version = ') >> $GITHUB_ENV @@ -57,25 +59,25 @@ jobs: release_name: Release v${{ env.VERSION }} draft: false prerelease: false - + - name: Upload Wheel id: upload_wheel uses: actions/upload-release-asset@v1.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ./dist/drheader-${{ env.VERSION }}-py2.py3-none-any.whl asset_name: DrHeader Wheel asset_content_type: application/x-python-wheel - + - name: Upload Changelog id: upload_changelog uses: actions/upload-release-asset@v1.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: changelog asset_name: DrHeader changelog asset_content_type: text/plain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 660262e..18dc445 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,34 +56,34 @@ Ready to contribute? Here's how to set up 1. Fork the drheader repo on GitHub. 2. Clone your fork locally: - + $ git clone git@github.com:your_name_here/drheader.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development: - + $ mkvirtualenv drheader $ cd drheader/ $ python setup.py develop 4. Create a branch for local development: - + $ git checkout -b name-of-your-bugfix-or-feature - + Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox: - + $ flake8 drheader tests - $ python setup.py test or py.test + $ py.test $ tox - + To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub: - + $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature @@ -95,8 +95,8 @@ Ready to contribute? Here's how to set up When submitting a pull request, please ensure that: 1. You submit it to 'develop' branch and there's no conflicts. -2. You check all tests are passing and have created new ones if change not covered in current test suite. +2. You check all tests are passing and have created new ones if change not covered in current test suite. 3. You update `README.md` if functionality has been added or modified. If you are creating new classes or methods, please use docstring to document the code. -4. You update `RULES.md` when extending or modifying the way rules can be used, adding documentation and examples for the new/modified feature. +4. You update `RULES.md` when extending or modifying the way rules can be used, adding documentation and examples for the new/modified feature. 5. Code works for Python >= 3.7 6. Once PR is submitted, workflow steps are successful (e.g.: Flake8, Bandit, Safety, etc.) diff --git a/MANIFEST.in b/MANIFEST.in index 3f439a1..12a0985 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include LICENSE include README.md include RULES.md include drheader/*.yml +include drheader/resources/delimiters.json recursive-include tests * recursive-exclude * __pycache__ diff --git a/README.md b/README.md index 36135fa..e78da74 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ It is also possible to call drHEADer from within an existing project, and this i from drheader import Drheader # create drheader instance -drheader_instance = Drheader(headers={'X-XSS-Protection': '1; mode=block'}, status_code=200) +drheader_instance = Drheader(headers={'X-XSS-Protection': '1; mode=block'}) report = drheader_instance.analyze() print(report) @@ -124,6 +124,25 @@ drheader_instance = Drheader(url="http://test.com", verify=False) Other arguments may be included in the future such as _timeout_, *allow_redirects* or _proxies_. +#### Cross-Origin Isolation +The default rules in drHEADer support cross-origin isolation via the `Cross-Origin-Embedder-Policy` and +`Cross-Origin-Opener-Policy` headers. Due to the potential for this to break websites that have not yet properly +configured their sub-resources for cross-origin isolation, these validations are opt-in at analysis time. If you want to +enforce these cross-origin isolation validations, you must pass the `cross-origin-isolated` flag. + +Using the CLI: +```shell +$ drheader scan single https://example.com --cross-origin-isolated +``` + +In a project: +```python +import drheader + +drheader_instance = drheader.Drheader(url='https://example.com') +drheader_instance.analyze(cross_origin_isolated=True) +``` + ## How Do I Customise drHEADer Rules? DrHEADer relies on a yaml file that defines the policy it will use when auditing security headers. The file is located at `./drheader/rules.yml`, and you can customise it to fit your particular needs. Please follow this [link](RULES.md) if you want to know more. @@ -137,7 +156,7 @@ DrHEADer relies on a yaml file that defines the policy it will use when auditing We have a lot of ideas for drHEADer, and will push often as a result. Some of the things you'll see shortly are: * Building on the Python library to make it easier to embed in your own projects. -* Releasing the API, which is seperate from the core library - the API allows you to hit URLs or endpoints at scale +* Releasing the API, which is separate from the core library - the API allows you to hit URLs or endpoints at scale * Better integration into MiTM proxies. ## Who Is Behind It? diff --git a/RELEASING.md b/RELEASING.md index 2dbe5f0..dea99c6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,18 +1,18 @@ # Releasing -Our approach to releasing new versions is quite simple. A new version will be released everytime there's a push to master branch. +Our approach to releasing new versions is quite simple. A new version will be released everytime there's a push to master branch. -We've managed to have all the process to bump version for next release in the different files fully automated by using [github actions](https://github.com/features/actions). +We've managed to have all the process to bump version for next release in the different files fully automated by using [GitHub Actions](https://github.com/features/actions). -We currently have 2 github actions configured in this repo, which will be triggered when: +We currently have 2 GitHub Actions configured in this repo, which will be triggered when: * There's a pull request. - * If the PR is to a non master branch, this action will run standard checks like nosetests, flake8, bandit and safety to ensure everything is good with the code. + * If the PR is to a non-master branch, this action will run standard checks like pytest, flake8, bandit and safety to ensure everything is good with the code. * If the PR is to the master branch, this action will run standard checks, automatically bump release version number in appropriate files and commit those changes to the pull request branch. * There are changes pushed to master branch. * This action will run standard checks, create the wheel, the release/tag using the version previously bumped and publish the artefact to Pypi -To bump version in files prior to release, we use [bump2version](https://github.com/c4urself/bump2version). The configuration for it to know what is the current version, what files need to have the version bumped up and what is the next version is in `setup.cfg`. +To bump version in files prior to release, we use [bump2version](https://github.com/c4urself/bump2version). The configuration for it to know what is the current version, what files need to have the version bumped up and what is the next version is in `setup.cfg`. ```ini [bumpversion] @@ -36,6 +36,6 @@ replace = __version__ = '{new_version}' ... ``` -With this configuration, we are specifying that only those three files need to have the version bumped before release. By default, `bump2version` bumps a minor version (ie . from 1.2.2 to 1.3.0), but if we want it to be a major version or a patch bump, we only need to specify the `new_version` attribute in the configuration with the version we want to use for the release. +With this configuration, we are specifying that only those three files need to have the version bumped before release. By default, `bump2version` bumps a minor version (ie . from 1.2.2 to 1.3.0), but if we want it to be a major version or a patch bump, we only need to specify the `new_version` attribute in the configuration with the version we want to use for the release. -**Note**: Master and develop branches are protected. It means that we require commits to be pushed through pull requests, status checks to pass before merging and restrict who can push to those branches. We aimed to have the release process fully automated, but because issues described [here](https://github.community/t5/GitHub-Actions/How-to-push-to-protected-branches-in-a-GitHub-Action/td-p/29609) or [here](https://github.community/t5/GitHub-Actions/Automatic-version-update-in-protected-branch/m-p/56469#M9895) when using github actions for this, we decided that disabling this protection in **develop** branch just when a PR from develop to master is submitted would be a good approach for us, so that the action that bumps the versions and commits the changes back can complete successfuly. +**Note**: Master and develop branches are protected. It means that we require commits to be pushed through pull requests, status checks to pass before merging and restrict who can push to those branches. We aimed to have the release process fully automated, but because issues described [here](https://github.community/t5/GitHub-Actions/How-to-push-to-protected-branches-in-a-GitHub-Action/td-p/29609) or [here](https://github.community/t5/GitHub-Actions/Automatic-version-update-in-protected-branch/m-p/56469#M9895) when using GitHub Actions for this, we decided that disabling this protection in **develop** branch just when a PR from develop to master is submitted would be a good approach for us, so that the action that bumps the versions and commits the changes back can complete successfully. diff --git a/RULES.md b/RULES.md index bec15a9..31afe1a 100644 --- a/RULES.md +++ b/RULES.md @@ -1,88 +1,357 @@ # Introduction +This document describes the format of the `rules.yml` file, which defines the policy drHEADer uses to audit your +security headers. It also documents how to make changes to it so that you can configure your custom policy based on +your specific requirements. -This document describes the format of the `rules.yml` file. This file defines the policy drHEADer relies on to audit security headers. It also documents how to make changes to it so that you can configure your custom policy based on your particular requirements. - -# File Format - -drHEADer policy is a yaml file, which is a human-readable format commonly used for configuration files. See a yaml sample drHEADer policy below: +## Contents +* [Sample Policy](#sample-policy) +* [File Structure](#file-structure) + * [Expected and Disallowed Values](#expected-and-disallowed-values) + * [Permissible Values](#permissible-values) + * [Validation Order](#validation-order) + * [Validating Policy Headers](#validating-policy-headers) + * [Validating Directives](#validating-directives) + * [Validating Cookies](#validating-cookies) + * [Validating Cookies Globally](#validating-cookies-globally) + * [Validating Named Cookies](#validating-named-cookies) + * [Validating Custom Headers](#validating-custom-headers) +* [Example Use Cases](#example-use-cases) + * [Hardening the CSP](#hardening-the-csp) + * [Securing Cookies](#securing-cookies) + * [Preventing Caching](#preventing-caching) + * [Enforcing Cross-Origin Isolation](#enforcing-cross-origin-isolation) + * [Enforcing a Fallback Referrer Policy](#enforcing-a-fallback-referrer-policy) +## Sample Policy +drHEADer policy is defined in a yaml file. An example policy is given below: ```yaml Headers: - X-Frame-Options: - Required: True - Enforce: True - Value: - - SAMEORIGIN - - DENY - X-XSS-Protection: + Cache-Control: Required: True - Enforce: True - Value: - - 1; mode=block - Server: - Required: False - Enforce: False Value: + - no-store + - max-age=0 Content-Security-Policy: Required: True - Enforce: False - Value: - Must-Contain-One: - - default-src 'none' - - default-src 'self' Must-Avoid: - - unsafe-inline - - unsafe-eval + - block-all-mixed-content + - referrer + - unsafe-inline + - unsafe-eval Directives: - script-src: + Default-Src: Required: True - Enforce: True - Value: - - self - style-src: - Required: True - Enforce: False - Value: - Must-Avoid: - - http://www.example.com + Value-One-Of: + - none + - self + Severity: Critical + Report-To: + Required: Optional + Value: /_/csp_report + Referrer-Policy: + Required: True + Value-One-Of: + - no-referrer + - same-origin + - strict-origin + Server: + Required: False + Severity: Warning Set-Cookie: Required: Optional - Enforce: False - Value: Must-Contain: + - HttpOnly + - Secure + Must-Contain-One: + - Expires + - Max-Age + X-Frame-Options: + Required: True + Value-One-Of: + - DENY + - SAMEORIGIN + X-XSS-Protection: + Required: True + Value: 0 +``` + +## File Structure +The yaml file structure for drHEADer is described below. All elements are case-insensitive, and all checks against +expected and disallowed values are case-insensitive. + +* There must always be a root element `headers` +* Inside the root element, there can be as many elements as headers you want to audit *(e.g. Content-Security-Policy, +Set-Cookie)* + +* Each header must specify whether the header is required via the `required` element. It can take the following values: + * `True`: The item must be present in the HTTP response + * `False`: The item must not be present in the HTTP response + * `Optional`: The header may be present in the HTTP response, but it is not mandatory + +* For items that are set as required or optional, the following additional rules may also be set. The checks will only +run if the item is present in the HTTP response: + * `Value`: The item value must be an exact match with the expected value + * `Value-One-Of`: The item value must be an exact match with exactly one of the expected values + * `Value-Any-Of`: The item value must be an exact match with one or more of the expected values + * `Must-Avoid`: The item value must not contain any of the disallowed values + * `Must-Contain`: The item value must contain all the expected values + * `Must-Contain-One`: The item value must contain one or more of the expected values + +* You can override the default severity for an item by providing a custom severity in the `severity` element + +Within each header element, rules can be set for individual directives via the `directives` element. There can be as +many directive elements as directives you want to audit *(e.g. default-src, script-src)*. The same validations +as above are available for individual directives. + +### Expected and Disallowed Values +For elements that define expected or disallowed values, those values can be given either as a list or a string. The two +elements shown below are equivalent: +```yaml +Value: + - max-age=31536000 + - includeSubDomains +``` +```yaml +Value: max-age=31536000; includeSubDomains +``` +If given as a string, individual items must be separated with the correct item delimiter for the header or directive +being evaluated. Therefore, for expected or disallowed values that specify multiple items, giving them as a list is +generally preferred. + +#### Permissible Values +For checks that define expected or disallowed values, these values can take a number of different formats to cover +various scenarios that you might want to enforce: +* Enforce or disallow standalone directives or values +```yaml +Value: no-store +``` +* Enforce or disallow entire key-value directives +```yaml +Value: max-age=0 +``` +* Enforce or disallow specific keys for key-value directives, without stipulating the value +```yaml +Value: max-age +``` +You can also specify keyword values *(e.g. unsafe-eval, unsafe-inline)* as valid disallowed values for must-avoid checks +when validating policy headers *(see [validating policy headers](#validating-policy-headers))*. + +The validations will match the expected or disallowed values against the whole item value *(standalone directive/value, +entire key-value directive, or key for key-value directive)*. If a value is typically declared in quotation marks, +such as those for [`Clear-Site-Data`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data), or +keywords for policy headers, you must omit the quotation marks: +```yaml +Clear-Site-Data: + Required: True + Value: + - cache + - storage +``` + +#### Validation Order +By default, order is not preserved when validating. That is, both values shown below are valid for the `Cache-Control` +rule in the sample policy at the beginning of this document: +```json +{"Cache-Control": "no-store; max-age=0"} +``` +```json +{"Cache-Control": "max-age=0; no-store"} +``` + +There may be scenarios in which you want to preserve order, such as when specifying a fallback policy for +[`Referrer-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#specify_a_fallback_policy). +In such scenarios, you can set `preserve-order` to `True`: +```yaml +Value: + - no-referrer + - strict-origin-when-cross-origin +Preserve-Order: True +``` +This option is only supported by the `value` validation for headers. Directive and cookie validations are not supported. + +### Validating Policy Headers +Policy headers are those that generally follow the syntax `; ` where +`` consists of ` ` and `` can consist of multiple items and keywords. +Currently, this covers +[`Content-Security-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy) and +[`Feature-Policy`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy). + +You can define disallowed values in must-avoid checks that will be searched for in the values of all key-value +directives. The below will report back all directives in the CSP that contain `unsafe-eval` or `unsafe-inline` as +non-compliant: +```yaml +Content-Security-Policy: + Required: True + Must-Avoid: + - unsafe-eval + - unsafe-inline +``` + +The quotation marks around keywords such as `'none'`, `'self'` and `'unsafe-inline'` in policy headers must not be +included in expected or disallowed values. The quotation marks are stripped from these values in HTTP responses before +they are compared to the expected and disallowed values. The exception to this is if you're enforcing an exact value for +the policy header (i.e. using the `value`, `value-any-of` or `value-one-of` validation), in which case you must keep the +quotation marks around keywords: +```yaml +Content-Security-Policy: + Required: True + Value: default-src 'none'; script-src 'self'; style-src 'unsafe-inline' +``` + +### Validating Directives +The mechanism for validating directives is the same as that for validating headers, and all the same validations are +available. You can use it to validate any directive that is declared in a key-value format for any header. Each +directive to be audited needs to be specified as an element under the `directives` element: +```yaml +Content-Security-Policy: + Required: True + Directives: + Default-Src: + Required: True + Value-One-Of: + - none + - self + Style-Src: + Required: True + Must-Contain: https://stylesheet-url.com +``` + +Note that if you want to enforce exists or not-exists validations for a directive, without enforcing any validations on +its value, it is generally simpler to do so using contain and avoid validations respectively at the header level: +```yaml +Content-Security-Policy: + Required: True + Must-Contain: + - default-src + Must-Avoid: + - frame-src +``` + +### Validating Cookies +Cookie validations are defined in the `set-cookie` element. The validations for cookies work slightly differently to +those for headers and directives. + +When defining a rule for a cookie, you have two options: +1. [Apply the rule globally to all cookies](#validating-cookies-globally) +2. [Apply the rule only to a specific named cookie](#validating-named-cookies) + +#### Validating Cookies Globally +To validate cookies globally, you must define the rule under the `set-cookie` element as you would for other headers. +```yaml +Set-Cookie: + Required: Optional + Must-Contain: - HttpOnly - Secure - Cache-Control: - Required: True - Enforce: True - Delimiter: ',' - Value: - - no-cache, no-store, must-revalidate -``` - -# File Structure - -The yaml file structure for drHEADwe is as follows: - -* There must always be a root element with name 'Headers:' -* Inside the root element, there must be as many elements as headers you want drHEADer to audit (ie: Content-Security-Policy, Set-Cookie, etc.). -* Within each header, rules can be set for individual directives (directives are only applicable if they are set using a key-value format, such as for those in the Content-Security-Policy). - The rules for the directives must be defined under a root element 'Directives' under the relevant header -* For each of these elements (or security headers to audit), the following flags can be set based on the specific requirements for that header: - * Required: - * 'True' if header is required to be present in the HTTP response - * 'False' if header is required not to be present in the HTTP response - * 'Optional' if header can be present in the HTTP response but is not mandatory - * Enforce: - * 'True' if the policy enforces a value for that header (full match) - * 'False' if the policy does not enforce a value for that header - * Value: - * It must be empty if 'Enforce' is set to False, otherwise - * It must be set to a list of values that would be accepted for that header. The validation will be successful if there is a full match (value in header matches with value in policy) with one of the values defined - * Delimiter: To be used when a header is enforced and the value specified contains multiple values that would be valid in any order (see example for Cache-Control). Default delimiter is ';'. - * Must-Contain: To be used when 'Required' is set to True or Optional and 'Enforce' is set to False. - * It can be set to a list of values that should be part of the header value. The validation will be successful if all values specified are found in the value set for that header (ie: for set-cookie the policy specifies that httponly AND secure should be part of the header value) - * Must-Contain-One: To be used when 'Required' is set to True or Optional and 'Enforce' is set to False. - * It can be set to a list of values where at least one should be part of the header. The validation will be successful if at least one value is found in the value set for that header (ie: for Content-Security-Policy the policy specifies that either "default-src 'none'" OR "default-src 'self'" should be part of the header value) - * Must-Avoid: To be used when 'Required' is set to True or Optional and 'Enforce' is set to False. - * It can be set to a list of values that should not be part of the header. The validation will be successful if none of the values are found in the value set for that header (ie: for Content-Security-Policy the policy specifies that "unsafe-inline" AND "unsafe-eval" should not be part of the header value) +``` +This example will enforce that all cookies returned set the `HttpOnly` and `Secure` flags. Global validations support +only `must-avoid`, `must-contain` and `must-contain-one` rules. + +#### Validating Named Cookies +To validate a named cookie, you must specify the cookie to be validated as an element under the `cookies` element. You +can then define validation rules per cookie. DrHEADer will search for a cookie matching the named one and apply the +validations only to that cookie. +```yaml +Set-Cookie: + Required: True + Cookies: + Session-Id: + Required: True + Must-Contain: Max-Age +``` + +The cookie validation mechanism for a named cookie assumes the following: + +* The cookie name and value are declared as the first attribute in the format `=;` +* The cookie name does contain an equals sign `=` +* The cookie value does contain a semicolon `;` + +The `value`, `value-any-of` and `value-one-of` validations are not supported for named cookies. The `directives` +element is also not supported for named cookies. + +### Validating Custom Headers +You can include custom headers for validation, and run the same validations on them, as you would any standard headers. +If providing multiple expected or disallowed values for value, avoid or contain checks, you need to specify the relevant +delimiters in the `item-delimiter`, `key-delimiter` and `value-delimiter` elements: +```yaml +X-Custom-Header: + Required: True + Must-Contain: + - item_value_1 + - item_value_2 + Item-Delimiter: ; + Key-Delimiter: = + Value-Delimiter: , +``` +For example, the above rule would identify the directives `item_1 = value_1, value_2`, `item_2 = value_1` and `item_3` +from the header given below: +```json +{"X-Custom-Header": "item_1 = value_1, value_2; item_2 = value_1; item_3"} +``` +If the directives are not declared in a key-value format, or the value does not support multiple items, you can omit the +`key-delimiter` and `value-delimiter` elements respectively. + +## Example Use Cases +### Hardening the CSP +```yaml +Content-Security-Policy: + Required: True + Must-Avoid: + - unsafe-inline + - unsafe-eval + - unsafe-hashes + Directives: + Default-Src: + Required: True + Must-Contain: 'https:' + Script-Src: + Required: True + Value: self +``` + +### Securing Cookies +```yaml +Set-Cookie: + Required: Optional + Must-Contain: + - HttpOnly + - SameSite=Strict + - Secure + Must-Contain-One: + - Max-Age + - Expires +``` + +### Preventing Caching +```yaml +Cache-Control: + Required: True + Value: + - no-store + - max-age=0 +Pragma: + Required: True + Value: no-cache +``` + +### Enforcing Cross-Origin Isolation +```yaml +Cross-Origin-Embedder-Policy: + Required: True + Value: require-corp +Cross-Origin-Opener-Policy: + Required: True + Value: same-origin +``` +** Note that cross-origin isolation validations are opt-in *(see +[cross-origin isolation](README.md#cross-origin-isolation))* + +### Enforcing a Fallback Referrer Policy +```yaml +Referrer-Policy: + Required: True + Value: + - no-referrer + - strict-origin-when-cross-origin + Preserve-Order: True +``` diff --git a/drheader/__init__.py b/drheader/__init__.py index afbaa11..04f8602 100644 --- a/drheader/__init__.py +++ b/drheader/__init__.py @@ -2,6 +2,6 @@ """Top-level package for drHEADer core.""" -__version__ = '1.6.0' +__version__ = '1.7.0' from drheader.core import Drheader # noqa diff --git a/drheader/cli.py b/drheader/cli.py index dbedb92..28e64ce 100644 --- a/drheader/cli.py +++ b/drheader/cli.py @@ -55,7 +55,8 @@ def scan(ctx, verify, certs): @click.option('--rules', 'rule_file', help='Use custom rule set', type=click.File()) @click.option('--rules-uri', 'rule_uri', help='Use custom rule set, downloaded from URI') @click.option('--merge', help='Merge custom file rules, on top of default rules', is_flag=True) -def compare(file, json_output, debug, rule_file, rule_uri, merge): +@click.option('--cross-origin-isolated', help='Enable cross-origin isolation validations', is_flag=True) +def compare(file, json_output, debug, rule_file, rule_uri, merge, cross_origin_isolated): """ If you have headers you would like to test with drheader, you can "compare" them with your ruleset this command. @@ -129,9 +130,9 @@ def compare(file, json_output, debug, rule_file, rule_uri, merge): for i in data: logging.debug('Analysing : {}'.format(i['url'])) drheader_instance = Drheader(url=i['url'], headers=i['headers']) - drheader_instance.analyze(rules) - audit.append({'url': i['url'], 'report': drheader_instance.report}) - if drheader_instance.report: + drheader_instance.analyze(rules, bool(cross_origin_isolated)) + audit.append({'url': i['url'], 'report': drheader_instance.reporter.report}) + if drheader_instance.reporter.report: exit_code = EXIT_CODE_FAILURE echo_bulk_report(audit, json_output) @@ -146,8 +147,9 @@ def compare(file, json_output, debug, rule_file, rule_uri, merge): @click.option('--rules-uri', 'rule_uri', help='Use custom rule set, downloaded from URI') @click.option('--merge', help='Merge custom file rules, on top of default rules', is_flag=True) @click.option('--junit', help='Produces a junit report with the result of the scan', is_flag=True) +@click.option('--cross-origin-isolated', help='Enable cross-origin isolation validations', is_flag=True) @click.pass_context -def single(ctx, target_url, json_output, debug, rule_file, rule_uri, merge, junit): +def single(ctx, target_url, json_output, debug, rule_file, rule_uri, merge, junit, cross_origin_isolated): """ Scan a single http(s) endpoint with drheader. @@ -185,32 +187,32 @@ def single(ctx, target_url, json_output, debug, rule_file, rule_uri, merge, juni try: logging.debug('Analyzing headers...') - drheader_instance.analyze(rules) + drheader_instance.analyze(rules, bool(cross_origin_isolated)) except Exception as e: if debug: raise click.ClickException(e) else: raise click.ClickException('Failed to analyze headers.') - if drheader_instance.report: + if drheader_instance.reporter.report: exit_code = EXIT_CODE_FAILURE if json_output: - click.echo(json.dumps(drheader_instance.report)) + click.echo(json.dumps(drheader_instance.reporter.report)) else: click.echo() - if not drheader_instance.report: + if not drheader_instance.reporter.report: click.echo('No issues found!') else: - click.echo('{0} issues found'.format(len(drheader_instance.report))) - for i in drheader_instance.report: + click.echo('{0} issues found'.format(len(drheader_instance.reporter.report))) + for i in drheader_instance.reporter.report: values = [] for k, v in i.items(): values.append([k, v]) click.echo('----') click.echo(tabulate(values, tablefmt="presto")) if junit: - file_junit_report(rules, drheader_instance.report) + file_junit_report(rules, drheader_instance.reporter.report) sys.exit(exit_code) @@ -223,8 +225,9 @@ def single(ctx, target_url, json_output, debug, rule_file, rule_uri, merge, juni @click.option('--rules', 'rule_file', help='Use custom rule set', type=click.File()) @click.option('--rules-uri', 'rule_uri', help='Use custom rule set, downloaded from URI') @click.option('--merge', help='Merge custom file rules, on top of default rules', is_flag=True) +@click.option('--cross-origin-isolated', help='Enable cross-origin isolation validations', is_flag=True) @click.pass_context -def bulk(ctx, file, json_output, input_format, debug, rule_file, rule_uri, merge): +def bulk(ctx, file, json_output, input_format, debug, rule_file, rule_uri, merge, cross_origin_isolated): """ Scan multiple http(s) endpoints with drheader. @@ -307,9 +310,9 @@ def bulk(ctx, file, json_output, input_format, debug, rule_file, rule_uri, merge drheader_instance = Drheader( url=v['url'], params=v.get('params', None), verify=ctx.obj['verify']) logging.debug('Analysing: {}...'.format(v)) - drheader_instance.analyze(rules) - audit.append({'url': v['url'], 'report': drheader_instance.report}) - if drheader_instance.report: + drheader_instance.analyze(rules, bool(cross_origin_isolated)) + audit.append({'url': v['url'], 'report': drheader_instance.reporter.report}) + if drheader_instance.reporter.report: exit_code = EXIT_CODE_FAILURE echo_bulk_report(audit, json_output) diff --git a/drheader/cli_utils.py b/drheader/cli_utils.py index cb4a2ca..822373e 100644 --- a/drheader/cli_utils.py +++ b/drheader/cli_utils.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- - -"""Utils for drheader console script.""" +"""Utility functions for cli module.""" import json import os @@ -10,14 +9,12 @@ def echo_bulk_report(audit, json_output=False): - """ - Output bulk report. + """Prints a report from a bulk scan. - :param audit: audit from core - :param json_output: json output flag - :return: None + Args: + audit (list): The report generated from the scan. + json_output (bool): (optional) A flag to format the output as JSON. Default is false. """ - if json_output: click.echo(json.dumps(audit)) else: @@ -33,14 +30,12 @@ def echo_bulk_report(audit, json_output=False): def file_junit_report(rules, report): - """ - Output file Junit xml report + """Generates a JUnit XML report from a scan result. - :param rules: set of rules to verify - :param report: report generated by drheader - :return: None + Args: + rules (dict): The rules used to perform the scan. + report (list): The report generated from the scan. """ - test_cases = [] for header in rules: diff --git a/drheader/core.py b/drheader/core.py index 4a7710e..b9b79dc 100644 --- a/drheader/core.py +++ b/drheader/core.py @@ -1,365 +1,183 @@ +"""Main module and entry point for analysis.""" import json +import os import requests import validators -from requests.structures import CaseInsensitiveDict +from requests import structures -from drheader.utils import load_rules, _to_dict +from drheader import report, utils +from drheader.validators import cookie_validator, directive_validator, header_validator +_CROSS_ORIGIN_HEADERS = ['cross-origin-embedder-policy', 'cross-origin-opener-policy'] -class Drheader: - """ - Core functionality for DrHeader. This is where the magic happens - """ - error_types = { - 1: 'Header not included in response', - 2: 'Header should not be returned', - 3: 'Value does not match security policy', - 4: 'Must-Contain directive missed', - 5: 'Must-Avoid directive included', - 6: 'Must-Contain-One directive missed', - 7: 'Directive not included in response', - 8: 'Directive should not be returned' - } - - def __init__( - self, - url=None, - method="GET", - headers=None, - status_code=None, - params=None, - request_headers=None, - verify=True - ): - """ - NOTE: at least one param required. - - :param url: (optional) URL of target - :type url: str - :param method: (optional) Method to use when doing the request - :type method: str - :param headers: (optional) Override headers - :type headers: dict - :param status_code: Override status code - :type status_code: int - :param params: Request params - :type params: dict - :param request_headers: Request headers - :type request_headers: dict - :param verify: Verify the server's TLS certificate - :type verify: bool or str - """ - if request_headers is None: - request_headers = {} - if isinstance(headers, str): - headers = json.loads(headers) - elif url and not headers: - headers, status_code = self._get_headers(url, method, params, request_headers, verify) - - self.status_code = status_code - self.headers = CaseInsensitiveDict(headers) - self.anomalies = [] - self.url = url - self.delimiter = ';' - self.report = [] - - @staticmethod - def _get_headers(url, method, params, request_headers, verify): - """ - Get headers for specified url. - - :param url: URL of target - :type url: str - :param method: (optional) Method to use when doing the request - :type method: str - :param params: Request params - :type params: dict - :param request_headers: Request headers - :type request_headers: dict - :param verify: Verify the server's TLS certificate - :type verify: bool or str - :return: headers, status_code - :rtype: dict, int - """ - - if validators.url(url): - req_obj = getattr(requests, method.lower()) - r = req_obj(url, data=params, headers=request_headers, verify=verify) - - headers = r.headers - if len(r.raw.headers.getlist('Set-Cookie')) > 0: - headers['set-cookie'] = r.raw.headers.getlist('Set-Cookie') - return headers, r.status_code - - def analyze(self, rules=None): - """ - Analyze the currently loaded headers against provided rules. - - :param rules: Override rules to compare headers against - :type rules: dict - :return: Audit report - :rtype: list - """ - - for header, value in self.headers.items(): - if type(value) == str: - self.headers[header] = value.lower() - if type(value) == list: - value = [item.lower() for item in value] - self.headers[header] = value - - if not rules: - rules = load_rules() - for rule, config in rules.items(): - self.__validate_rules(config, header=rule) - if 'Directives' in config and rule in self.headers: - for directive, d_config in config['Directives'].items(): - self.__validate_rules(d_config, header=rule, directive=directive) - return self.report - - def __validate_rule_and_value(self, expected_value, header, directive): - """ - Verify headers content matches provided config. - - :param expected_value: Expected value of header. - :param header: Name of header - :param directive: Name of directive (optional) - :return: - """ - expected_value_list = [str(item).lower() for item in expected_value] - if len(expected_value_list) == 1: - expected_value_list = [item.strip(' ') for item in expected_value_list[0].split(self.delimiter)] - - if directive: - rule = directive - headers = _to_dict(self.headers[header], ';', ' ') - else: - rule = header - headers = self.headers - - if rule not in headers: - self.__add_report_item( - severity='high', - error_type=7 if directive else 1, - header=header, - directive=directive, - expected=expected_value_list) - else: - rule_list = [item.strip(' ') for item in headers[rule].split(self.delimiter)] - if not all(elem in expected_value_list for elem in rule_list): - self.__add_report_item( - severity='high', - error_type=3, - header=header, - directive=directive, - expected=expected_value_list, - value=headers[rule]) - - def __validate_not_exists(self, header, directive): - """ - Verify specified rule does not exist in loaded headers. - - :param header: Name of header - :param directive: Name of directive (optional) - """ +with open(os.path.join(os.path.dirname(__file__), 'resources/delimiters.json')) as delimiters: + _DELIMITERS = utils.translate_to_case_insensitive_dict(json.load(delimiters)) - if directive: - rule = directive - headers = _to_dict(self.headers[header], ';', ' ') - else: - rule = header - headers = self.headers - if rule in headers: - self.__add_report_item( - severity='high', - error_type=8 if directive else 2, - header=header, - directive=directive) +class Drheader: + """Main class and entry point for analysis. - def __validate_exists(self, header, directive): - """ - Verify specified rule exists in loaded headers. + Attributes: + headers (CaseInsensitiveDict): The headers to analyse. + cookies (CaseInsensitiveDict): The cookies to analyse. + reporter (Reporter): Reporter instance that generates and holds the final report. + """ - :param header: Name of header - :param directive: Name of directive (optional) - """ - if directive: - rule = directive - headers = _to_dict(self.headers[header], ';', ' ') - else: - rule = header - headers = self.headers + def __init__(self, headers=None, url=None, method='get', params=None, request_headers=None, verify=True): + """Initialises a Drheader instance. - if rule not in headers: - self.__add_report_item( - severity='high', - error_type=7 if directive else 1, - header=header, - directive=directive) + Either headers or url must be defined. If both are defined, the value passed in headers will take priority. If + only url is defined, the headers will be retrieved from the HTTP response from the provided URL. - return rule in headers # Return value to prevent subsequent avoid/contain checks if the header is not present + Args: + headers (dict | str): (optional) The headers to analyse. Must be valid JSON if passed as a string. + url (str): (optional) The URL from which to retrieve the headers. + method (str): (optional) The HTTP verb to use when retrieving the headers. Default is 'get'. + params (dict): (optional) Any request parameters to send when retrieving the headers. + request_headers (dict): (optional) Any request headers to send when retrieving the headers. + verify (bool): (optional) A flag to verify the server's TLS certificate. Default is True. - def __validate_must_avoid(self, config, header, directive): + Raises: + ValueError: If neither headers nor url is provided, or if url is not a valid URL. """ - Verify specified values do not exist in loaded headers. + if not headers: + if not url: + raise ValueError("Nothing provided for analysis. Either 'headers' or 'url' must be defined") + else: + headers = _get_headers_from_url(url, method, params, request_headers, verify) + elif isinstance(headers, str): + headers = json.loads(headers) - :param config: Configuration rule-set to use - :param header: Name of header - :param directive: Name of directive (optional) + self.cookies = structures.CaseInsensitiveDict() + self.headers = structures.CaseInsensitiveDict(headers) + self.reporter = report.Reporter() + + for cookie in self.headers.get('set-cookie', []): + cookie = cookie.split('=', 1) + self.cookies[cookie[0]] = cookie[1] + + def analyze(self, rules=None, cross_origin_isolated=False): + """Analyses headers against a drHEADer ruleset. + + Args: + rules (dict): (optional) The rules against which to assess the headers. Default rules are used if undefined. + cross_origin_isolated (bool): (optional) A flag to enable cross-origin isolation rules. Default is False. + + Returns: + A list containing all the rule violations found during analysis. The report consists of individual dict + items per header and rule. Each item in the report will detail the non-compliant header, the rule violated + and its associated severity, and, if applicable, the observed value of the header, any expected, disallowed + or anomalous values, and the correct delimiter. For example: + { + 'rule': 'Referrer-Policy', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'origin-when-cross-origin' + 'expected': ['same-origin', 'strict-origin-when-cross-origin'] + } """ - if directive: - rule = directive - header_value = _to_dict(self.headers[header], ';', ' ')[rule] + if not rules: + rules = utils.translate_to_case_insensitive_dict(utils.load_rules()) else: - rule = header - header_value = self.headers[rule] - - config['Must-Avoid'] = [item.lower() for item in config['Must-Avoid']] - - for avoid_value in config['Must-Avoid']: - if avoid_value in header_value and rule not in self.anomalies: - if rule.lower() == 'content-security-policy': - policy = _to_dict(self.headers[header], ';', ' ') - non_compliant_values = [item for item in list(policy.values()) if avoid_value in item] - indices = [list(policy.values()).index(item) for item in non_compliant_values] - for index in indices: - self.__add_report_item( - severity='medium', - error_type=5, - header=header, - directive=list(policy.keys())[index], - avoid=config['Must-Avoid'], - value=avoid_value) - else: - self.__add_report_item( - severity='medium', - error_type=5, - header=header, - directive=directive, - avoid=config['Must-Avoid'], - value=avoid_value) - - def __validate_must_contain(self, config, header, directive): - """ - Verify the provided header contains certain params. + rules = utils.translate_to_case_insensitive_dict(rules) - :param config: Configuration rule-set to use - :param header: Name of header - :param directive: Name of directive (optional) - """ - if directive: - rule = directive - header_value = _to_dict(self.headers[header], ';', ' ')[rule] - else: - rule = header - header_value = self.headers[rule] - - if 'Must-Contain-One' in config: - config['Must-Contain-One'] = [item.lower() for item in config['Must-Contain-One']] - contain_values = header_value.split(' ') if directive else header_value.split(self.delimiter) - does_contain = False - - for contain_value in contain_values: - contain_value = contain_value.lstrip() - if contain_value in config['Must-Contain-One']: - does_contain = True - break - if not does_contain: - self.__add_report_item( - severity='high', - error_type=6, - header=header, - directive=directive, - expected=config['Must-Contain-One'], - value=config['Must-Contain-One']) - - elif 'Must-Contain' in config: - config['Must-Contain'] = [item.lower() for item in config['Must-Contain']] - if header.lower() == 'set-cookie': - for cookie in self.headers[header]: - for contain_value in config['Must-Contain']: - if contain_value not in cookie: - self.__add_report_item( - severity='high' if contain_value == 'secure' else 'medium', - error_type=4, - header=header, - expected=config['Must-Contain'], - value=contain_value, - cookie=cookie) - else: - for contain_value in config['Must-Contain']: - if contain_value not in header_value and rule not in self.anomalies: - self.__add_report_item( - severity='medium', - error_type=4, - header=header, - directive=directive, - expected=config['Must-Contain'], - value=contain_value) - - def __validate_rules(self, config, header, directive=None): - """ - Entry point for validation. + h_validator = header_validator.HeaderValidator(self.headers) + d_validator = directive_validator.DirectiveValidator(self.headers) + c_validator = cookie_validator.CookieValidator(self.cookies) - :param config: Configuration rule-set to use - :param header: Name of header - :param directive: Name of directive (optional) - """ - try: - self.delimiter = config['Delimiter'] - except KeyError: - self.delimiter = ';' - - if config['Required'] is True or (config['Required'] == 'Optional' and header in self.headers): - if config['Enforce']: - self.__validate_rule_and_value(config['Value'], header, directive) + for header, config in rules.items(): + if header.lower() in _CROSS_ORIGIN_HEADERS and not cross_origin_isolated: + continue else: - exists = self.__validate_exists(header, directive) - if exists: - if 'Must-Contain-One' in config or 'Must-Contain' in config: - self.__validate_must_contain(config, header, directive) - if 'Must-Avoid' in config: - self.__validate_must_avoid(config, header, directive) - elif config['Required'] is False: - self.__validate_not_exists(header, directive) - - def __add_report_item(self, severity, error_type, header, directive=None, expected=None, avoid=None, value='', - cookie=''): - """ - Add a entry to report. - - :param severity: [low, medium, high] - :type severity: str - :param error_type: [1...6] related to error_types - :type error_type: int - :param expected: Expected value of header - :param avoid: Avoid value of header - :param value: Current value of header - :param cookie: Value of cookie (if applicable) - """ - if directive: - error = {'rule': header + ' - ' + directive, 'severity': severity, 'message': self.error_types[error_type]} + self._analyze_header(config, h_validator, header) + if 'directives' in config and header in self.headers: + self._analyze_directives(config, d_validator, header) + if 'cookies' in config and header.lower() == 'set-cookie': + self._analyze_cookies(config, c_validator) + return self.reporter.report + + def _analyze_header(self, config, validator, header): + if header.lower() != 'set-cookie': + self._validate_rules(config, validator, header) + elif header in self.headers: + for cookie in self.cookies: + self._validate_rules(config, validator, header, cookie=cookie) + + def _analyze_directives(self, config, validator, header): + for directive, config in config['directives'].items(): + self._validate_rules(config, validator, header, directive=directive) + + def _analyze_cookies(self, config, validator): + for cookie, config in config['cookies'].items(): + self._validate_rules(config, validator, header='Set-Cookie', cookie=cookie) + + def _validate_rules(self, config, validator, header, directive=None, cookie=None): + if header in _DELIMITERS: + config['delimiters'] = _DELIMITERS[header] + + is_required = str(config['required']).strip().lower() + + if is_required == 'false': + report_item = validator.validate_not_exists(config, header, directive=directive, cookie=cookie) + self._add_to_report_if_exists(report_item) else: - error = {'rule': header, 'severity': severity, 'message': self.error_types[error_type]} - - if expected: - error['expected'] = expected - error['delimiter'] = self.delimiter - if avoid: - error['avoid'] = avoid - error['delimiter'] = self.delimiter - - if error_type == 3: - error['value'] = value - elif error_type in (4, 5, 6): - if header.lower() == 'set-cookie': - error['value'] = cookie - else: - if directive: - error['value'] = _to_dict(self.headers[header], ';', ' ')[directive].strip('\'') - else: - error['value'] = self.headers[header] - error['anomaly'] = value - self.report.append(error) + exists = self._validate_exists(is_required, config, validator, header, directive, cookie) + if exists: + self._validate_enforced_value(config, validator, header, directive) + self._validate_avoid_and_contain_values(config, validator, header, directive, cookie) + + def _validate_exists(self, is_required, config, validator, header, directive, cookie): + if is_required == 'true': + report_item = validator.validate_exists(config, header, directive=directive, cookie=cookie) + self._add_to_report_if_exists(report_item) + return bool(not report_item) + elif cookie: + return cookie in self.cookies + elif directive: + return directive in utils.parse_policy(self.headers[header], **_DELIMITERS[header], keys_only=True) + elif header: + return header in self.headers + + def _validate_enforced_value(self, config, validator, header, directive): + if 'value' in config: + report_item = validator.validate_value(config, header, directive=directive) + self._add_to_report_if_exists(report_item) + elif 'value-any-of' in config: + report_item = validator.validate_value_any_of(config, header, directive=directive) + self._add_to_report_if_exists(report_item) + elif 'value-one-of' in config: + report_item = validator.validate_value_one_of(config, header, directive=directive) + self._add_to_report_if_exists(report_item) + + def _validate_avoid_and_contain_values(self, config, validator, header, directive, cookie): + if 'must-avoid' in config: + report_item = validator.validate_must_avoid(config, header, directive=directive, cookie=cookie) + self._add_to_report_if_exists(report_item) + if 'must-contain' in config: + report_item = validator.validate_must_contain(config, header, directive=directive, cookie=cookie) + self._add_to_report_if_exists(report_item) + if 'must-contain-one' in config: + report_item = validator.validate_must_contain_one(config, header, directive=directive, cookie=cookie) + self._add_to_report_if_exists(report_item) + + def _add_to_report_if_exists(self, report_item): + if report_item: + try: + self.reporter.add_item(report_item) + except AttributeError: + for item in report_item: + self.reporter.add_item(item) + + +def _get_headers_from_url(url, method, params, headers, verify): + if not validators.url(url): + raise ValueError(f"Cannot retrieve headers from '{url}'. The URL is malformed") + + request_object = getattr(requests, method.lower()) + response = request_object(url, data=params, headers=headers, verify=verify) + response_headers = response.headers + + if len(response.raw.headers.getlist('Set-Cookie')) > 0: + response_headers['set-cookie'] = response.raw.headers.getlist('Set-Cookie') + return response_headers diff --git a/drheader/report.py b/drheader/report.py new file mode 100644 index 0000000..ca63837 --- /dev/null +++ b/drheader/report.py @@ -0,0 +1,70 @@ +"""Primary module for report generation and storage.""" +import enum +from typing import NamedTuple + + +class ErrorType(enum.Enum): + AVOID = 'Must-Avoid directive included' + CONTAIN = 'Must-Contain directive missed' + CONTAIN_ONE = 'Must-Contain-One directive missed. At least one of the expected items was expected' + DISALLOWED = '{} should not be returned' + REQUIRED = '{} not included in response' + VALUE = 'Value does not match security policy' + VALUE_ANY = 'Value does not match security policy. At least one of the expected items was expected' + VALUE_ONE = 'Value does not match security policy. Exactly one of the expected items was expected' + + +class ReportItem(NamedTuple): + severity: str + error_type: ErrorType + header: str + directive: str = None + cookie: str = None + value: str = None + avoid: list = None + expected: list = None + anomalies: list = None + delimiter: str = None + + +class Reporter: + """Class to generate and store reports from a scan. + + Attributes: + report (list): The report detailing validation failures encountered during a scan. + """ + + def __init__(self): + """Initialises a Reporter instance with an empty report.""" + self.report = [] + + def add_item(self, item): + """Adds a validation failure to the report. + + Args: + item (ReportItem): The validation failure to be added. + """ + finding = {} + if item.directive: + finding['rule'] = f'{item.header} - {item.directive}' + finding['message'] = item.error_type.value.format('Directive') + elif item.cookie: + finding['rule'] = f'{item.header} - {item.cookie}' + finding['message'] = item.error_type.value.format('Cookie') + else: + finding['rule'] = item.header + finding['message'] = item.error_type.value.format('Header') + + finding['severity'] = item.severity + + if item.value: + finding['value'] = item.value + if item.expected: + finding['expected'] = item.expected + if len(item.expected) > 1 and item.delimiter: + finding['delimiter'] = item.delimiter + elif item.avoid: + finding['avoid'] = item.avoid + if item.anomalies: + finding['anomalies'] = item.anomalies + self.report.append(finding) diff --git a/drheader/resources/delimiters.json b/drheader/resources/delimiters.json new file mode 100644 index 0000000..d0d19f3 --- /dev/null +++ b/drheader/resources/delimiters.json @@ -0,0 +1,40 @@ +{ + "Cache-Control": { + "item_delimiter": ",", + "key_delimiter": "=" + }, + "Clear-Site-Data": { + "item_delimiter": ",", + "strip": "\" " + }, + "Content-Security-Policy": { + "item_delimiter": ";", + "key_delimiter": " ", + "value_delimiter": " ", + "strip": "' " + }, + "Feature-Policy": { + "item_delimiter": ";", + "key_delimiter": " ", + "value_delimiter": " ", + "strip": "' " + }, + "Referrer-Policy": { + "item_delimiter": "," + }, + "Set-Cookie": { + "item_delimiter": ";", + "key_delimiter": "=" + }, + "Strict-Transport-Security": { + "item_delimiter": ";", + "key_delimiter": "=" + }, + "X-Forwarded-For": { + "item_delimiter": "," + }, + "X-XSS-Protection": { + "item_delimiter": ";", + "key_delimiter": "=" + } +} diff --git a/drheader/rules.yml b/drheader/rules.yml index 3d279f7..37e92ba 100644 --- a/drheader/rules.yml +++ b/drheader/rules.yml @@ -1,89 +1,67 @@ Headers: - Content-Security-Policy: + Cache-Control: Required: True - Enforce: False Value: - Must-Contain-One: - - default-src 'none' - - default-src 'self' + - no-store + - max-age=0 + Content-Security-Policy: + Required: True Must-Avoid: - unsafe-inline - unsafe-eval - X-XSS-Protection: + Directives: + default-src: + Required: True + Value-One-Of: + - none + - self + Cross-Origin-Embedder-Policy: Required: True - Enforce: True - Value: - - 0 - Server: - Required: False - Enforce: False - Value: - Strict-Transport-Security: + Value: require-corp + Cross-Origin-Opener-Policy: Required: True - Enforce: True - Value: - - max-age=31536000; includeSubDomains - X-Frame-Options: + Value: same-origin + Pragma: Required: True - Enforce: True - Value: - - SAMEORIGIN - - DENY - X-Content-Type-Options: + Value: no-cache + Referrer-Policy: Required: True - Enforce: True - Value: - - nosniff + Value-One-Of: + - strict-origin + - strict-origin-when-cross-origin + - no-referrer + Server: + Required: False Set-Cookie: Required: Optional - Enforce: False - Value: Must-Contain: - HttpOnly - Secure - Referrer-Policy: - Required: True - Enforce: False - Delimiter: ',' - Value: - Must-Contain-One: - - strict-origin - - strict-origin-when-cross-origin - - no-referrer - Cache-Control: - Required: True - Enforce: True - Delimiter: ',' - Value: - - no-store, max-age=0 - Pragma: + Strict-Transport-Security: Required: True - Enforce: True Value: - - no-cache - X-powered-by: + - max-age=31536000 + - includeSubDomains + User-Agent: Required: False - Enforce: False - Value: X-AspNet-Version: Required: False - Enforce: False - Value: - X-Generator: - Required: False - Enforce: False - Value: - User-Agent: + X-Client-IP: Required: False - Enforce: False - Value: + X-Content-Type-Options: + Required: True + Value: nosniff X-Forwarded-For: Required: False - Enforce: False - Value: - X-Client-IP: + X-Frame-Options: + Required: True + Value-One-Of: + - DENY + - SAMEORIGIN + X-Generator: Required: False - Enforce: False - Value: - - # TODO - Add ruleset and severity + X-Powered-By: + Required: False + X-XSS-Protection: + Required: True + Value: 0 diff --git a/drheader/utils.py b/drheader/utils.py index 15295c4..f8c74f1 100644 --- a/drheader/utils.py +++ b/drheader/utils.py @@ -1,57 +1,76 @@ # -*- coding: utf-8 -*- - -"""Utils for drheader.""" - +"""Utility functions for core module.""" import io import logging import os +from typing import NamedTuple import requests import yaml +from requests import structures -def _to_dict(string_to_convert, item_delimiter, key_value_delimiter): - result = {} - dict_values = list(filter(None, string_to_convert.strip().split(item_delimiter))) - - for item in dict_values: - key_value = list(filter(None, item.strip().split(key_value_delimiter, 1))) - if len(key_value) == 2: - result[key_value[0].strip()] = key_value[1].strip('\'') +class KeyValueDirective(NamedTuple): + key: str + value: list + raw_value: str = None - return result +def parse_policy(policy, item_delimiter=None, key_delimiter=None, value_delimiter=None, strip=None, keys_only=False): + """Parses a policy string into a list of individual directives. -def _to_lower_dict(some_dict): - """Convert all keys to lowercase""" - result = {} - for key, value in some_dict.items(): - try: - result[key.lower()] = value - except AttributeError: - result[key] = value - return result + Args: + policy (str): The policy to be parsed. + item_delimiter (str): (optional) The character that delimits individual directives. + key_delimiter (str): (optional) The character that delimits keys and values in key-value directives. + value_delimiter (str): (optional) The character that delimits individual values in key-value directives. + strip (str): (optional) A string of characters to strip from directive values. + keys_only (bool): (optional) A flag to return only keys from key-value directives. Default is False. - -def load_rules(rule_file=None, merge=None): + Returns: + A list of directives. """ - Loads drheader ruleset. Will load local defaults unless overridden. - If merge flag is present, result file will be a merge between local defaults and custom file - :param rule_file: file object of rules. - :type rule_file: file - :param merge: flag indicating to merge file_rule with default rules - :type rule_file: boolean - :return: drheader rules - :rtype: dict + if not item_delimiter: + return [policy.strip(strip)] + elif not key_delimiter: + return [item.strip(strip) for item in policy.strip().split(item_delimiter)] + else: + policy_items = [item for item in policy.strip().split(item_delimiter)] + directives = [] + + for item in policy_items: + directives.append(item.strip()) + split_item = item.strip(key_delimiter).split(key_delimiter, 1) + if len(split_item) == 2: + if keys_only: + directives.append(split_item[0].strip()) + else: + key_value_directive = _extract_key_value_directive(split_item, value_delimiter, strip) + directives.append(key_value_directive) + return directives + + +def load_rules(rule_file=None, merge=False): + """Returns a drHEADer ruleset from a file. + + The loaded ruleset can be configured to be merged with the default drHEADer rules. If a rule exists in both the + custom rules and default rules, the custom one will take priority and override the default one. Otherwise, the new + custom rule will be appended to the default rules. If no file is provided, the default rules will be returned. + + Args: + rule_file (file): (optional) The YAML file containing the ruleset. + merge (bool): (optional) A flag to merge the loaded rules with the drHEADer default rules. Default is False. + + Returns: + A dict containing the loaded rules. """ - if rule_file: logging.debug('') rules = yaml.safe_load(rule_file.read()) if merge: with open(os.path.join(os.path.dirname(__file__), 'rules.yml'), 'r') as f: default_rules = yaml.safe_load(f.read()) - rules = merge_rules(default_rules, rules) + rules = _merge_rules(default_rules, rules) else: with open(os.path.join(os.path.dirname(__file__), 'rules.yml'), 'r') as f: rules = yaml.safe_load(f.read()) @@ -59,34 +78,34 @@ def load_rules(rule_file=None, merge=None): return rules['Headers'] -def merge_rules(default_rules, custom_rules): - """ - Mege both rule set. Rules defined in 'custom_rules', also present in 'default_rules', will be overriden. - If a new rule is present in custom_rules, not present in default_rules, it will be added. - :param default_rules: base file object of rules. - :type default_rules: dict - :param custom_rules: override file object of rules. - :type custom_rules: dict - :return: final rule - :rtype: dict - """ +def get_rules_from_uri(uri): + """Retrieves a rules file from a URL.""" + download = requests.get(uri) + if not download.content: + raise Exception('No content retrieved from {}'.format(uri)) + file = io.BytesIO(download.content) + return file + +def translate_to_case_insensitive_dict(dict_to_translate): + """Recursively transforms a dict into a case-insensitive dict.""" + for key, value in dict_to_translate.items(): + if isinstance(value, dict): + dict_to_translate[key] = translate_to_case_insensitive_dict(value) + return structures.CaseInsensitiveDict(dict_to_translate) + + +def _extract_key_value_directive(directive, value_delimiter, strip): + if value_delimiter: + value_items = list(filter(lambda s: s.strip(), directive[1].split(value_delimiter))) + value = [item.strip(strip) for item in value_items] + else: + value = [directive[1].strip(strip)] + return KeyValueDirective(directive[0].strip(), value, directive[1]) + + +def _merge_rules(default_rules, custom_rules): for rule in custom_rules['Headers']: default_rules['Headers'][rule] = custom_rules['Headers'][rule] return default_rules - - -def get_rules_from_uri(URI): - """ - Retrieves custom rule set from URL - :param URI: URL to your custom rules file - :type URI: uri - :return: rules file - :rtype: file - """ - download = requests.get(URI) - if not download.content: - raise Exception('No content retrieved from {}'.format(URI)) - file = io.BytesIO(download.content) - return file diff --git a/drheader/validators/__init__.py b/drheader/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/drheader/validators/base.py b/drheader/validators/base.py new file mode 100644 index 0000000..471b326 --- /dev/null +++ b/drheader/validators/base.py @@ -0,0 +1,115 @@ +"""Base module for validators.""" +import abc + + +class ValidatorBase: + """Base class for validators.""" + + @abc.abstractmethod + def validate_exists(self, config, header, directive=None, cookie=None): + """Validates that a header, directive or cookie exists in a set of headers. + + Args: + config (CaseInsensitiveDict): The configuration of the exists rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + cookie (str): (optional) The cookie to validate. + """ + + @abc.abstractmethod + def validate_not_exists(self, config, header, directive=None, cookie=None): + """Validates that a header, directive or cookie does not exist in a set of headers. + + Args: + config (CaseInsensitiveDict): The configuration of the not-exists rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + cookie (str): (optional) The cookie to validate. + """ + + @abc.abstractmethod + def validate_value(self, config, header, directive=None): + """Validates that a header or directive matches a single expected value. + + Args: + config (CaseInsensitiveDict): The configuration of the value rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + """ + + @abc.abstractmethod + def validate_value_any_of(self, config, header, directive=None): + """Validates that a header or directive matches one or more of a list of expected values. + + Args: + config (CaseInsensitiveDict): The configuration of the value-any-of rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + """ + + @abc.abstractmethod + def validate_value_one_of(self, config, header, directive=None): + """Validates that a header or directive matches one of a list of expected values. + + Args: + config (CaseInsensitiveDict): The configuration of the value-one-of rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + """ + + @abc.abstractmethod + def validate_must_avoid(self, config, header, directive=None, cookie=None): + """Validates that a header, directive or cookie does not contain any of a list of disallowed values. + + Args: + config (CaseInsensitiveDict): The configuration of the must-avoid rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + cookie (str): (optional) The cookie to validate. + """ + + @abc.abstractmethod + def validate_must_contain(self, config, header, directive=None, cookie=None): + """Validates that a header, directive or cookie contains all of a list of expected values. + + Args: + config (CaseInsensitiveDict): The configuration of the must-contain rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + cookie (str): (optional) The cookie to validate. + """ + + @abc.abstractmethod + def validate_must_contain_one(self, config, header, directive=None, cookie=None): + """Validates that a header, directive or cookie contains one or more of a list of expected values. + + Args: + config (CaseInsensitiveDict): The configuration of the must-contain-one rule. + header (str): The header to validate. + directive (str): (optional) The directive to validate. + cookie (str): (optional) The cookie to validate. + """ + + +class UnsupportedValidationError(Exception): + """Exception to be raised when an unsupported validation is called. + + Attributes: + message (string): A message describing the error. + """ + + def __init__(self, message): + """Initialises an UnsupportedValidationError instance with a message.""" + self.message = message + + +def get_delimiter(config, delimiter_type): + if 'delimiters' in config: + return config['delimiters'].get(delimiter_type) + + +def get_expected_values(config, key, delimiter): + if isinstance(config[key], list): + return [str(item).strip() for item in config[key]] + else: + return [item.strip() for item in str(config[key]).split(delimiter)] diff --git a/drheader/validators/cookie_validator.py b/drheader/validators/cookie_validator.py new file mode 100644 index 0000000..1aa6e64 --- /dev/null +++ b/drheader/validators/cookie_validator.py @@ -0,0 +1,109 @@ +"""Validator module for cookies.""" +from drheader import report, utils +from drheader.validators import base + +_DELIMITER_TYPE = 'item_delimiter' + + +class CookieValidator(base.ValidatorBase): + """Validator class for validating cookies. + + Attributes: + cookies (CaseInsensitiveDict): The cookies to analyse. + """ + + def __init__(self, cookies): + """Initialises a CookieValidator instance with cookies.""" + self.cookies = cookies + + def validate_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + if cookie not in self.cookies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.REQUIRED + return report.ReportItem(severity, error_type, header, cookie=cookie) + + def validate_not_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + if cookie in self.cookies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.DISALLOWED + return report.ReportItem(severity, error_type, header, cookie=cookie) + + def validate_value(self, config, header, directive=None): + """Method not supported. + + Raises: + UnsupportedValidationError: If the method is called. + """ + raise base.UnsupportedValidationError("'Value' validations are not supported for cookies") + + def validate_value_any_of(self, config, header, directive=None): + """Method not supported. + + Raises: + UnsupportedValidationError: If the method is called. + """ + raise base.UnsupportedValidationError("'Value-Any-Of' validations are not supported for cookies") + + def validate_value_one_of(self, config, header, directive=None): + """Method not supported. + + Raises: + UnsupportedValidationError: If the method is called. + """ + raise base.UnsupportedValidationError("'Value-One-Of' validations are not supported for cookies") + + def validate_must_avoid(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + disallowed = base.get_expected_values(config, 'must-avoid', delimiter) + + cookie_value = self.cookies[cookie] + cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) + cookie_items = {str(item).lower() for item in cookie_items} + + anomalies = [] + for avoid in disallowed: + if avoid.lower() in cookie_items: + anomalies.append(avoid) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.AVOID + return report.ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, avoid=disallowed, + anomalies=anomalies) + + def validate_must_contain(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain', delimiter) + + cookie_value = self.cookies[cookie] + cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) + cookie_items = {str(item).lower() for item in cookie_items} + + anomalies = [] + for contain in expected: + if contain.lower() not in cookie_items: + anomalies.append(contain) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN + return report.ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, expected=expected, + anomalies=anomalies, delimiter=delimiter) + + def validate_must_contain_one(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain-one', delimiter) + + cookie_value = self.cookies[cookie] + cookie_items = utils.parse_policy(cookie_value, **config['delimiters'], keys_only=True) + cookie_items = {str(item).lower() for item in cookie_items} + + if not any(contain.lower() in cookie_items for contain in expected): + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN_ONE + return report.ReportItem(severity, error_type, header, cookie=cookie, value=cookie_value, expected=expected) diff --git a/drheader/validators/directive_validator.py b/drheader/validators/directive_validator.py new file mode 100644 index 0000000..e339993 --- /dev/null +++ b/drheader/validators/directive_validator.py @@ -0,0 +1,164 @@ +"""Validator module for directives.""" +from drheader import report, utils +from drheader.validators import base + +_DELIMITER_TYPE = 'value_delimiter' + + +class DirectiveValidator(base.ValidatorBase): + """Validator class for validating directives. + + Attributes: + headers (CaseInsensitiveDict): The headers to analyse. + """ + + def __init__(self, headers): + """Initialises a DirectiveValidator instance with headers.""" + self.headers = headers + + def validate_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + directives = utils.parse_policy(self.headers[header], **config['delimiters'], keys_only=True) + directives = {str(item).lower() for item in directives} + + if directive.lower() not in directives: + severity = config.get('severity', 'high') + error_type = report.ErrorType.REQUIRED + if 'value' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value', delimiter) + return report.ReportItem(severity, error_type, header, directive=directive, expected=expected, + delimiter=delimiter) + elif 'value-any-of' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value-any-of', delimiter) + return report.ReportItem(severity, error_type, header, directive=directive, expected=expected, + delimiter=delimiter) + elif 'value-one-of' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value-one-of', delimiter) + return report.ReportItem(severity, error_type, header, directive=directive, expected=expected) + else: + return report.ReportItem(severity, error_type, header, directive=directive) + + def validate_not_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + directives = utils.parse_policy(self.headers[header], **config['delimiters'], keys_only=True) + directives = {str(item).lower() for item in directives} + + if directive.lower() in directives: + severity = config.get('severity', 'high') + error_type = report.ErrorType.DISALLOWED + return report.ReportItem(severity, error_type, header, directive=directive) + + def validate_value(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_items = {str(item).lower() for item in kvd.value} + + if directive_items != {item.lower() for item in expected}: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + expected=expected, delimiter=delimiter) + + def validate_value_any_of(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + accepted = base.get_expected_values(config, 'value-any-of', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_items = {str(item).lower() for item in kvd.value} + + anomalies = [] + accepted_lower = [item.lower() for item in accepted] + for item in directive_items: + if item not in accepted_lower: + anomalies.append(item) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE_ANY + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + expected=accepted, anomalies=anomalies, delimiter=delimiter) + + def validate_value_one_of(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + accepted = base.get_expected_values(config, 'value-one-of', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_value = kvd.value[0] + + if directive_value not in {item.lower() for item in accepted}: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE_ONE + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + expected=accepted) + + def validate_must_avoid(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + disallowed = base.get_expected_values(config, 'must-avoid', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_items = {str(item).lower() for item in kvd.value} + + anomalies = [] + for avoid in disallowed: + if avoid.lower() in directive_items: + anomalies.append(avoid) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.AVOID + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + avoid=disallowed, anomalies=anomalies) + + def validate_must_contain(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_items = {str(item).lower() for item in kvd.value} + + anomalies = [] + for contain in expected: + if contain.lower() not in directive_items: + anomalies.append(contain) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + expected=expected, anomalies=anomalies, delimiter=delimiter) + + def validate_must_contain_one(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain-one', delimiter) + + directives = utils.parse_policy(self.headers[header], **config['delimiters']) + kvd = _get_key_value_directive(directive, directives) + directive_items = {str(item).lower() for item in kvd.value} + + if not any(contain.lower() in directive_items for contain in expected): + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN_ONE + return report.ReportItem(severity, error_type, header, directive=directive, value=kvd.raw_value, + expected=expected) + + +def _get_key_value_directive(directive_name, directives_list): + for directive in directives_list: + if isinstance(directive, utils.KeyValueDirective) and directive.key.lower() == directive_name.lower(): + return directive diff --git a/drheader/validators/header_validator.py b/drheader/validators/header_validator.py new file mode 100644 index 0000000..b9f05a0 --- /dev/null +++ b/drheader/validators/header_validator.py @@ -0,0 +1,218 @@ +"""Validator module for headers.""" +from drheader import report, utils +from drheader.validators import base + +_DELIMITER_TYPE = 'item_delimiter' +_POLICY_HEADERS = ['content-security-policy', 'feature-policy', 'permissions-policy'] +_STRIP_HEADERS = ['clear-site-data'] + + +class HeaderValidator(base.ValidatorBase): + """Validator class for validating headers. + + Attributes: + headers (CaseInsensitiveDict): The headers to analyse. + """ + + def __init__(self, headers): + """Initialises a HeaderValidator instance with headers.""" + self.headers = headers + + def validate_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + if header not in self.headers: + severity = config.get('severity', 'high') + error_type = report.ErrorType.REQUIRED + if 'value' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value', delimiter) + return report.ReportItem(severity, error_type, header, expected=expected, delimiter=delimiter) + elif 'value-any-of' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value-any-of', delimiter) + return report.ReportItem(severity, error_type, header, expected=expected, delimiter=delimiter) + elif 'value-one-of' in config: + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value-one-of', delimiter) + return report.ReportItem(severity, error_type, header, expected=expected) + else: + return report.ReportItem(severity, error_type, header) + + def validate_not_exists(self, config, header, directive=None, cookie=None): + """See base class.""" + if header in self.headers: + severity = config.get('severity', 'high') + error_type = report.ErrorType.DISALLOWED + return report.ReportItem(severity, error_type, header) + + def validate_value(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'value', delimiter) + + header_value = self.headers[header] + strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None + header_items = utils.parse_policy(header_value, item_delimiter=delimiter, strip=strip_chars) + + if config.get('preserve-order'): + header_items = [item.lower() for item in header_items] + expected_lower = [item.lower() for item in expected] + else: + header_items = {item.lower() for item in header_items} + expected_lower = {item.lower() for item in expected} + + if header_items != expected_lower: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE + return report.ReportItem(severity, error_type, header, value=header_value, expected=expected, + delimiter=delimiter) + + def validate_value_any_of(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + accepted = base.get_expected_values(config, 'value-any-of', delimiter) + + header_value = self.headers[header] + strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None + header_items = utils.parse_policy(header_value, item_delimiter=delimiter, strip=strip_chars) + + anomalies = [] + accepted_lower = [item.lower() for item in accepted] + for item in header_items: + if item not in accepted_lower: + anomalies.append(item) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE_ANY + return report.ReportItem(severity, error_type, header, value=header_value, expected=accepted, + anomalies=anomalies, delimiter=delimiter) + + def validate_value_one_of(self, config, header, directive=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + accepted = base.get_expected_values(config, 'value-one-of', delimiter) + + header_value = self.headers[header] + strip_chars = base.get_delimiter(config, 'strip') if header.lower() in _STRIP_HEADERS else None + + if header_value.strip(strip_chars).lower() not in {item.lower() for item in accepted}: + severity = config.get('severity', 'high') + error_type = report.ErrorType.VALUE_ONE + return report.ReportItem(severity, error_type, header, value=header_value, expected=accepted) + + def validate_must_avoid(self, config, header, directive=None, cookie=None): + """See base class.""" + if header.lower() in _POLICY_HEADERS: + return self._validate_must_avoid_for_policy_header(config, header) + + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + disallowed = base.get_expected_values(config, 'must-avoid', delimiter) + + header_value = self._get_cookie(cookie) if cookie else self.headers[header] + header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) + header_items = {str(item).lower() for item in header_items} + + anomalies = [] + for avoid in disallowed: + if avoid.lower() in header_items: + anomalies.append(avoid) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.AVOID + return report.ReportItem(severity, error_type, header, cookie=cookie, value=header_value, avoid=disallowed, + anomalies=anomalies) + + def validate_must_contain(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain', delimiter) + + header_value = self._get_cookie(cookie) if cookie else self.headers[header] + header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) + header_items = {str(item).lower() for item in header_items} + + anomalies = [] + for contain in expected: + if contain.lower() not in header_items: + anomalies.append(contain) + + if anomalies: + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN + return report.ReportItem(severity, error_type, header, cookie=cookie, value=header_value, expected=expected, + anomalies=anomalies, delimiter=delimiter) + + def validate_must_contain_one(self, config, header, directive=None, cookie=None): + """See base class.""" + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + expected = base.get_expected_values(config, 'must-contain-one', delimiter) + + header_value = self._get_cookie(cookie) if cookie else self.headers[header] + header_items = utils.parse_policy(header_value, **config.get('delimiters', {}), keys_only=True) + header_items = {str(item).lower() for item in header_items} + + if not any(contain.lower() in header_items for contain in expected): + severity = config.get('severity', 'high') + error_type = report.ErrorType.CONTAIN_ONE + return report.ReportItem(severity, error_type, header, cookie=cookie, value=header_value, expected=expected) + + def _validate_must_avoid_for_policy_header(self, config, header): + delimiter = base.get_delimiter(config, _DELIMITER_TYPE) + disallowed = base.get_expected_values(config, 'must-avoid', delimiter) + + header_value = self.headers[header] + header_items = [] + + directives = utils.parse_policy(header_value, **config['delimiters']) + for directive in directives: + try: + header_items.append(directive.key) + header_items += [value for value in directive.value] + except AttributeError: + header_items.append(directive) + header_items = {str(item).lower() for item in header_items} + + anomalies, ncd_items, report_items = [], {}, [] + for avoid in disallowed: + if avoid.lower() in header_items: + non_compliant_directives = [] + for directive in directives: + try: + if avoid in directive.value: + non_compliant_directives.append(directive) + except AttributeError: + pass + + if not non_compliant_directives: + anomalies.append(avoid) + else: + for ncd in non_compliant_directives: + directive, value = ncd.key, ncd.raw_value + ncd_item = { + 'value': value, + 'anomalies': ncd_items.get(directive, {}).get('anomalies', []) + [avoid] + } + ncd_items[directive] = ncd_item + + severity = config.get('severity', 'high') + error_type = report.ErrorType.AVOID + + if anomalies: + item = report.ReportItem(severity, error_type, header, value=header_value, avoid=disallowed, + anomalies=anomalies) + report_items.append(item) + if ncd_items: + for directive in ncd_items: + value, anomalies = ncd_items[directive]['value'], ncd_items[directive]['anomalies'] + item = report.ReportItem(severity, error_type, header, directive=directive, value=value, + avoid=disallowed, anomalies=anomalies) + report_items.append(item) + return report_items + + def _get_cookie(self, expected): + for cookie in self.headers['set-cookie']: + cookie_name = cookie.split('=', 1)[0] + if cookie_name == expected: + return cookie diff --git a/requirements.txt b/requirements.txt index c6f881c..1c59314 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests>=2.22.0 -jsonschema==4.2.1 +jsonschema==4.4.0 jsonschema[format] Click>=7.0 validators>=0.14.0 diff --git a/requirements_dev.txt b/requirements_dev.txt index eb18e6e..5c8e481 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,20 +1,18 @@ bump2version==1.0.1 -wheel==0.37.0 -watchdog==2.1.6 -tox==3.24.4 -coverage==6.1.2 +wheel==0.37.1 +watchdog==2.1.7 +tox==3.24.5 +coverage==6.3.2 Sphinx==4.3.0 -m2r2==0.3.1 -twine==3.6.0 -pytest==6.2.5 -pytest-runner==5.3.1 +m2r2==0.3.2 +twine==3.8.0 +pytest==7.1.1 setuptools>=39.0.1 -bandit==1.7.1 +bandit==1.7.4 flake8==4.0.1 safety==1.10.3 -nose>=1.3.6 validators>=0.14.0 unittest2==1.1.0 xmlunittest==0.5.0 junit-xml==1.9 -responses==0.16.0 +responses==0.20.0 diff --git a/setup.cfg b/setup.cfg index c22cd3a..0a9516d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.6.0 +current_version = 1.7.0 commit = True tag = False diff --git a/setup.py b/setup.py index 86c10b4..464f1f5 100644 --- a/setup.py +++ b/setup.py @@ -3,11 +3,11 @@ """The setup script.""" -from setuptools import setup - import os import re +from setuptools import setup + base_dir = os.path.dirname(__file__) long_description = '' @@ -24,10 +24,6 @@ with open('requirements.txt') as f: requirements = f.read() -setup_requirements = ['pytest-runner', ] - -test_requirements = ['pytest'] - setup( classifiers=[ 'Development Status :: 5 - Production/Stable', @@ -51,11 +47,9 @@ keywords='drheader', author='Santander UK Security Engineering', name='drheader', - packages=['drheader'], - setup_requires=setup_requirements, + packages=['drheader', 'drheader/validators'], test_suite='tests', - tests_require=test_requirements, url='https://github.com/santandersecurityresearch/drheader', - version='1.6.0', + version='1.7.0', zip_safe=False, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bulk_files/bulk_headers.json b/tests/bulk_files/bulk_headers.json deleted file mode 100644 index 1edb013..0000000 --- a/tests/bulk_files/bulk_headers.json +++ /dev/null @@ -1,69 +0,0 @@ -[ - { - "url": "https://test.com", - "headers": { - "X-XSS-Protection": "1; mode=block", - "Content-Security-Policy": "default-src 'none'; script-src 'self' unsafe-inline; object-src 'self';", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - "X-Frame-Options": "SAMEORIGIN", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin", - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Set-Cookie": [ - "HttpOnly; Secure" - ] - }, - "status_code": 200 - }, - { - "url": "https://test1.com", - "headers": { - "X-XSS-Protection": "1; mode=block", - "Content-Security-Policy": "default-src 'none'; script-src 'self' unsafe-inline; object-src 'self';", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - "X-Frame-Options": "SAMEORIGIN", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin", - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Set-Cookie": [ - "ss_cookie=opW4vJZ5ynerND7oj_J0_Ja; Path=/; Secure; HttpOnly" - ] - }, - "status_code": 200 - }, - { - "url": "test2", - "headers": { - "X-XSS-Protection": "1; mode=block", - "Content-Security-Policy": "default-src 'none'; script-src 'self'; object-src 'self';", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - "X-Frame-Options": "DENY", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin", - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Set-Cookie": [ - "ss_cookie=opW4vJZ5ynerND7oj_J0_Ja; Path=/; Secure; HttpOnly" - ] - } - }, - { - "url": "https://test3.com", - "headers": { - "X-XSS-Protection": "1; mode=block", - "Content-Security-Policy": "default-src 'none'; script-src 'self'; object-src 'self';", - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - "X-Frame-Options": "SAMEORIGIN", - "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin", - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Set-Cookie": [ - "ss_cookie=opW4vJZ5ynerND7oj_J0_Ja; Path=/; Secure; HttpOnly" - ] - }, - "status_code": 200 - } -] diff --git a/tests/integration_tests/__init__.py b/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration_tests/test_drheader.py b/tests/integration_tests/test_drheader.py new file mode 100644 index 0000000..5d71537 --- /dev/null +++ b/tests/integration_tests/test_drheader.py @@ -0,0 +1,334 @@ +import os + +import unittest2 +import yaml + +from tests.integration_tests import utils + + +class TestDrHeader(unittest2.TestCase): + + def tearDown(self): + utils.reset_default_rules() + + def test__should_get_headers_from_url(self): + report = utils.process_test(url='https://google.com') + self.assertIsNotNone(report) + + def test_header__should_handle_case_insensitive_header_names(self): + modify_rule('Content-Security-Policy', {'Required': True}) + headers = utils.add_or_modify_header('Content-Security-Policy', "default-src 'none'") + headers['CONTENT-SECURITY-POLICY'] = headers.pop('Content-Security-Policy') + + report = utils.process_test(headers=headers) + self.assertEqual(0, len(report), msg=utils.build_error_message(report)) + + def test_header__should_handle_case_insensitive_header_values(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Must-Contain': ['default-src']}) + headers = utils.add_or_modify_header('Content-Security-Policy', "default-src 'none'") + headers['Content-Security-Policy'] = headers.pop('Content-Security-Policy').upper() + + report = utils.process_test(headers=headers) + self.assertEqual(0, len(report), msg=utils.build_error_message(report)) + + def test_header__should_not_run_cross_origin_validations_when_cross_origin_isolated_is_false(self): + headers = utils.delete_headers('Cross-Origin-Embedder-Policy', 'Cross-Origin-Opener-Policy') + + report = utils.process_test(headers=headers, cross_origin_isolated=False) + self.assertEqual(0, len(report), msg=utils.build_error_message(report)) + + def test_header__should_not_validate_an_optional_header_that_is_not_present(self): + modify_rule('Cache-Control', {'Required': 'Optional', 'Value': ['no-store']}) + headers = utils.delete_headers('Cache-Control') + + report = utils.process_test(headers=headers) + self.assertEqual(0, len(report), msg=utils.build_error_message(report)) + + def test_header__should_validate_an_optional_header_that_is_present(self): + modify_rule('Cache-Control', {'Required': 'Optional', 'Value': 'no-store'}) + headers = utils.add_or_modify_header('Cache-Control', 'no-cache') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'no-cache', + 'expected': ['no-store'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__exists_validation_ko(self): + modify_rule('Cache-Control', {'Required': True}) + headers = utils.delete_headers('Cache-Control') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Header not included in response', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__not_exists_validation_ko(self): + modify_rule('Cache-Control', {'Required': False}) + headers = utils.add_or_modify_header('Cache-Control', 'private, public') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__value_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Value': ['no-store']}) + headers = utils.add_or_modify_header('Cache-Control', 'no-cache') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'no-cache', + 'expected': ['no-store'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__value_any_of_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Value-Any-Of': ['private', 'no-cache', 'no-transform']}) + headers = utils.add_or_modify_header('Cache-Control', 'private, public, no-transform') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Value does not match security policy. At least one of the expected items was expected', + 'severity': 'high', + 'value': 'private, public, no-transform', + 'expected': ['private', 'no-cache', 'no-transform'], + 'delimiter': ',', + 'anomalies': ['public'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__value_one_of_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Value-One-Of': ['no-cache', 'no-store']}) + headers = utils.add_or_modify_header('Cache-Control', 'private, must-revalidate') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'private, must-revalidate', + 'expected': ['no-cache', 'no-store'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__must_avoid_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Must-Avoid': ['private', 'public']}) + headers = utils.add_or_modify_header('Cache-Control', 'private, must-understand') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Must-Avoid directive included', + 'severity': 'high', + 'value': 'private, must-understand', + 'avoid': ['private', 'public'], + 'anomalies': ['private'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__must_contain_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Must-Contain': ['must-revalidate']}) + headers = utils.add_or_modify_header('Cache-Control', 'private') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Must-Contain directive missed', + 'severity': 'high', + 'value': 'private', + 'expected': ['must-revalidate'], + 'anomalies': ['must-revalidate'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_header__must_contain_one_validation_ko(self): + modify_rule('Cache-Control', {'Required': True, 'Must-Contain-One': ['must-revalidate', 'no-cache']}) + headers = utils.add_or_modify_header('Cache-Control', 'private') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', + 'severity': 'high', + 'value': 'private', + 'expected': ['must-revalidate', 'no-cache'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_directive__value_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value': ['self']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'https://example.com', + 'expected': ['self'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_directive__value_any_of_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value-Any-Of': ['https://example1.com', 'https://example2.com']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Value does not match security policy. At least one of the expected items was expected', + 'severity': 'high', + 'value': 'https://example.com', + 'expected': ['https://example1.com', 'https://example2.com'], + 'delimiter': ' ', + 'anomalies': ['https://example.com'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_directive__value_one_of_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Value-One-Of': ['none', 'self']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', 'style-src https://example.com') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'https://example.com', + 'expected': ['none', 'self'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_directive__must_avoid_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Avoid': ['unsafe-inline']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'unsafe-inline'") + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Must-Avoid directive included', + 'severity': 'high', + 'value': "'unsafe-inline'", + 'avoid': ['unsafe-inline'], + 'anomalies': ['unsafe-inline'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_directive__must_contain_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Contain': ['https://example.com']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'self'") + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Must-Contain directive missed', + 'severity': 'high', + 'value': "'self'", + 'expected': ['https://example.com'], + 'anomalies': ['https://example.com'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_directive__must_contain_one_validation_ko(self): + modify_rule('Content-Security-Policy', {'Required': True, 'Directives': {'style-src': {'Required': True, 'Must-Contain-One': ['https://example1.com', 'https://example2.com']}}}) + headers = utils.add_or_modify_header('Content-Security-Policy', "style-src 'self'") + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - style-src', + 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', + 'severity': 'high', + 'value': "'self'", + 'expected': ['https://example1.com', 'https://example2.com'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_cookie__exists_validation_ko(self): + modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True}}}) + headers = utils.add_or_modify_header('Set-Cookie', ['tracker=657488329; HttpOnly; Secure']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session', + 'message': 'Cookie not included in response', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_cookie__not_exists_validation_ko(self): + modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': False}}}) + headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session', + 'message': 'Cookie should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_cookie__must_avoid_validation_ko(self): + modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Avoid': ['Path']}}}) + headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure; Path=/docs']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session', + 'message': 'Must-Avoid directive included', + 'severity': 'high', + 'value': '657488329; HttpOnly; Secure; Path=/docs', + 'avoid': ['Path'], + 'anomalies': ['Path'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_cookie__must_contain_validation_ko(self): + modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Contain': ['HttpOnly', 'SameSite=Strict', 'Secure']}}}) + headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; SameSite=Lax; Secure']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session', + 'message': 'Must-Contain directive missed', + 'severity': 'high', + 'value': '657488329; HttpOnly; SameSite=Lax; Secure', + 'expected': ['HttpOnly', 'SameSite=Strict', 'Secure'], + 'delimiter': ';', + 'anomalies': ['SameSite=Strict'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_cookie__must_contain_one_validation_ko(self): + modify_rule('Set-Cookie', {'Required': True, 'Cookies': {'session': {'Required': True, 'Must-Contain-One': ['Expires', 'Max-Age']}}}) + headers = utils.add_or_modify_header('Set-Cookie', ['session=657488329; HttpOnly; Secure']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session', + 'message': 'Must-Contain-One directive missed. At least one of the expected items was expected', + 'severity': 'high', + 'value': '657488329; HttpOnly; Secure', + 'expected': ['Expires', 'Max-Age'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + +def modify_rule(rule_name, rule_value): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml'), 'w') as rules: + modified_rule = {'Headers': {rule_name: rule_value}} + yaml.dump(modified_rule, rules, sort_keys=False) diff --git a/tests/integration_tests/test_rules.py b/tests/integration_tests/test_rules.py new file mode 100644 index 0000000..08f40ed --- /dev/null +++ b/tests/integration_tests/test_rules.py @@ -0,0 +1,335 @@ +import unittest2 + +from tests.integration_tests import utils + + +class TestDefaultRules(unittest2.TestCase): + + def tearDown(self): + utils.reset_default_rules() + + def test__should_validate_all_rules_for_valid_headers(self): + headers = utils.get_headers() + + report = utils.process_test(headers=headers) + self.assertEqual(len(report), 0, msg=utils.build_error_message(report)) + + def test_cache_control__should_exist(self): + headers = utils.delete_headers('Cache-Control') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['no-store', 'max-age=0'], + 'delimiter': ',' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_cache_control__should_disable_caching(self): + headers = utils.add_or_modify_header('Cache-Control', 'no-cache') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Cache-Control', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'no-cache', + 'expected': ['no-store', 'max-age=0'], + 'delimiter': ',' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cache-Control')) + + def test_csp__should_exist(self): + headers = utils.delete_headers('Content-Security-Policy') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy', + 'message': 'Header not included in response', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_csp__should_enforce_default_src(self): + headers = utils.add_or_modify_header('Content-Security-Policy', 'default-src https://example.com') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Content-Security-Policy - default-src', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'https://example.com', + 'expected': ['none', 'self'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Content-Security-Policy')) + + def test_coep__should_exist_when_cross_origin_isolated_is_true(self): + headers = utils.delete_headers('Cross-Origin-Embedder-Policy') + + report = utils.process_test(headers=headers, cross_origin_isolated=True) + expected = { + 'rule': 'Cross-Origin-Embedder-Policy', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['require-corp'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Embedder-Policy')) + + def test_coep__should_enforce_require_corp_when_cross_origin_isolated_is_true(self): + headers = utils.add_or_modify_header('Cross-Origin-Embedder-Policy', 'unsafe-none') + + report = utils.process_test(headers=headers, cross_origin_isolated=True) + expected = { + 'rule': 'Cross-Origin-Embedder-Policy', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'unsafe-none', + 'expected': ['require-corp'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Embedder-Policy')) + + def test_coop__should_exist_when_cross_origin_isolated_is_true(self): + headers = utils.delete_headers('Cross-Origin-Opener-Policy') + + report = utils.process_test(headers=headers, cross_origin_isolated=True) + expected = { + 'rule': 'Cross-Origin-Opener-Policy', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['same-origin'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Opener-Policy')) + + def test_coop__should_enforce_same_origin_when_cross_origin_isolated_is_true(self): + headers = utils.add_or_modify_header('Cross-Origin-Opener-Policy', 'same-origin-allow-popups') + + report = utils.process_test(headers=headers, cross_origin_isolated=True) + expected = { + 'rule': 'Cross-Origin-Opener-Policy', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': 'same-origin-allow-popups', + 'expected': ['same-origin'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Cross-Origin-Opener-Policy')) + + def test_pragma__should_exist(self): + headers = utils.delete_headers('Pragma') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Pragma', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['no-cache'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Pragma')) + + def test_referrer_policy__should_exist(self): + headers = utils.delete_headers('Referrer-Policy') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Referrer-Policy', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Referrer-Policy')) + + def test_referrer_policy__should_enforce_strict_policy(self): + headers = utils.add_or_modify_header('Referrer-Policy', 'same-origin') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Referrer-Policy', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'same-origin', + 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Referrer-Policy')) + + def test_server__should_not_exist(self): + headers = utils.add_or_modify_header('Server', 'Apache/2.4.1 (Unix)') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Server', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Server')) + + def test_set_cookie__should_enforce_secure_for_all_cookies(self): + headers = utils.add_or_modify_header('Set-Cookie', ['session_id=585733723; HttpOnly; SameSite=Strict']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session_id', + 'message': 'Must-Contain directive missed', + 'severity': 'high', + 'value': 'session_id=585733723; HttpOnly; SameSite=Strict', + 'expected': ['HttpOnly', 'Secure'], + 'delimiter': ';', + 'anomalies': ['Secure'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_set_cookie__should_enforce_httponly_for_all_cookies(self): + headers = utils.add_or_modify_header('Set-Cookie', ['session_id=585733723; Secure; SameSite=Strict']) + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Set-Cookie - session_id', + 'message': 'Must-Contain directive missed', + 'severity': 'high', + 'value': 'session_id=585733723; Secure; SameSite=Strict', + 'expected': ['HttpOnly', 'Secure'], + 'delimiter': ';', + 'anomalies': ['HttpOnly'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Set-Cookie')) + + def test_strict_transport_security__should_exist(self): + headers = utils.delete_headers('Strict-Transport-Security') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'Strict-Transport-Security', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['max-age=31536000', 'includeSubDomains'], + 'delimiter': ';' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'Strict-Transport-Security')) + + def test_user_agent__should_not_exist(self): + headers = utils.add_or_modify_header('User-Agent', 'Dalvik/2.1.0 (Linux; U; Android 6.0.1; Nexus Player)') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'User-Agent', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'User-Agent')) + + def test_x_aspnet_version__should_not_exist(self): + headers = utils.add_or_modify_header('X-AspNet-Version', '2.0.50727') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-AspNet-Version', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-AspNet-Version')) + + def test_x_client_ip__should_not_exist(self): + headers = utils.add_or_modify_header('X-Client-IP', '27.59.32.182') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Client-IP', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Client-IP')) + + def test_x_content_type_options__should_exist(self): + headers = utils.delete_headers('X-Content-Type-Options') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Content-Type-Options', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['nosniff'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Content-Type-Options')) + + def test_x_frame_options__should_exist(self): + headers = utils.delete_headers('X-Frame-Options') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Frame-Options', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['DENY', 'SAMEORIGIN'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Frame-Options')) + + def test_x_frame_options__should_disable_allow_from(self): + headers = utils.add_or_modify_header('X-Frame-Options', 'ALLOW-FROM https//example.com') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Frame-Options', + 'message': 'Value does not match security policy. Exactly one of the expected items was expected', + 'severity': 'high', + 'value': 'ALLOW-FROM https//example.com', + 'expected': ['DENY', 'SAMEORIGIN'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Frame-Options')) + + def test_x_forwarded_for__should_not_exist(self): + headers = utils.add_or_modify_header('X-Forwarded-For', '2001:db8:85a3:8d3:1319:8a2e:370:7348') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Forwarded-For', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Forwarded-For')) + + def test_x_generator__should_not_exist(self): + headers = utils.add_or_modify_header('X-Generator', 'Drupal 7 (http://drupal.org)') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Generator', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Generator')) + + def test_x_powered_by__should_not_exist(self): + headers = utils.add_or_modify_header('X-Powered-By', 'ASP.NET') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-Powered-By', + 'message': 'Header should not be returned', + 'severity': 'high' + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-Powered-By')) + + def test_x_xss_protection__should_exist(self): + headers = utils.delete_headers('X-XSS-Protection') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-XSS-Protection', + 'message': 'Header not included in response', + 'severity': 'high', + 'expected': ['0'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-XSS-Protection')) + + def test_x_xss_protection__should_disable_filter(self): + headers = utils.add_or_modify_header('X-XSS-Protection', '1; mode=block') + + report = utils.process_test(headers=headers) + expected = { + 'rule': 'X-XSS-Protection', + 'message': 'Value does not match security policy', + 'severity': 'high', + 'value': '1; mode=block', + 'expected': ['0'] + } + self.assertIn(expected, report, msg=utils.build_error_message(report, expected, 'X-XSS-Protection')) diff --git a/tests/integration_tests/utils.py b/tests/integration_tests/utils.py new file mode 100644 index 0000000..c5943cf --- /dev/null +++ b/tests/integration_tests/utils.py @@ -0,0 +1,58 @@ +import json +import os + +import yaml + +from drheader import core + + +def get_headers(): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/headers_ok.json')) as headers: + return json.load(headers) + + +def add_or_modify_header(header_name, update_value): + headers = get_headers() + headers[header_name] = update_value + return headers + + +def delete_headers(*args): + headers = get_headers() + for header_name in args: + headers.pop(header_name, None) + return headers + + +def process_test(headers=None, url=None, cross_origin_isolated=False): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as rules: + rules = yaml.safe_load(rules.read())['Headers'] + + drheader = core.Drheader(headers=headers, url=url) + return drheader.analyze(rules=rules, cross_origin_isolated=cross_origin_isolated) + + +def build_error_message(report, expected=None, rule=None): + unexpected_items = [] + for item in report: + if item != expected: + if rule and item['rule'].startswith(rule): + unexpected_items.append(item) + elif not rule: + unexpected_items.append(item) + + error_message = '\n' + if len(unexpected_items) > 0: + error_message += '\nThe following items were found but were not expected in the report:\n' + error_message += json.dumps(unexpected_items, indent=2) + if expected and expected not in report: + error_message += '\n\nThe following was not found but was expected in the report:\n' + error_message += json.dumps(expected, indent=2) + return error_message + + +def reset_default_rules(): + with open(os.path.join(os.path.dirname(__file__), '../../drheader/rules.yml')) as rules, \ + open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml'), 'w') as default_rules: + rules = yaml.safe_load(rules.read()) + yaml.dump(rules, default_rules, indent=2, sort_keys=False) diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py deleted file mode 100644 index 2aa648b..0000000 --- a/tests/test_cli_utils.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -"""Tests for `cli_utils.py` file.""" - -import os -import yaml -import json -import unittest2 -import xmlunittest - -from drheader.cli_utils import file_junit_report - - -class TestCliUtilsFunctions(unittest2.TestCase, xmlunittest.XmlTestMixin): - def setUp(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/default_rules.yml'), 'r') as f: - self.rules = yaml.safe_load(f.read())['Headers'] - f.close() - - with open(os.path.join(os.path.dirname(__file__), 'testfiles/example_report.json'), 'r') as f: - self.report = json.loads(f.read()) - f.close() - - file_junit_report(self.rules, self.report) - - with open('reports/junit.xml', 'r') as f: - self.xml = f.read() - f.close() - - def test_file_junit_report_writes_default_file(self): - self.assertXmlDocument(self.xml) - - def test_file_junit_report_contains_test_suites_node(self): - root = self.assertXmlDocument(self.xml) - self.assertXmlNode(root, tag='testsuites') - - def test_file_junit_report_contains_ten_failures_and_seventeen_cases(self): - root = self.assertXmlDocument(self.xml) - self.assertXmlHasAttribute(root, 'failures', expected_values=('10')) - self.assertXmlHasAttribute(root, 'tests', expected_values=('17')) - - def test_file_junit_report_contains_only_one_testsuite(self): - root = self.assertXmlDocument(self.xml) - self.assertXpathsOnlyOne(root, ('./testsuite', './testsuite[@name="DrHeader"]')) - - def test_file_junit_report_contains_all_header_as_testcases(self): - root = self.assertXmlDocument(self.xml) - for item in self.rules: - self.assertXpathsExist(root, ('./testsuite/testcase', './testsuite/testcase[contains(@name,'+item+')]')) - - def test_file_junit_report_contains_seventeen_testcases(self): - root = self.assertXmlDocument(self.xml) - self.assertEqual(root.xpath('count(./testsuite/testcase)'), 17) - - -# start unittest2 to run these tests -if __name__ == "__main__": - unittest2.main() diff --git a/tests/test_drheader.py b/tests/test_drheader.py deleted file mode 100644 index 6be2116..0000000 --- a/tests/test_drheader.py +++ /dev/null @@ -1,677 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -"""Tests for `drheader` package.""" -import json -import logging -import os -import re - -import unittest2 -import yaml - -from drheader import Drheader - - -class DrheaderRules(unittest2.TestCase): - def setUp(self): - # this is run each time before each test_ method is invoked - self.logger = logging.Logger - self.instance = '' - self.report = list - - # configuration - - def tearDown(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'w') as f_test,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/default_rules.yml')) as f_default: - default_rules = yaml.safe_load(f_default.read()) - yaml.dump(default_rules, f_test, sort_keys=False) - - def _process_test(self, url=None, headers=None, status_code=None): - # all tests use this method to run the test and analyze the results. - with open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f: - rules = yaml.safe_load(f.read())['Headers'] - - self.instance = Drheader(url=url, headers=headers, status_code=status_code) - self.instance.analyze(rules=rules) - - # test can then make assertions against the contents of self.instance.report to determine success of failure. - - def test_get_headers_ok(self): - url = 'https://google.com' - self._process_test(url=url) - self.assertNotEqual(self.report, None, msg="A Report was generated") - - def test_compare_rules_ok(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f: - file = json.loads(f.read()) - - self._process_test(headers=file, status_code=200) - self.assertEqual(len(self.instance.report), 0, msg=self.build_error_message(self.instance.report)) - - def test_compare_rules_ok_with_case_insensitive_keys(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f: - file = json.loads(f.read()) - - file['x-xss-protection'] = file.pop('X-XSS-Protection') - - self._process_test(headers=file, status_code=200) - self.assertEqual(len(self.instance.report), 0, msg=self.build_error_message(self.instance.report)) - - def test_compare_rules_ok_with_case_insensitive_values(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f: - file = json.loads(f.read()) - - file['Content-Security-Policy'] = file.pop('Content-Security-Policy').upper() - file['X-Frame-Options'] = file.pop('X-Frame-Options').lower() - - self._process_test(headers=file, status_code=200) - self.assertEqual(len(self.instance.report), 0, msg=self.build_error_message(self.instance.report)) - - def test_compare_rules_enforce_ko(self): - headers = { - 'X-XSS-Protection': '1; mode=bloc', - 'Content-Security-Policy': "default-src 'none'; script-src 'self'; object-src 'self';" - } - expected_response = { - 'severity': 'high', - 'rule': 'X-XSS-Protection', - 'message': 'Value does not match security policy', - 'expected': ['0'], - 'delimiter': ';', - 'value': '1; mode=bloc' - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_response, self.instance.report, msg="X-XSS") - - def test_compare_rules_required_ko(self): - headers = { - 'X-XSS-Protection': '1; mode=block' - } - expected_response = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Header not included in response' - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_response, self.instance.report, msg="Generated Rules") - - def test_compare_rules_not_required_ko(self): - headers = { - 'X-XSS-Protection': '1; mode=block', - 'Content-Security-Policy': "default-src 'none'; script-src 'self'; object-src 'self';", - 'Server': 'Apache', - 'X-Generator': 'Drupal 7 (http://drupal.org)' - } - server_response = { - 'severity': 'high', - 'rule': 'Server', - 'message': 'Header should not be returned' - } - generator_response = { - 'severity': 'high', - 'rule': 'X-Generator', - 'message': 'Header should not be returned' - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(server_response, self.instance.report, msg="Server Rule was triggered") - self.assertIn(generator_response, self.instance.report, msg="Generator Rule was triggered") - - def test_compare_must_contain_ko(self): - headers = { - 'X-XSS-Protection': '1; mode=block', - 'Content-Security-Policy': "default-src 'random'; script-src 'self'" - } - csp_contain_response = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'random'; script-src 'self'", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(csp_contain_response, self.instance.report, msg="CSP Contain Rule was triggered") - - def test_compare_must_avoid_ko(self): - headers = { - 'X-XSS-Protection': '1; mode=block', - 'Content-Security-Policy': "default-src 'none'; script-src 'self'; object-src 'self'; " - "connect-src 'unsafe-inline';" - } - csp_avoid_response = { - 'severity': 'medium', - 'rule': 'Content-Security-Policy - connect-src', - 'message': 'Must-Avoid directive included', - 'avoid': ['unsafe-inline', 'unsafe-eval'], 'delimiter': ';', - 'value': "unsafe-inline", - 'anomaly': 'unsafe-inline' - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(csp_avoid_response, self.instance.report, msg="CSP Avoid Rule was triggered") - - def test_compare_optional(self): - headers = { - 'X-XSS-Protection': '0', - 'Set-Cookie': ['Test'] - } - medium_contain_response = { - 'severity': 'medium', - 'rule': 'Set-Cookie', - 'message': 'Must-Contain directive missed', - 'expected': ['httponly', 'secure'], - 'value': 'test', - 'delimiter': ';', - 'anomaly': 'httponly' - } - high_contain_response = { - 'severity': 'high', - 'rule': 'Set-Cookie', - 'message': 'Must-Contain directive missed', - 'expected': ['httponly', 'secure'], - 'delimiter': ';', - 'value': 'test', - 'anomaly': 'secure' - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(medium_contain_response, self.instance.report, msg="Medium Rule was triggered") - self.assertIn(high_contain_response, self.instance.report, msg="High Rule was triggered") - - def test_compare_optional_not_exist(self): - headers = { - 'X-XSS-Protection': '1; mode=block' - } - header_not_included_response = { - 'rule': 'Set-Cookie', - 'severity': 'high', - 'message': 'Header not included in response', - } - - self._process_test(headers=headers, status_code=200) - self.assertNotIn(header_not_included_response, self.instance.report, msg="Httponly Rule was triggered") - - def test_referrer_policy_invalid_values(self): - headers = { - 'Referrer-Policy': 'origin' - } - referrer_response = { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'], - 'delimiter': ',', - 'value': 'origin', - 'anomaly': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] - } - - self._process_test(headers=headers) - self.assertIn(referrer_response, self.instance.report, msg="Referrer Policy Rule was triggered") - - def test_referrer_policy_valid_values(self): - headers = { - 'Referrer-Policy': 'no-referrer' - } - - # this need updating as there is no referrer-policy rule in the output - no_referrer_response = { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'], - 'delimiter': ',', - 'value': 'no-referrer', - 'anomaly': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'] - } - - self._process_test(headers=headers) - self.assertNotIn(no_referrer_response, self.instance.report, msg="No Referrer Policy Rule was triggered") - - def test_referrer_policy_invalid_values_typo(self): - headers = { - 'Referrer-Policy': 'no-referrerr' - } - - # this need updating as there is no referrer-policy rule in the output - no_referrer_response = { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'Value does not match security policy', - 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'], - 'value': 'no-referrerr' - } - - self._process_test(headers=headers) - self.assertNotIn(no_referrer_response, self.instance.report, msg="No Referrer Policy Rule was triggered") - - def test_referrer_policy_strict_origin(self): - headers = { - 'Referrer-Policy': 'strict-origin' - } - - # this needs updating because there is no refferer policy in output - no_referrer_response = { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'value does not match security policy', - 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'], - 'delimiter': ',', - 'value': 'strict-origin' - } - - self._process_test(headers=headers) - self.assertNotIn(no_referrer_response, self.instance.report, msg="Referrer SO Policy Rule was triggered") - - def test_referrer_policy_strict_cross_origin(self): - headers = { - 'Referrer-Policy': 'strict-origin-when-cross-origin' - } - - # this needs updating because there is no refferer policy in output - referrer_strict_origin_response = { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'Value does not match security policy', - 'expected': ['strict-origin', 'strict-origin-when-cross-origin', 'no-referrer'], - 'delimiter': ';', 'value': - 'strict-origin-when-cross-origin' - } - - self._process_test(headers=headers) - self.assertNotIn(referrer_strict_origin_response, self.instance.report, - msg="Refered SOWCO Policy Rule was triggred") - - def test_csp_invalid_default_directive(self): - headers = { - 'Content-Security-Policy': "default-src 'random';" - } - - # this needs updating because there is no Content-Security-Warining in output - csp_invalid_default_response = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'random';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(csp_invalid_default_response, self.instance.report, msg="CSP directive Policy Rule was triggered") - - def test_csp_valid_default_directive_none(self): - headers = { - 'Content-Security-Policy': "default-src 'none';" - } - - # this needs updating because there is no Content-Security-Warining in output - csp_response_none = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'none';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertNotIn(csp_response_none, self.instance.report, msg="CSP directive policy none was caught") - - def test_csp_invalid_default_directive_none(self): - headers = { - 'Content-Security-Policy': "default-src 'non';" - } - - # this needs updating because there is no Content-Security-Warining in output - csp_response_none = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'non';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(csp_response_none, self.instance.report, msg="CSP directive policy none was caught") - - def test_csp_valid_default_directive_self(self): - headers = { - 'Content-Security-Policy': "default-src 'self';" - } - csp_response_self = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'self';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertNotIn(csp_response_self, self.instance.report, msg="CSP directive policy self was caught") - - def test_csp_invalid_default_directive_self(self): - headers = { - 'Content-Security-Policy': "default-src 'selfie';" - } - csp_response_self = { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'selfie';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - } - - self._process_test(headers=headers, status_code=200) - self.assertIn(csp_response_self, self.instance.report, msg="CSP directive policy self was caught") - - def test_compare_rules_full_output(self): - headers = { - 'Server': 'Apache', - 'X-Generator': 'Drupal 7 (http://drupal.org)', - 'X-XSS-Protection': '1; mode=bloc', - 'Content-Security-Policy': "default-src 'random'; script-src 'self'; object-src 'self'; " - "connect-src 'unsafe-inline';" - } - expected_report = [ - { - 'severity': 'high', - 'rule': 'Content-Security-Policy', - 'message': 'Must-Contain-One directive missed', - 'expected': ["default-src 'none'", "default-src 'self'"], - 'delimiter': ';', - 'value': "default-src 'random'; script-src 'self'; object-src 'self'; connect-src 'unsafe-inline';", - 'anomaly': ["default-src 'none'", "default-src 'self'"] - }, - { - 'severity': 'medium', - 'rule': 'Content-Security-Policy - connect-src', - 'message': 'Must-Avoid directive included', - 'avoid': ['unsafe-inline', 'unsafe-eval'], - 'delimiter': ';', - 'value': "unsafe-inline", - 'anomaly': 'unsafe-inline' - }, - { - 'severity': 'high', 'rule': 'X-XSS-Protection', - 'message': 'Value does not match security policy', - 'expected': ['0'], - 'delimiter': ';', - 'value': '1; mode=bloc' - }, - { - 'severity': 'high', - 'rule': 'Server', - 'message': 'Header should not be returned' - }, - { - 'severity': 'high', - 'rule': 'Strict-Transport-Security', - 'message': 'Header not included in response', - 'expected': ['max-age=31536000', 'includesubdomains'], - 'delimiter': ';' - }, - { - 'severity': 'high', - 'rule': 'X-Frame-Options', - 'message': 'Header not included in response', - 'expected': ['sameorigin', 'deny'], - 'delimiter': ';' - }, - { - 'severity': 'high', - 'rule': 'X-Content-Type-Options', - 'message': 'Header not included in response', - 'expected': ['nosniff'], - 'delimiter': ';' - }, - { - 'severity': 'high', - 'rule': 'Referrer-Policy', - 'message': 'Header not included in response' - }, - { - 'severity': 'high', - 'rule': 'Cache-Control', - 'message': 'Header not included in response', - 'expected': ['no-store', 'max-age=0'], - 'delimiter': ',' - }, - { - 'severity': 'high', - 'rule': 'Pragma', - 'message': 'Header not included in response', - 'expected': ['no-cache'], - 'delimiter': ';' - }, - { - 'severity': 'high', - 'rule': 'X-Generator', - 'message': 'Header should not be returned' - } - ] - - self._process_test(headers=headers, status_code=200) - self.assertEqual(self.instance.report, expected_report, msg=self.build_error_message(self.instance.report, expected_report)) - - def test_csp_required_directive_not_present(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f_headers,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f_rules: - headers = json.loads(f_headers.read()) - rules = yaml.safe_load(f_rules.read()) - - rule_value = rules['Headers']['Content-Security-Policy'] - rule_value['Directives'] = { - 'script-src': { - 'Required': True, - 'Enforce': False - } - } - self.modify_rules('Content-Security-Policy', rule_value) - - directive = re.search('script-src [^;]*(;)?', headers['Content-Security-Policy']).group() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace(directive, '') - - expected_report = { - 'severity': 'high', - 'rule': 'Content-Security-Policy - script-src', - 'message': 'Directive not included in response' - } - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_report, self.instance.report) - - def test_csp_directive_invalid_value(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f_headers,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f_rules: - headers = json.loads(f_headers.read()) - rules = yaml.safe_load(f_rules.read()) - - rule_value = rules['Headers']['Content-Security-Policy'] - rule_value['Directives'] = { - 'script-src': { - 'Required': True, - 'Enforce': True, - 'Delimiter': ' ', - 'Value': ['self'] - } - } - self.modify_rules('Content-Security-Policy', rule_value) - - directive = re.search('script-src [^;]*(;)?', headers['Content-Security-Policy']).group() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace(directive, 'script-src https://www.santander.co.uk https://www.google.com;') - - expected_report = { - 'severity': 'high', - 'rule': 'Content-Security-Policy - script-src', - 'message': 'Value does not match security policy', - 'expected': ['self'], - 'delimiter': ' ', - 'value': 'https://www.santander.co.uk https://www.google.com' - } - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_report, self.instance.report) - - def test_csp_directive_must_avoid_value_included(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f_headers,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f_rules: - headers = json.loads(f_headers.read()) - rules = yaml.safe_load(f_rules.read()) - - rule_value = rules['Headers']['Content-Security-Policy'] - rule_value['Directives'] = { - 'script-src': { - 'Required': True, - 'Enforce': False, - 'Delimiter': ' ', - 'Value': '', - 'Must-Avoid': ['https://www.santander.co.uk'] - } - } - self.modify_rules('Content-Security-Policy', rule_value) - - directive = re.search('script-src [^;]*(;)?', headers['Content-Security-Policy']).group() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace(directive, 'script-src https://www.santander.co.uk https://www.google.com;') - - expected_report = { - 'severity': 'medium', - 'rule': 'Content-Security-Policy - script-src', - 'message': 'Must-Avoid directive included', - 'avoid': ['https://www.santander.co.uk'], - 'delimiter': ' ', - 'value': 'https://www.santander.co.uk https://www.google.com', - 'anomaly': 'https://www.santander.co.uk' - } - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_report, self.instance.report) - - def test_csp_directive_must_contain_value_not_included(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f_headers,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f_rules: - headers = json.loads(f_headers.read()) - rules = yaml.safe_load(f_rules.read()) - - rule_value = rules['Headers']['Content-Security-Policy'] - rule_value['Directives'] = { - 'script-src': { - 'Required': True, - 'Enforce': False, - 'Delimiter': ' ', - 'Value': '', - 'Must-Contain': ['https://www.santander.co.uk'] - } - } - self.modify_rules('Content-Security-Policy', rule_value) - - directive = re.search('script-src [^;]*(;)?', headers['Content-Security-Policy']).group() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace(directive, 'script-src \'self\';') - - expected_report = { - 'severity': 'medium', - 'rule': 'Content-Security-Policy - script-src', - 'message': 'Must-Contain directive missed', - 'expected': ['https://www.santander.co.uk'], - 'delimiter': ' ', - 'value': 'self', - 'anomaly': 'https://www.santander.co.uk' - } - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_report, self.instance.report) - - def test_csp_directive_must_contain_one_value_not_included(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/header_ok.json'), 'r') as f_headers,\ - open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r') as f_rules: - headers = json.loads(f_headers.read()) - rules = yaml.safe_load(f_rules.read()) - - rule_value = rules['Headers']['Content-Security-Policy'] - rule_value['Directives'] = { - 'script-src': { - 'Required': True, - 'Enforce': False, - 'Delimiter': ' ', - 'Value': '', - 'Must-Contain-One': ['https://www.santander.co.uk', 'https://www.google.com'] - } - } - self.modify_rules('Content-Security-Policy', rule_value) - - directive = re.search('script-src [^;]*(;)?', headers['Content-Security-Policy']).group() - headers['Content-Security-Policy'] = headers['Content-Security-Policy'].replace(directive, 'script-src \'self\';') - - expected_report = { - 'severity': 'high', - 'rule': 'Content-Security-Policy - script-src', - 'message': 'Must-Contain-One directive missed', - 'expected': ['https://www.santander.co.uk', 'https://www.google.com'], - 'delimiter': ' ', - 'value': 'self', - 'anomaly': ['https://www.santander.co.uk', 'https://www.google.com'] - } - self._process_test(headers=headers, status_code=200) - self.assertIn(expected_report, self.instance.report) - - @staticmethod - def modify_rules(rule, rule_value): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/test_rules.yml'), 'r+') as f: - rules = yaml.safe_load(f.read()) - rules['Headers'][rule] = rule_value - yaml.dump(rules, f) - - @staticmethod - def build_error_message(report, expected_report=None): - if expected_report is None: - expected_report = [] - elif type(expected_report) is dict: - expected_report = expected_report.items() - - unexpected_items = [] - for item in report: - if item not in expected_report: - unexpected_items.append(item) - - missing_items = [] - for item in expected_report: - if item not in report: - missing_items.append(item) - - error_message = "" - if len(unexpected_items) > 0: - error_message += "\nThe following items were found but were not expected in the report: \n" - error_message += json.dumps(unexpected_items, indent=2) - - if len(missing_items) > 0: - error_message += "\nThe following items were not found but were expected in the report: \n" - error_message += json.dumps(missing_items, indent=2) - - return error_message - - # def test_command_line_interface(): - # """Test the CLI.""" - # runner = CliRunner() - # result = runner.invoke(cli.main) - # # assert result.exit_code == 0 - # # assert 'drheader.cli.main' in result.output - # help_result = runner.invoke(cli.main, - # ['-t', 'url']) - # print(help_result.output) - # # assert help_result.exit_code == 0 - # # assert '--help Show this message and exit.' in help_result.output - - -# start unittest2 to run these tests -if __name__ == "__main__": - unittest2.main() diff --git a/tests/testfiles/custom_rules.yml b/tests/test_resources/custom_rules.yml similarity index 50% rename from tests/testfiles/custom_rules.yml rename to tests/test_resources/custom_rules.yml index fcc143f..6cfeb7b 100644 --- a/tests/testfiles/custom_rules.yml +++ b/tests/test_resources/custom_rules.yml @@ -1,13 +1,8 @@ Headers: Content-Security-Policy: Required: True - Enforce: False - Value: - X-powered-by: + X-Powered-By: Required: True - Enforce: False - Value: New-Invented-Header: Required: True - Enforce: True - Value: + Value: required-value diff --git a/tests/testfiles/custom_rules_merged.yml b/tests/test_resources/custom_rules_merged.yml similarity index 53% rename from tests/testfiles/custom_rules_merged.yml rename to tests/test_resources/custom_rules_merged.yml index 3cbdc31..de8cc2a 100644 --- a/tests/testfiles/custom_rules_merged.yml +++ b/tests/test_resources/custom_rules_merged.yml @@ -1,85 +1,61 @@ Headers: - Content-Security-Policy: + Cache-Control: Required: True - Enforce: False Value: - X-XSS-Protection: + - no-store + - max-age=0 + Content-Security-Policy: Required: True - Enforce: True - Value: - - 0 - Server: - Required: False - Enforce: False - Value: - Strict-Transport-Security: + Cross-Origin-Embedder-Policy: Required: True - Enforce: True - Value: - - max-age=31536000; includeSubDomains - X-Frame-Options: + Value: require-corp + Cross-Origin-Opener-Policy: Required: True - Enforce: True - Value: - - SAMEORIGIN - - DENY - X-Content-Type-Options: + Value: same-origin + Pragma: Required: True - Enforce: True - Value: - - nosniff - Set-Cookie: - Required: Optional - Enforce: False - Value: - Must-Contain: - - HttpOnly - - Secure + Value: no-cache Referrer-Policy: Required: True - Enforce: False - Delimiter: ',' - Value: - Must-Contain-One: + Value-One-Of: - strict-origin - strict-origin-when-cross-origin - no-referrer - Cache-Control: - Required: True - Enforce: True - Delimiter: ',' - Value: - - no-store, max-age=0 - Pragma: - Required: True - Enforce: True - Value: - - no-cache - X-powered-by: + Server: + Required: False + Set-Cookie: + Required: Optional + Must-Contain: + - HttpOnly + - Secure + Strict-Transport-Security: Required: True - Enforce: False Value: - X-AspNet-Version: + - max-age=31536000 + - includeSubDomains + User-Agent: Required: False - Enforce: False - Value: - X-Generator: + X-AspNet-Version: Required: False - Enforce: False - Value: - User-Agent: + X-Client-IP: Required: False - Enforce: False - Value: + X-Content-Type-Options: + Required: True + Value: nosniff X-Forwarded-For: Required: False - Enforce: False - Value: - X-Client-IP: + X-Frame-Options: + Required: True + Value-One-Of: + - DENY + - SAMEORIGIN + X-Generator: Required: False - Enforce: False - Value: + X-Powered-By: + Required: True + X-XSS-Protection: + Required: True + Value: 0 New-Invented-Header: Required: True - Enforce: True - Value: + Value: required-value diff --git a/tests/testfiles/test_rules.yml b/tests/test_resources/default_rules.yml similarity index 52% rename from tests/testfiles/test_rules.yml rename to tests/test_resources/default_rules.yml index 07e19a1..b372c23 100644 --- a/tests/testfiles/test_rules.yml +++ b/tests/test_resources/default_rules.yml @@ -1,87 +1,67 @@ Headers: + Cache-Control: + Required: true + Value: + - no-store + - max-age=0 Content-Security-Policy: Required: true - Enforce: false - Value: null - Must-Contain-One: - - default-src 'none' - - default-src 'self' Must-Avoid: - unsafe-inline - unsafe-eval - X-XSS-Protection: + Directives: + default-src: + Required: true + Value-One-Of: + - none + - self + Cross-Origin-Embedder-Policy: Required: true - Enforce: true - Value: - - 0 - Server: - Required: false - Enforce: false - Value: null - Strict-Transport-Security: + Value: require-corp + Cross-Origin-Opener-Policy: Required: true - Enforce: true - Value: - - max-age=31536000; includeSubDomains - X-Frame-Options: + Value: same-origin + Pragma: Required: true - Enforce: true - Value: - - SAMEORIGIN - - DENY - X-Content-Type-Options: + Value: no-cache + Referrer-Policy: Required: true - Enforce: true - Value: - - nosniff + Value-One-Of: + - strict-origin + - strict-origin-when-cross-origin + - no-referrer + Server: + Required: false Set-Cookie: Required: Optional - Enforce: false - Value: null Must-Contain: - HttpOnly - Secure - Referrer-Policy: - Required: true - Enforce: false - Delimiter: ',' - Value: null - Must-Contain-One: - - strict-origin - - strict-origin-when-cross-origin - - no-referrer - Cache-Control: - Required: true - Enforce: true - Delimiter: ',' - Value: - - no-store, max-age=0 - Pragma: + Strict-Transport-Security: Required: true - Enforce: true Value: - - no-cache - X-powered-by: + - max-age=31536000 + - includeSubDomains + User-Agent: Required: false - Enforce: false - Value: null X-AspNet-Version: Required: false - Enforce: false - Value: null - X-Generator: - Required: false - Enforce: false - Value: null - User-Agent: + X-Client-IP: Required: false - Enforce: false - Value: null + X-Content-Type-Options: + Required: true + Value: nosniff X-Forwarded-For: Required: false - Enforce: false - Value: null - X-Client-IP: + X-Frame-Options: + Required: true + Value-One-Of: + - DENY + - SAMEORIGIN + X-Generator: Required: false - Enforce: false - Value: null + X-Powered-By: + Required: false + X-XSS-Protection: + Required: true + Value: 0 diff --git a/tests/testfiles/example_report.json b/tests/test_resources/example_report.json similarity index 100% rename from tests/testfiles/example_report.json rename to tests/test_resources/example_report.json diff --git a/tests/testfiles/header_ok.json b/tests/test_resources/headers_ok.json similarity index 66% rename from tests/testfiles/header_ok.json rename to tests/test_resources/headers_ok.json index a7471f2..0f0fe04 100644 --- a/tests/testfiles/header_ok.json +++ b/tests/test_resources/headers_ok.json @@ -1,13 +1,15 @@ { "X-XSS-Protection": "0", "Content-Security-Policy": "default-src 'none'; script-src 'self'; object-src 'self'; frame-src 'self'", + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Opener-Policy": "same-origin", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", - "Referrer-Policy": "strict-origin", + "Referrer-Policy": "no-referrer", "Cache-Control": "no-store, max-age=0", "Pragma": "no-cache", "Set-Cookie": [ - "HttpOnly; Secure" + "session_id=4589399433; HttpOnly; Secure" ] } diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 442c2af..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -"""Tests for `utils.py` file.""" - -import os -import yaml -import unittest2 -import responses - -from drheader.utils import load_rules, get_rules_from_uri - - -class TestUtilsFunctions(unittest2.TestCase): - def setUp(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/default_rules.yml'), 'r') as f: - self.default_rules = yaml.safe_load(f.read()) - f.close() - with open(os.path.join(os.path.dirname(__file__), 'testfiles/custom_rules.yml'), 'r') as f: - self.custom_rules = yaml.safe_load(f.read()) - f.close() - with open(os.path.join(os.path.dirname(__file__), 'testfiles/custom_rules_merged.yml'), 'r') as f: - self.custom_rules_merged = yaml.safe_load(f.read()) - f.close() - - def test_load_rules_default(self): - rules = load_rules() - self.assertEqual(rules, self.default_rules['Headers']) - - def test_load_rules_custom(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/custom_rules.yml'), 'r') as f: - rules = load_rules(f) - f.close() - self.assertNotEqual(rules, self.default_rules['Headers']) - self.assertEqual(rules, self.custom_rules['Headers']) - - def test_load_rules_custom_and_merge(self): - with open(os.path.join(os.path.dirname(__file__), 'testfiles/custom_rules.yml'), 'r') as f: - rules = load_rules(f, True) - f.close() - self.assertNotEqual(rules, self.default_rules['Headers']) - self.assertNotEqual(rules, self.custom_rules['Headers']) - self.assertEqual(rules, self.custom_rules_merged['Headers']) - - def test_load_rules_bad_parameter(self): - with self.assertRaises(AttributeError): - load_rules(2) - - @responses.activate - def test_get_rules_from_uri_wrong_URI(self): - responses.add(responses.GET, 'http://mydomain.com/custom.yml', status=404) - with self.assertRaises(Exception): - get_rules_from_uri("http://mydomain.com/custom.yml") - - @responses.activate - def test_get_rules_from_uri_good_URI(self): - responses.add(responses.GET, 'http://localhost:8080/custom.yml', json=self.custom_rules, status=200) - file = get_rules_from_uri("http://localhost:8080/custom.yml") - content = yaml.safe_load(file.read()) - self.assertEqual(content, self.custom_rules) - - -# start unittest2 to run these tests -if __name__ == "__main__": - unittest2.main() diff --git a/tests/testfiles/default_rules.yml b/tests/testfiles/default_rules.yml deleted file mode 100644 index 3d279f7..0000000 --- a/tests/testfiles/default_rules.yml +++ /dev/null @@ -1,89 +0,0 @@ -Headers: - Content-Security-Policy: - Required: True - Enforce: False - Value: - Must-Contain-One: - - default-src 'none' - - default-src 'self' - Must-Avoid: - - unsafe-inline - - unsafe-eval - X-XSS-Protection: - Required: True - Enforce: True - Value: - - 0 - Server: - Required: False - Enforce: False - Value: - Strict-Transport-Security: - Required: True - Enforce: True - Value: - - max-age=31536000; includeSubDomains - X-Frame-Options: - Required: True - Enforce: True - Value: - - SAMEORIGIN - - DENY - X-Content-Type-Options: - Required: True - Enforce: True - Value: - - nosniff - Set-Cookie: - Required: Optional - Enforce: False - Value: - Must-Contain: - - HttpOnly - - Secure - Referrer-Policy: - Required: True - Enforce: False - Delimiter: ',' - Value: - Must-Contain-One: - - strict-origin - - strict-origin-when-cross-origin - - no-referrer - Cache-Control: - Required: True - Enforce: True - Delimiter: ',' - Value: - - no-store, max-age=0 - Pragma: - Required: True - Enforce: True - Value: - - no-cache - X-powered-by: - Required: False - Enforce: False - Value: - X-AspNet-Version: - Required: False - Enforce: False - Value: - X-Generator: - Required: False - Enforce: False - Value: - User-Agent: - Required: False - Enforce: False - Value: - X-Forwarded-For: - Required: False - Enforce: False - Value: - X-Client-IP: - Required: False - Enforce: False - Value: - - # TODO - Add ruleset and severity diff --git a/tests/unit_tests/__init__.py b/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/test_cli.py b/tests/unit_tests/test_cli.py new file mode 100644 index 0000000..2d7886e --- /dev/null +++ b/tests/unit_tests/test_cli.py @@ -0,0 +1,188 @@ +import json +import os +import os.path +import tempfile +from unittest import mock + +import unittest2 +import xmlunittest +import yaml +from click import ClickException +from click.testing import CliRunner + +from drheader import cli +from drheader.cli_utils import file_junit_report + + +class TestCli(unittest2.TestCase): + + def setUp(self): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/example_report.json')) as report_file: + self.mock_report = json.load(report_file) + with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as rules_file: + self.mock_rules = yaml.safe_load(rules_file) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_compare_should_analyse_headers(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'[{"url": "https://test1.com", "headers": {"X-XSS-Protection": "1; mode=block"}},' + b'{"url": "https://test2.com", "headers": {"X-Frame-Options": "DENY"}}]') + tmp.seek(0) + runner = CliRunner() + runner.invoke(cli.main, ['compare', tmp.name]) + + self.assertEqual(drheader_mock.call_args_list, [ + mock.call(url='https://test1.com', headers={'X-XSS-Protection': '1; mode=block'}), + mock.call(url='https://test2.com', headers={'X-Frame-Options': 'DENY'}) + ]) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_compare_invalid_format_should_raise_exception_and_exit(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'[{"url": "https://test1.com", "http_headers": {"X-XSS-Protection": "1; mode=block"}}]') + tmp.seek(0) + runner = CliRunner() + result = runner.invoke(cli.main, ['compare', tmp.name]) + + self.assertEqual(ClickException.exit_code, result.exit_code) + self.assertIn("Error: 'headers' is a required property", result.output) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_single_should_analyse_target_url(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + runner = CliRunner() + runner.invoke(cli.main, ['scan', 'single', 'https://www.google.com']) + + drheader_mock.assert_called_once_with(url='https://www.google.com', verify=mock.ANY) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_single_with_json_flag_should_output_json(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + runner = CliRunner() + result = runner.invoke(cli.main, ['scan', 'single', 'https://www.google.com', '--json']) + + with open(os.path.join(os.path.dirname(__file__), '../test_resources/example_report.json')) as report_file: + self.assertEqual(json.load(report_file), json.loads(result.output)) + + @mock.patch('drheader.cli.file_junit_report') + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_single_with_junit_flag_should_write_junit_report(self, drheader_mock, load_rules_mock, junit_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + runner = CliRunner() + runner.invoke(cli.main, ['scan', 'single', 'https://www.google.com', '--junit']) + + junit_mock.assert_called_once_with(self.mock_rules, self.mock_report) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_bulk_should_read_json_file(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'[{"url": "https://test1.com"}, {"url": "https://test2.com"}, {"url": "https://test3.com"}]') + tmp.seek(0) + runner = CliRunner() + runner.invoke(cli.main, ['scan', 'bulk', tmp.name]) + + self.assertEqual(drheader_mock.call_args_list, [ + mock.call(url='https://test1.com', params=mock.ANY, verify=mock.ANY), + mock.call(url='https://test2.com', params=mock.ANY, verify=mock.ANY), + mock.call(url='https://test3.com', params=mock.ANY, verify=mock.ANY), + ]) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_bulk_should_read_txt_file(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'https://test1.com\nhttps://test2.com\nhttps://test3.com') + tmp.seek(0) + runner = CliRunner() + runner.invoke(cli.main, ['scan', 'bulk', '-ff', 'txt', tmp.name]) + + self.assertEqual(drheader_mock.call_args_list, [ + mock.call(url='https://test1.com', params=mock.ANY, verify=mock.ANY), + mock.call(url='https://test2.com', params=mock.ANY, verify=mock.ANY), + mock.call(url='https://test3.com', params=mock.ANY, verify=mock.ANY), + ]) + + @mock.patch('drheader.cli.load_rules') + @mock.patch('drheader.cli.Drheader') + def test_scan_bulk_invalid_format_should_raise_exception_and_exit(self, drheader_mock, load_rules_mock): + drheader_instance = drheader_mock.return_value + drheader_instance.reporter.report = self.mock_report + load_rules_mock.return_value = self.mock_rules + + with tempfile.NamedTemporaryFile() as tmp: + tmp.write(b'[{"address": "https://test1.com"}, {"url": "https://test2.com"}]') + tmp.seek(0) + runner = CliRunner() + result = runner.invoke(cli.main, ['scan', 'bulk', tmp.name]) + + self.assertEqual(ClickException.exit_code, result.exit_code) + self.assertIn("Error: 'url' is a required property", result.output) + + +class TestCliUtils(unittest2.TestCase, xmlunittest.XmlTestMixin): + + def setUp(self): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as f: + self.rules = yaml.safe_load(f.read())['Headers'] + with open(os.path.join(os.path.dirname(__file__), '../test_resources/example_report.json')) as f: + self.report = json.loads(f.read()) + + file_junit_report(self.rules, self.report) + with open('reports/junit.xml') as f: + self.xml = f.read() + + def test_file_junit_report_writes_default_file(self): + self.assertXmlDocument(self.xml) + + def test_file_junit_report_contains_test_suites_node(self): + root = self.assertXmlDocument(self.xml) + self.assertXmlNode(root, tag='testsuites') + + def test_file_junit_report_contains_ten_failures_and_seventeen_cases(self): + root = self.assertXmlDocument(self.xml) + self.assertXmlHasAttribute(root, 'failures', expected_values='10') + self.assertXmlHasAttribute(root, 'tests', expected_values='19') + + def test_file_junit_report_contains_only_one_testsuite(self): + root = self.assertXmlDocument(self.xml) + self.assertXpathsOnlyOne(root, ('./testsuite', './testsuite[@name="DrHeader"]')) + + def test_file_junit_report_contains_all_header_as_testcases(self): + root = self.assertXmlDocument(self.xml) + for item in self.rules: + self.assertXpathsExist(root, ('./testsuite/testcase', './testsuite/testcase[contains(@name,'+item+')]')) + + def test_file_junit_report_contains_seventeen_testcases(self): + root = self.assertXmlDocument(self.xml) + self.assertEqual(root.xpath('count(./testsuite/testcase)'), 19) diff --git a/tests/unit_tests/test_utils.py b/tests/unit_tests/test_utils.py new file mode 100644 index 0000000..c1f04bf --- /dev/null +++ b/tests/unit_tests/test_utils.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Tests for `utils.py` file.""" + +import os + +import responses +import unittest2 +import yaml + +from drheader import utils + + +class TestUtils(unittest2.TestCase): + + def setUp(self): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/default_rules.yml')) as f: + self.default_rules = yaml.safe_load(f.read()) + with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules.yml')) as f: + self.custom_rules = yaml.safe_load(f.read()) + with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules_merged.yml')) as f: + self.custom_rules_merged = yaml.safe_load(f.read()) + + def test_parse_policy__should_extract_standalone_directive(self): + policy = "default-src 'none'; upgrade-insecure-requests" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ') + + self.assertIn('upgrade-insecure-requests', directives_list) + + def test_parse_policy__should_extract_key_value_directive(self): + policy = "default-src 'none'; upgrade-insecure-requests" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ') + + expected = utils.KeyValueDirective('default-src', ["'none'"], "'none'") + self.assertIn(expected, directives_list) + + def test_parse_policy__should_extract_raw_key_value_directive(self): + policy = "default-src 'none'; upgrade-insecure-requests" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ') + + self.assertIn("default-src 'none'", directives_list) + + def test_parse_policy__should_extract_all_values_for_key_value_directive_with_multiple_values(self): + policy = "default-src 'none'; script-src https: 'unsafe-inline'" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ') + + expected = utils.KeyValueDirective('script-src', ['https:', "'unsafe-inline'"], "https: 'unsafe-inline'") + self.assertIn(expected, directives_list) + + def test_parse_policy__should_extract_keys_from_key_value_directives_when_keys_only_is_true(self): + policy = "default-src 'none'" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ', keys_only=True) + + self.assertIn('default-src', directives_list) + + def test_parse_policy__should_remove_strip_characters_from_directive_values(self): + policy = "default-src 'none'; script-src 'unsafe-inline'" + directives_list = utils.parse_policy(policy, ';', key_delimiter=' ', value_delimiter=' ', strip='\' ') + + expected = utils.KeyValueDirective('default-src', ['none'], "'none'") + self.assertIn(expected, directives_list) + + def test_parse_policy__should_handle_repeated_delimiters(self): + policy = "default-src 'none';; ;; script-src 'self' 'unsafe-inline';;" + directives_list = utils.parse_policy(policy, ';', ' ', value_delimiter=' ') + + expected = utils.KeyValueDirective('script-src', ["'self'", "'unsafe-inline'"], " 'self' 'unsafe-inline'") + self.assertIn(expected, directives_list) + + def test_load_rules_should_load_default_rules_when_no_rules_file_is_provided(self): + rules = utils.load_rules() + self.assertEqual(rules, self.default_rules['Headers']) + + def test_load_rules_should_load_custom_rules_when_a_rules_file_is_provided(self): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules.yml')) as f: + rules = utils.load_rules(f) + + self.assertEqual(rules, self.custom_rules['Headers']) + + def test_load_rules_should_merge_custom_rules_with_default_rules_when_merge_flag_is_true(self): + with open(os.path.join(os.path.dirname(__file__), '../test_resources/custom_rules.yml')) as f: + rules = utils.load_rules(f, True) + + self.assertEqual(rules, self.custom_rules_merged['Headers']) + + @responses.activate + def test_get_rules_from_uri_should_return_rules_from_a_valid_uri(self): + uri = 'http://localhost:8080/custom.yml' + responses.add(responses.GET, uri, json=self.custom_rules, status=200) + + rules_file = utils.get_rules_from_uri(uri) + rules = yaml.safe_load(rules_file.read()) + self.assertEqual(rules, self.custom_rules) + + @responses.activate + def test_get_rules_from_uri_should_raise_an_error_when_no_content_is_found(self): + uri = 'http://mydomain.com/custom.yml' + responses.add(responses.GET, uri, status=404) + + with self.assertRaises(Exception) as e: + utils.get_rules_from_uri(uri) + + self.assertEqual('No content retrieved from {}'.format(uri), str(e.exception)) diff --git a/tests/unit_tests/test_validators.py b/tests/unit_tests/test_validators.py new file mode 100644 index 0000000..23aba25 --- /dev/null +++ b/tests/unit_tests/test_validators.py @@ -0,0 +1,165 @@ +from unittest import mock + +import unittest2 +from requests import structures + +from drheader import report, utils +from drheader.validators import header_validator + + +class TestBase(unittest2.TestCase): + + def assert_report_items_equal(self, expected_report_item, observed_report_item, msg=None): + does_validate = True + + for field in expected_report_item._asdict(): + expected = getattr(expected_report_item, field) + observed = getattr(observed_report_item, field) + if not expected == observed: + msg += f"\tNon-matching values for field '{field}'. Expected: '{expected}'; Observed: '{observed}'\n" + does_validate = False + if not does_validate: + raise self.failureException(msg) + + +class TestHeaderValidator(TestBase): + + def setUp(self): + self.validator = header_validator.HeaderValidator(headers=structures.CaseInsensitiveDict()) + self.addTypeEqualityFunc(report.ReportItem, super().assert_report_items_equal) + + @mock.patch('drheader.utils.parse_policy') + def test_validate_value__should_enforce_order_when_preserve_order_is_true(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'value': ['no-referrer', 'strict-origin-when-cross-origin'], + 'delimiters': {'item_delimiter': ','}, + 'preserve-order': True + }) + self.validator.headers['referrer-policy'] = 'strict-origin-when-cross-origin, no-referrer' + parse_policy_mock.return_value = ['strict-origin-when-cross-origin', 'no-referrer'] + + response = self.validator.validate_value(config, 'referrer-policy') + 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=',') + self.assertEqual(expected, response, msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_avoid__should_validate_named_cookie(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-avoid': ['samesite=lax'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': '='} + }) + self.validator.headers['set-cookie'] = ['session=657488329; samesite=lax', 'tracker=849338398; samesite=lax'] + parse_policy_mock.return_value = ['session=657488329', 'session', 'samesite=lax', 'samesite'] + + response = self.validator.validate_must_avoid(config, 'set-cookie', cookie='session') + expected = report.ReportItem('high', report.ErrorType.AVOID, 'set-cookie', cookie='session', value='session=657488329; samesite=lax', avoid=['samesite=lax'], anomalies=['samesite=lax']) + self.assertEqual(expected, response, msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_contain__should_validate_named_cookie(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-contain': ['httponly', 'secure'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': '='} + }) + self.validator.headers['set-cookie'] = ['session=657488329; httponly', 'tracker=849338398; httponly'] + parse_policy_mock.return_value = ['session=657488329', 'session', 'httponly'] + + response = self.validator.validate_must_contain(config, 'set-cookie', cookie='session') + expected = report.ReportItem('high', report.ErrorType.CONTAIN, 'set-cookie', cookie='session', value='session=657488329; httponly', expected=['httponly', 'secure'], delimiter=';', anomalies=['secure']) + self.assertEqual(expected, response, msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_contain_one__should_validate_named_cookie(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-contain-one': ['expires', 'max-age'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': '='} + }) + self.validator.headers['set-cookie'] = ['session=657488329', 'tracker=849338398'] + parse_policy_mock.return_value = ['session=657488329', 'session'] + + response = self.validator.validate_must_contain_one(config, 'set-cookie', cookie='session') + expected = report.ReportItem('high', report.ErrorType.CONTAIN_ONE, 'set-cookie', cookie='session', value='session=657488329', expected=['expires', 'max-age']) + self.assertEqual(expected, response, msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_avoid_for_policy_header__should_validate_standalone_directive(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-avoid': ['block-all-mixed-content'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} + }) + self.validator.headers['content-security-policy'] = "default-src 'none'; block-all-mixed-content" + + parse_policy_mock.return_value = [ + "default-src 'none'", + utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), + 'block-all-mixed-content' + ] + + response = self.validator.validate_must_avoid(config, 'content-security-policy') + 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']) + self.assertEqual(expected, response[0], msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_avoid_for_policy_header__should_validate_key_value_directive(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-avoid': ['script-src'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} + }) + self.validator.headers['content-security-policy'] = "default-src 'none'; script-src https://example.com" + + parse_policy_mock.return_value = [ + "default-src 'none'", + utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), + 'script-src https://example.com', + utils.KeyValueDirective(key='script-src', value=['https://example.com'], raw_value='https://example.com') + ] + + response = self.validator.validate_must_avoid(config, 'content-security-policy') + 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']) + self.assertEqual(expected, response[0], msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_avoid_for_policy_header__should_validate_keyword_value(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-avoid': ['unsafe-inline'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} + }) + self.validator.headers['content-security-policy'] = "default-src 'none'; script-src 'unsafe-inline'" + + parse_policy_mock.return_value = [ + "default-src 'none'", + utils.KeyValueDirective(key='default-src', value=['none'], raw_value="'none'"), + "script-src 'unsafe-inline'", + utils.KeyValueDirective(key='script-src', value=['unsafe-inline'], raw_value="'unsafe-inline'") + ] + + response = self.validator.validate_must_avoid(config, 'content-security-policy') + expected = report.ReportItem('high', report.ErrorType.AVOID, 'content-security-policy', directive='script-src', value="'unsafe-inline'", avoid=['unsafe-inline'], anomalies=['unsafe-inline']) + self.assertEqual(expected, response[0], msg='The report items are not equal:\n') + + @mock.patch('drheader.utils.parse_policy') + def test_validate_must_avoid_for_policy_header__should_report_all_non_compliant_directives(self, parse_policy_mock): + config = structures.CaseInsensitiveDict({ + 'required': True, + 'must-avoid': ['unsafe-inline'], + 'delimiters': {'item_delimiter': ';', 'key_delimiter': ' ', 'value_delimiter': ' ', 'strip': '\' '} + }) + self.validator.headers['content-security-policy'] = "script-src 'unsafe-inline'; object-src 'unsafe-inline'" + + parse_policy_mock.return_value = [ + "script-src 'unsafe-inline'", + utils.KeyValueDirective(key='script-src', value=['unsafe-inline'], raw_value="'unsafe-inline'"), + "object-src 'unsafe-inline'", + utils.KeyValueDirective(key='object-src', value=['unsafe-inline'], raw_value="'unsafe-inline'") + ] + + response = self.validator.validate_must_avoid(config, 'content-security-policy') + self.assertEqual('script-src', response[0].directive) + self.assertEqual('object-src', response[1].directive)