├── .bandit ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── bandit.yml │ └── docker.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── docs └── images │ ├── logo.jpg │ └── usage.png ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tntfuzzer ├── core ├── __init__.py ├── curlcommand.py ├── httpoperation.py ├── pattern.py ├── replicator.py └── resultvalidatior.py ├── tests ├── __init__.py ├── core │ ├── __init__.py │ ├── curlcommandtest.py │ ├── httpoperationtest.py │ ├── patterntest.py │ ├── replicatortest.py │ └── resultvalidatortest.py ├── tntfuzzertest.py └── utils │ ├── __init__.py │ └── strutilstest.py ├── tntfuzzer.py └── utils ├── __init__.py └── strutils.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: /tntfuzzer/tests 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Set parameter X to '...' 13 | 2. Run the application 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots or text output to help explain your problem. 22 | 23 | **Used Software (please complete the following information):** 24 | - OS: [e.g. Linux or macOS] 25 | - Python [e.g. python 2.7, python 3] 26 | - Application-Version [e.g. 1.0.0] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | > Describe the problems, issues, or needs driving this feature/fix and include describtions about related issues (including issue number if possible). 3 | 4 | ## Goals 5 | > Describe the solutions that this feature/fix will introduce to resolve the problems described above. 6 | 7 | ## Approach 8 | > Describe how you are implementing the solutions. 9 | 10 | ## Added tests? 11 | > Did you add tests for your feature? Yes/No 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/bandit.yml: -------------------------------------------------------------------------------- 1 | name: SAST (bandit) 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Bandit Check 16 | uses: jpetrucciani/bandit-check@1.6.2 17 | with: 18 | path: '.' 19 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Docker Image CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | build: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@v2 24 | 25 | - name: Build the Docker image 26 | run: docker build . --file Dockerfile --tag tntfuzzer:$(date +%s) 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # idea 104 | .idea/ 105 | 106 | # OS X 107 | .DS_Store 108 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: python 3 | python: 4 | - "3.7" 5 | # command to install dependencies 6 | install: 7 | - git clone https://github.com/mseclab/PyJFuzz.git && cd PyJFuzz && python setup.py install && cd .. 8 | - pip install -r requirements.txt 9 | - pip install -r requirements-dev.txt 10 | # command to run tests 11 | script: 12 | - cd tntfuzzer && nosetests tests/core/*.py tests/utils/*.py tests/*.py 13 | - pycodestyle --max-line-length=120 ./tests/core/*.py 14 | - pycodestyle --max-line-length=120 ./tests/utils/*.py 15 | - pycodestyle --max-line-length=120 --ignore=W605,W504 ./*.py 16 | - pycodestyle --max-line-length=120 --ignore=W605,W504 ./core/*.py 17 | - pycodestyle --max-line-length=120 --ignore=W605,W504 ./utils/*.py 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | 7 | RUN apk add git --update 8 | RUN git clone https://github.com/mseclab/PyJFuzz.git && cd PyJFuzz && python setup.py install 9 | RUN rm -rf PyJFuzz 10 | RUN cd .. 11 | RUN pip install -r requirements.txt 12 | 13 | COPY . . 14 | 15 | CMD ["python", "tntfuzzer/tntfuzzer.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Tobias Hassenklöver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![](docs/images/logo.jpg) 3 | 4 | [![Build Status](https://travis-ci.com/Teebytes/TnT-Fuzzer.svg?branch=master)](https://travis-ci.com/Teebytes/TnT-Fuzzer) [![Downloads](https://pepy.tech/badge/tntfuzzer/month)](https://pepy.tech/project/tntfuzzer) [![codebeat badge](https://codebeat.co/badges/baec008b-eaf2-451f-b2ff-758c0c8551f0)](https://codebeat.co/projects/github-com-teebytes-tnt-fuzzer-master) [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) 5 | ============ 6 | TnT-Fuzzer is an OpenAPI (swagger) fuzzer written in python. It is like dynamite for your API! 7 | 8 | TnT-Fuzzer is designed to make fuzzing, robustness testing and validation of REST APIs easy and maintainable. After the fuzzer runs, the log files state the exact history of requests to reenact a crash or misuse. TnT-Fuzzer can be used 9 | for penetration testing or continued testing of a service in development. 10 | 11 | ## Installation 12 | TnT-Fuzzer shifted support away from python 2 to **python 3.7**. If you 13 | need a python 2 compatible source, lookup TnT-Fuzzer Version 1.0.0 and below. 14 | 15 | ### With docker 16 | Manual installation (see below) can be a little tricky, due to some dependencies not available for **python 3**. If you just want to 17 | run the fuzzer via the commandline, the installation via docker is a fast and reliable choice. You won't even need a python installation on your system. Just run the docker build with a local tag of your choice: 18 | 19 | ``` 20 | docker build . -t YOUR_TAG 21 | ``` 22 | 23 | Then after the build of the image is complete, running tntfuzzer in a container is as easy as the following: 24 | 25 | ``` 26 | docker run YOUR_TAG python tntfuzzer/tntfuzzer.py --url https://petstore.swagger.io/v2/swagger.json --iterations 100 --log_all 27 | ``` 28 | 29 | This command is equal to the command used in the examples section. For more information on use, see below. 30 | 31 | ### From source 32 | Checkout git repository. Navigate into fresh cloned repository and install 33 | all dependencies needed. All dependencies are listed in requirements.txt 34 | and can be installed via pip: 35 | 36 | ``` 37 | pip install -r requirements.txt 38 | ``` 39 | 40 | However, at the moment of writing this guide the PyJFuzz dependency available 41 | via [pypi](https://pypi.org/) is outdated only compatible with python 2 only. So, 42 | when problems installing the PyJFuzz dependency occur, install the newest version 43 | of it manually and then install the other dependencies: 44 | 45 | ``` 46 | git clone https://github.com/mseclab/PyJFuzz.git && cd PyJFuzz && python setup.py install 47 | cd .. 48 | pip install -r requirements.txt 49 | ``` 50 | 51 | Then all dependencies should be met and run **tntfuzzer** with: 52 | 53 | ``` 54 | python tntfuzzer/tntfuzzer.py 55 | ``` 56 | 57 | ### For Development 58 | There are dependencies only needed for developing the fuzzer. These are listed 59 | in the requirements-dev.txt and can be installed via pip: 60 | 61 | ``` 62 | pip install -r requirements-dev.txt 63 | ``` 64 | 65 | #### Testing 66 | 67 | For testing or development, have a look at the [swagger petstore example](http://petstore.swagger.io/). A local stub 68 | server can easily be generated and run locally. 69 | 70 | Run software tests using the following command: 71 | 72 | ``` 73 | $ cd tntfuzzer 74 | $ nosetests tests/core/*.py tests/utils/*.py tests/*.py 75 | ........................ 76 | ---------------------------------------------------------------------- 77 | Ran 41 tests in 0.028s 78 | 79 | OK 80 | ``` 81 | 82 | ## Documentation 83 | 84 | ### Examples 85 | 86 | To get a better hang what can be done with tntfuzzer, print the usage infos: 87 | 88 | ``` 89 | tntfuzzer -h 90 | ``` 91 | 92 | ![](docs/images/usage.png) 93 | 94 | The most important parameter is the **--url**, with the URL to your OpenAPI specification json file. 95 | 96 | The parameter **--iterations** will specifiy how often an API call will be fuzzed. If 97 | the **--iterations** parameter is not specified, every API call is fuzzed only once. 98 | 99 | Per default only responses that are not documented in your Service's OpenAPI specification are logged. This way only 100 | undocumented errors are logged. If you want all fuzz responses to be logged, you have to specify that by 101 | setting the **--log_all** parameter. 102 | 103 | If you want to connect to servers using self-signed certificates, use the **--ignore-cert-errors**. 104 | 105 | Sometimes an OpenAPI file will contain an invalid host name, or point to the wrong server. If you use the **--host** 106 | option you can override this without making a local copy of the file. Same happens with **--basepath** that let you 107 | specify a different basepath for the API. 108 | 109 | So following example run will fuzz every API call specified in the swagger.json with 100 permutations each. All 110 | responses received from the server are logged: 111 | 112 | ``` 113 | tntfuzzer --url http://example.com:8080/v2/swagger.json --iterations 100 --log_all 114 | ``` 115 | 116 | ### Log 117 | 118 | When run, TnT-Fuzzer logs all responses in a table on commandline: 119 | 120 | | operation | url | response code | response message | response body | curl command | 121 | |---|---|---|---|---|---| 122 | | get | http://localhost:8080/v2/apicall | 200 | Successful Operation | {'success': true} | ```curl -XGET -H "Content-type: application/json" -d '{'foo': bar}' 'http://localhost:8080/v2/apicall'``` | 123 | -------------------------------------------------------------------------------- /docs/images/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/docs/images/logo.jpg -------------------------------------------------------------------------------- /docs/images/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/docs/images/usage.png -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pycodestyle 2 | nose==1.3.7 3 | coverage 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bottle==0.12.20 2 | certifi==2018.8.24 3 | chardet==3.0.4 4 | gitdb2==2.0.4 5 | GitPython==3.1.27 6 | gramfuzz==1.2.0 7 | idna==2.7 8 | PyJFuzz==1.1.0 9 | requests>=2.20.0 10 | smmap2==2.0.4 11 | termcolor==1.1.0 12 | urllib3>=1.24.2 13 | randomdict==0.2.0 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # read in version from tntfuzzer.py 4 | with open('tntfuzzer/tntfuzzer.py', 'r') as file: 5 | content = file.readlines() 6 | for line in content: 7 | if line.startswith('version'): 8 | version = line.split('\"')[1] 9 | break 10 | 11 | # read all dependencies and convert them to list 12 | with open('requirements.txt') as file: 13 | content = file.readlines() 14 | dependencies = [line.replace('\n', '') for line in content] 15 | 16 | setup( 17 | name='tntfuzzer', 18 | packages=find_packages(exclude=['tests.*']), 19 | version=version, 20 | description='An OpenAPI (Swagger) fuzzer written in python. Basically TnT for your API.', 21 | author='Tobias Hassenkloever', 22 | author_email='tnt@teebytes.net', 23 | url='https://github.com/Teebytes/TnT-Fuzzer', 24 | keywords=['openapi', 'swagger', 'fuzzer', 'fuzzing', 'json-api', 'REST'], 25 | entry_points={'console_scripts': ['tntfuzzer=tntfuzzer.tntfuzzer:main']}, 26 | install_requires=dependencies) 27 | -------------------------------------------------------------------------------- /tntfuzzer/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/tntfuzzer/core/__init__.py -------------------------------------------------------------------------------- /tntfuzzer/core/curlcommand.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | class CurlCommand: 5 | 6 | def __init__(self, url, method, data, headers, ignore_tls=False): 7 | self.url = url 8 | self.method = method 9 | self.data = data 10 | self.headers = headers 11 | 12 | if ignore_tls: 13 | self.ignore_tls = "-k " # short for --insecure 14 | else: 15 | self.ignore_tls = "" 16 | 17 | def get(self): 18 | if not self.data or len(self.data) < 1: 19 | curl_command = "curl " + self.ignore_tls + "-X" + self.method.upper() + " " + self.generate_headers() \ 20 | + " " + self.url 21 | else: 22 | curl_command = "curl " + self.ignore_tls + "-X" + self.method.upper() + " " + \ 23 | self.generate_headers() + " -d '" + self.data + "' " + self.url 24 | 25 | return curl_command 26 | 27 | def generate_headers(self): 28 | headers_string = " -H \"Content-type: application/json\"" 29 | 30 | if self.headers is not None: 31 | # self.headers is always a dict, because argparse has type=json.loads 32 | headers_string += " " 33 | for key, value in self.headers.items(): 34 | headers_string += "-H \"" + key + "\": \"" + value + "\"" 35 | headers_string += " " 36 | return headers_string.strip() 37 | -------------------------------------------------------------------------------- /tntfuzzer/core/httpoperation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | from argparse import Namespace 5 | from random import Random 6 | 7 | from pyjfuzz.core.pjf_configuration import PJFConfiguration 8 | from pyjfuzz.core.pjf_factory import PJFFactory 9 | from pyjfuzz.core.errors import PJFInvalidType 10 | 11 | 12 | class HttpOperation: 13 | def __init__(self, op_code, host_basepath, path, op_infos, headers, replicator, use_fuzzing=True, ignore_tls=False): 14 | self.op_code = op_code 15 | self.url = host_basepath.rstrip("/") + "/" + path.lstrip("/") 16 | self.op_infos = op_infos 17 | self.use_fuzzing = use_fuzzing 18 | self.replicator = replicator 19 | self.fuzzer = None 20 | self.request_body = None 21 | self.headers = headers 22 | self.ignore_tls = ignore_tls 23 | self.random = Random() 24 | 25 | def fuzz(self, json_str): 26 | if self.use_fuzzing is False: 27 | return json_str 28 | 29 | if self.fuzzer is None: 30 | try: 31 | config = PJFConfiguration(Namespace(json=json.loads(json_str), nologo=True, level=6)) 32 | self.fuzzer = PJFFactory(config) 33 | except PJFInvalidType: 34 | return json_str 35 | return self.fuzzer.fuzzed 36 | 37 | def execute(self): 38 | url = self.url 39 | form_data = dict() 40 | verify_tls = not self.ignore_tls # ignore_tls defaults to False, but verify=False will disable TLS verification 41 | 42 | if 'parameters' in self.op_infos: 43 | for parameter in self.op_infos['parameters']: 44 | # catch path parameters and replace them in url 45 | if 'path' == parameter['in']: 46 | if 'type' in parameter: 47 | parameter_type = parameter['type'] 48 | elif 'type' in parameter['schema']: 49 | parameter_type = parameter['schema']['type'] 50 | else: 51 | parameter_type = parameter['schema']['$ref'].split('/')[-1] 52 | url = self.replace_url_parameter(url, parameter['name'], parameter_type) 53 | 54 | if 'body' == parameter['in']: 55 | self.request_body = self.create_body(parameter) 56 | self.request_body = self.fuzz(self.request_body) 57 | 58 | if 'formData' == parameter['in'] or 'query' == parameter['in']: 59 | if 'type' in parameter: 60 | type_cls = parameter['type'] 61 | elif 'type' in parameter['schema']: 62 | type_cls = parameter['schema']['type'] 63 | else: 64 | type_cls = parameter['schema']['$ref'].split('/')[-1] 65 | if 'array' == type_cls: 66 | if 'items' in parameter and 'type' in parameter['items']: 67 | type_cls = parameter['items']['type'] 68 | if ('schema' in parameter and 'items' in parameter['schema'] and 69 | 'type' in parameter['schema']['items']): 70 | type_cls = parameter['schema']['items']['type'] 71 | else: 72 | type_cls = parameter['schema']['items']['$ref'].split('/')[-1] 73 | 74 | if 'required' in parameter and self.is_parameter_not_optional_but_randomize(parameter['required']): 75 | form_data[parameter['name']] = self.create_form_parameter(type_cls) 76 | 77 | if self.op_code == 'post': 78 | if bool(form_data): 79 | response = requests.post(url=url, data=form_data, json=None, headers=self.headers, verify=verify_tls) 80 | else: 81 | response = requests.post(url=url, data=None, json=self.request_body, headers=self.headers, 82 | verify=verify_tls) 83 | 84 | elif self.op_code == 'get': 85 | response = requests.get(url=url, params=form_data, headers=self.headers, verify=verify_tls) 86 | 87 | elif self.op_code == 'delete': 88 | response = requests.delete(url=url, headers=self.headers, verify=verify_tls) 89 | 90 | elif self.op_code == 'put': 91 | response = requests.put(url=url, data=form_data, headers=self.headers, verify=verify_tls) 92 | 93 | elif self.op_code == 'patch': 94 | response = requests.patch(url=url, data=form_data, headers=self.headers, verify=verify_tls) 95 | 96 | else: 97 | response = None 98 | 99 | return response 100 | 101 | def replace_url_parameter(self, url, name, object_type): 102 | if name is not None and object_type is not None: 103 | value = self.replicator.create_init_value(object_type) 104 | new_url = url.replace('{' + name + '}', str(value)) 105 | return new_url 106 | else: 107 | return url 108 | 109 | def create_form_parameter(self, object_type): 110 | value = self.replicator.create_init_value(object_type) 111 | return str(value) 112 | 113 | def is_parameter_not_optional_but_randomize(self, parameter_required): 114 | if parameter_required: 115 | return True 116 | 117 | if bool(self.random.getrandbits(1)): 118 | return True 119 | 120 | return False 121 | 122 | def create_body(self, parameter): 123 | result = '' 124 | if 'type' in parameter['schema']: 125 | if parameter['schema']['type'] == 'array': 126 | list_bodyitem = list() 127 | if '$ref' in parameter['schema']['items']: 128 | object_type = parameter['schema']['items']['$ref'] 129 | list_bodyitem.append(self.replicator.as_dict(object_type)) 130 | else: 131 | object_type = parameter['schema']['items']['type'] 132 | list_bodyitem.append(self.replicator.create_init_value(object_type)) 133 | result += json.dumps(list_bodyitem) 134 | else: 135 | object_type = parameter['schema']['type'] 136 | object_value = self.replicator.create_init_value(object_type) 137 | if object_type == 'string': 138 | object_value = '"' + object_value + '"' 139 | result += object_value 140 | else: 141 | object_type = parameter['schema']['$ref'] 142 | result += self.replicator.as_json(object_type) 143 | 144 | return result 145 | -------------------------------------------------------------------------------- /tntfuzzer/core/pattern.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from string import ascii_uppercase, ascii_lowercase, digits 3 | 4 | 5 | class MaxLengthException(Exception): 6 | pass 7 | 8 | 9 | class WasNotFoundException(Exception): 10 | pass 11 | 12 | 13 | class Pattern: 14 | MAX_LENGTH = 20280 15 | 16 | @staticmethod 17 | def gen(length): 18 | """ 19 | Generate a pattern of a given length up to a maximum 20 | of 20280 - after this the pattern would repeat 21 | """ 22 | if length >= Pattern.MAX_LENGTH: 23 | raise MaxLengthException('ERROR: Pattern length exceeds maximum of %d' % Pattern.MAX_LENGTH) 24 | 25 | pattern = '' 26 | for upper in ascii_uppercase: 27 | for lower in ascii_lowercase: 28 | for digit in digits: 29 | if len(pattern) < length: 30 | pattern += upper + lower + digit 31 | else: 32 | out = pattern[:length] 33 | return out 34 | 35 | @staticmethod 36 | def search(search_pattern): 37 | """ 38 | Search for search_pattern in pattern. Convert from hex if needed 39 | Looking for needle in haystack 40 | """ 41 | needle = search_pattern 42 | 43 | try: 44 | if needle.startswith('0x'): 45 | # Strip off '0x', convert to ASCII and reverse 46 | needle = needle[2:] 47 | needle = bytes.fromhex(needle).decode('ascii') 48 | needle = needle[::-1] 49 | except TypeError as e: 50 | print('Unable to convert hex input:', e) 51 | sys.exit(1) 52 | 53 | haystack = '' 54 | for upper in ascii_uppercase: 55 | for lower in ascii_lowercase: 56 | for digit in digits: 57 | haystack += upper + lower + digit 58 | found_at = haystack.find(needle) 59 | if found_at > -1: 60 | return found_at 61 | 62 | raise WasNotFoundException('Couldn`t find %s (%s) anywhere in the pattern.' % (search_pattern, needle)) 63 | 64 | 65 | def print_help(): 66 | print('Usage: %s LENGTH|PATTERN' % sys.argv[0]) 67 | print() 68 | print('Generate a pattern of length LENGTH or search for PATTERN and ') 69 | print('return its position in the pattern.') 70 | print() 71 | sys.exit(0) 72 | 73 | 74 | if __name__ == '__main__': 75 | if len(sys.argv) < 2: 76 | print_help() 77 | 78 | if sys.argv[1].isdigit(): 79 | pat = Pattern.gen(int(sys.argv[1])) 80 | print(pat) 81 | else: 82 | found = Pattern.search(sys.argv[1]) 83 | print('Pattern %s first occurrence at position %d in pattern.' % 84 | (sys.argv[1], found)) 85 | -------------------------------------------------------------------------------- /tntfuzzer/core/replicator.py: -------------------------------------------------------------------------------- 1 | import json 2 | import string 3 | import sys 4 | from random import Random 5 | from randomdict import RandomDict 6 | 7 | from core.pattern import Pattern 8 | 9 | 10 | class ReplicationException(Exception): 11 | pass 12 | 13 | 14 | class Replicator: 15 | """Replicates objects for requests in json format from definitions""" 16 | 17 | def __init__(self, definitions, use_string_pattern, init_rand_values, max_string_len=200): 18 | """Creates a Replicator for given type 19 | 20 | :parameter definitions all object definitions that could be referenced in the type that should be replicated 21 | :parameter use_string_pattern if valid, generated patterns instead of random character strings 22 | :parameter init_rand_values indicates if the replicated values should be random or - as per default - be 0 or '' 23 | :parameter max_string_len maximum length of strings, which are generated 24 | """ 25 | self.definitions = definitions 26 | self.init_rand_values = init_rand_values 27 | self.use_string_pattern = use_string_pattern 28 | self.max_string_len = max_string_len 29 | self.random = Random() 30 | self.randomdict = RandomDict() 31 | 32 | def create_init_value(self, object_type): 33 | if object_type == 'integer': 34 | if self.init_rand_values: 35 | return self.random.randint(0, sys.maxsize) 36 | else: 37 | return 0 38 | if object_type == 'string': 39 | if self.init_rand_values: 40 | if self.use_string_pattern: 41 | return Pattern.gen(self.random.randint(0, self.max_string_len)) 42 | else: 43 | return self.randomword(self.random.randint(0, self.max_string_len)) 44 | else: 45 | return '' 46 | if object_type == 'number': 47 | if self.init_rand_values: 48 | return self.random.uniform(0, sys.maxsize) 49 | else: 50 | return 0 51 | if object_type == 'boolean': 52 | if self.init_rand_values: 53 | return bool(self.random.getrandbits(1)) 54 | else: 55 | return False 56 | if object_type == 'object': 57 | # FIXME: get props from object and rerun with data types 58 | return self.randomdict.__dict__ 59 | if object_type == 'file': 60 | return '/file/to/../something' # TODO: react to init_rand_values 61 | return self.replicate(object_type) 62 | 63 | def replicate(self, object_type): 64 | object_class = object_type.replace('#/definitions/', '') 65 | 66 | if not self.definition_contains_object_class(object_class): 67 | raise ReplicationException('Object type "' + object_class + 68 | '" (from Swagger JSON) not found in given definitions.') 69 | 70 | object_schema = self.definitions[object_class] 71 | object_instance = {} 72 | if 'enum' in object_schema: 73 | return self.random.choice(object_schema['enum']) 74 | for prop in object_schema['properties']: 75 | if 'type' in object_schema['properties'][prop]: 76 | prop_type = object_schema['properties'][prop]['type'] 77 | else: 78 | prop_type = object_schema['properties'][prop]['$ref'] 79 | 80 | if prop_type == 'array': 81 | if '$ref' in object_schema['properties'][prop]['items']: 82 | array_type = object_schema['properties'][prop]['items']['$ref'] 83 | else: 84 | array_type = object_schema['properties'][prop]['items']['type'] 85 | object_instance[prop] = list() 86 | object_instance[prop].append(self.create_init_value(array_type)) 87 | 88 | else: 89 | object_instance[prop] = self.create_init_value(prop_type) 90 | 91 | return object_instance 92 | 93 | def definition_contains_object_class(self, object_class): 94 | if object_class in self.definitions: 95 | return True 96 | else: 97 | return False 98 | 99 | def randomword(self, length): 100 | letters = string.ascii_lowercase 101 | return ''.join(self.random.choice(letters) for i in range(length)) 102 | 103 | def as_dict(self, object_type): 104 | return self.replicate(object_type) 105 | 106 | def as_json(self, object_type): 107 | return json.dumps(self.as_dict(object_type)) 108 | -------------------------------------------------------------------------------- /tntfuzzer/core/resultvalidatior.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ResultValidator: 4 | def __init__(self): 5 | pass 6 | 7 | def evaluate(self, response, response_infos, log_unexpected_errors_only): 8 | result_log = dict() 9 | 10 | result_log['status_code'] = response.status_code 11 | result_log['body'] = response.text 12 | result_log['documented_reason'] = None 13 | 14 | # response is documented in open api for operation 15 | if str(response.status_code) in response_infos: 16 | result_log['documented_reason'] = response_infos[str(response.status_code)]['description'] 17 | 18 | return result_log 19 | -------------------------------------------------------------------------------- /tntfuzzer/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/tntfuzzer/tests/__init__.py -------------------------------------------------------------------------------- /tntfuzzer/tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/tntfuzzer/tests/core/__init__.py -------------------------------------------------------------------------------- /tntfuzzer/tests/core/curlcommandtest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import TestCase 3 | 4 | from core.curlcommand import CurlCommand 5 | 6 | 7 | class CurlCommandTest(TestCase): 8 | 9 | def test_get_method(self): 10 | method = "get" 11 | url = "http://example.com/api/v2/test" 12 | data = "{\"id\": 1, \"name\": \"Foo\"}" 13 | headers = json.loads('{}') 14 | curlcommand = CurlCommand(url, method, data, headers) 15 | 16 | self.assertEqual(curlcommand.get(), "curl -XGET -H \"Content-type: application/json\" -d " 17 | "'{\"id\": 1, \"name\": \"Foo\"}' http://example.com/api/v2/test") 18 | 19 | def test_post_method(self): 20 | method = "pOsT" 21 | url = "http://example.com/api/post" 22 | data = "{\"id\": 2, \"name\": \"Bar\"}" 23 | headers = json.loads('{}') 24 | curlcommand = CurlCommand(url, method, data, headers) 25 | 26 | self.assertEqual(curlcommand.get(), "curl -XPOST -H \"Content-type: application/json\" -d " 27 | "'{\"id\": 2, \"name\": \"Bar\"}' http://example.com/api/post") 28 | 29 | def test_empty_data(self): 30 | method = "get" 31 | url = "http://example.com/api/v2/list" 32 | data = "" 33 | headers = json.loads('{}') 34 | curlcommand = CurlCommand(url, method, data, headers) 35 | self.assertEqual(curlcommand.get(), "curl -XGET -H \"Content-type: application/json\" " 36 | "http://example.com/api/v2/list") 37 | 38 | def test_generate_headers(self): 39 | method = "get" 40 | url = "http://example.com/api/v2/list" 41 | data = "" 42 | headers = json.loads('{ \"X-API-Key\": \"abcdef12345\", \"user-agent\": \"tntfuzzer\" }') 43 | expected_result = u'-H \"Content-type: application/json\" -H \"X-API-Key\": \"abcdef12345\" ' \ 44 | u'-H \"user-agent\": \"tntfuzzer\"' 45 | curlcommand = CurlCommand(url, method, data, headers) 46 | self.assertEqual(curlcommand.generate_headers(), expected_result) 47 | 48 | def test_generate_headers_returns_contenttype_only_when_headers_nonetype(self): 49 | method = "get" 50 | url = "http://example.com/api/v2/list" 51 | data = "" 52 | expected_result = u'-H \"Content-type: application/json\"' 53 | 54 | curlcommand = CurlCommand(url, method, data, None) 55 | 56 | self.assertEqual(curlcommand.generate_headers(), expected_result) 57 | -------------------------------------------------------------------------------- /tntfuzzer/tests/core/httpoperationtest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from unittest.mock import patch, MagicMock 3 | 4 | from core.httpoperation import HttpOperation 5 | from core.replicator import Replicator 6 | from tests.core.replicatortest import ReplicatorTest 7 | 8 | 9 | def mock_request_get(url, params=None, headers=None, verify=False): 10 | pass 11 | 12 | 13 | def mock_request_post(url, data=None, json=None, headers=None, verify=False): 14 | pass 15 | 16 | 17 | def mock_request_delete(url, headers=None, verify=False): 18 | pass 19 | 20 | 21 | def mock_request_put(url, data=None, headers=None, verify=False): 22 | pass 23 | 24 | 25 | def random_true(obj, bit): 26 | return True 27 | 28 | 29 | def random_false(obj, bit): 30 | return False 31 | 32 | 33 | def create_http_op_with_random_mock(op_code, host_basepath, path, op_infos, headers, ignore_tls=True): 34 | replicator = Replicator(ReplicatorTest.SAMPLE_DEFINITION, True, False) 35 | http_op = HttpOperation(op_code, host_basepath, path, 36 | op_infos, headers, replicator, False, ignore_tls) 37 | http_op.random.getrandbits = MagicMock(return_value=True) 38 | return http_op 39 | 40 | 41 | class HttpOperationTest(TestCase): 42 | SAMPLE_OP_INFOS = { 43 | "tags": [ 44 | "pet" 45 | ], 46 | "summary": "Updates a pet in the store with form data", 47 | "description": "", 48 | "operationId": "updatePetWithForm", 49 | "consumes": [ 50 | "application/x-www-form-urlencoded" 51 | ], 52 | "produces": [ 53 | "application/xml", 54 | "application/json" 55 | ], 56 | "parameters": [ 57 | { 58 | "name": "petId", 59 | "in": "path", 60 | "description": "ID of pet that needs to be updated", 61 | "required": True, 62 | "type": "integer", 63 | "format": "int64" 64 | }, 65 | { 66 | "name": "name", 67 | "in": "formData", 68 | "description": "Updated name of the pet", 69 | "required": False, 70 | "type": "string" 71 | }, 72 | { 73 | "name": "status", 74 | "in": "formData", 75 | "description": "Updated status of the pet", 76 | "required": False, 77 | "type": "string" 78 | } 79 | ], 80 | "responses": { 81 | "405": { 82 | "description": "Invalid input" 83 | } 84 | }, 85 | "security": [ 86 | { 87 | "petstore_auth": [ 88 | "write:pets", 89 | "read:pets" 90 | ] 91 | } 92 | ] 93 | } 94 | 95 | def setUp(self): 96 | self.http_op = create_http_op_with_random_mock('post', 'https://server.de/', 'pet/{petId}/uploadImage', 97 | self.SAMPLE_OP_INFOS, {"X-API-Key": "abcdef123"}) 98 | 99 | def test_replace_url_parameter_replaces_placeholder_in_url_with_type_value(self): 100 | url = self.http_op.replace_url_parameter(self.http_op.url, 'petId', 'integer') 101 | self.assertEqual(url, 'https://server.de/pet/0/uploadImage') 102 | 103 | def test_replace_url_parameter_replaces_only_named_param(self): 104 | url = self.http_op.replace_url_parameter('https://server.de/pet/{petId}/uploadImage/{imgName}', 105 | 'imgName', 'string') 106 | self.assertEqual(url, 'https://server.de/pet/{petId}/uploadImage/') 107 | 108 | def test_create_form_parameter_makes_instance_of_type_as_string(self): 109 | value = self.http_op.create_form_parameter('integer') 110 | self.assertEqual(value, '0') 111 | 112 | def test_is_parameter_not_optional_but_randomize_returns_true_when_param_not_optional(self): 113 | result = self.http_op.is_parameter_not_optional_but_randomize(parameter_required=True) 114 | self.assertEqual(True, result) 115 | 116 | def test_is_parameter_not_optional_but_randomize_returns_true_when_param_optional_and_random_true(self): 117 | self.http_op.random.getrandbits = MagicMock(return_value=True) 118 | result = self.http_op.is_parameter_not_optional_but_randomize(parameter_required=False) 119 | self.assertEqual(True, result) 120 | 121 | def test_is_parameter_not_optional_but_randomize_returns_false_when_param_optional_and_random_false(self): 122 | self.http_op.random.getrandbits = MagicMock(return_value=False) 123 | result = self.http_op.is_parameter_not_optional_but_randomize(parameter_required=False) 124 | self.assertEqual(False, result) 125 | 126 | def test_execute_with_unrecognizable_http_op_will_result_in_Nonetype_response(self): 127 | self.http_op = create_http_op_with_random_mock('OGRE', 'https://server.de/', 'pet/{petId}/uploadImage', 128 | self.SAMPLE_OP_INFOS, {"X-API-Key": "abcdef123"}) 129 | result = self.http_op.execute() 130 | self.assertIsNone(result) 131 | 132 | @patch('requests.get', side_effect=mock_request_get) 133 | def test_execute_with_parameter_definition_will_send_request_without_parameters_set(self, mock_get): 134 | definition_no_parameters = self.SAMPLE_OP_INFOS 135 | definition_no_parameters.pop('parameters', 0) 136 | self.http_op = create_http_op_with_random_mock('get', 'https://server.de/', 'pet/{petId}/uploadImage', 137 | definition_no_parameters, {"X-API-Key": "abcdef123"}) 138 | self.http_op.execute() 139 | self.assertIn(mock.call(params={}, headers={"X-API-Key": "abcdef123"}, 140 | url='https://server.de/pet/{petId}/uploadImage', verify=False), mock_get.call_args_list) 141 | 142 | @patch('requests.post', side_effect=mock_request_post) 143 | def test_execute_will_post__op_request_with_params_when_form_data_param_set(self, mock_post): 144 | self.http_op.execute() 145 | self.assertIn(mock.call(data={'status': '', 'name': ''}, json=None, headers={"X-API-Key": "abcdef123"}, 146 | url='https://server.de/pet/0/uploadImage', verify=False), mock_post.call_args_list) 147 | 148 | @patch('requests.get', side_effect=mock_request_get) 149 | def test_execute_will_get_op_request_with_url_and_params_when_form_data_param_set(self, mock_get): 150 | self.http_op = create_http_op_with_random_mock('get', 'https://server.de/', 'pet/{petId}/uploadImage', 151 | self.SAMPLE_OP_INFOS, {"X-API-Key": "abcdef123"}) 152 | self.http_op.execute() 153 | self.assertIn(mock.call(params={'status': '', 'name': ''}, 154 | url='https://server.de/pet/0/uploadImage', 155 | headers={"X-API-Key": "abcdef123"}, verify=False), 156 | mock_get.call_args_list) 157 | 158 | @patch('requests.delete', side_effect=mock_request_delete) 159 | def test_execute_will_delete_op_request_with_url_only(self, mock_delete): 160 | self.http_op = create_http_op_with_random_mock('delete', 'https://server.de/', 'pet/{petId}/uploadImage', 161 | self.SAMPLE_OP_INFOS, {"X-API-Key": "abcdef123"}) 162 | self.http_op.execute() 163 | self.assertIn(mock.call(url='https://server.de/pet/0/uploadImage', 164 | headers={"X-API-Key": "abcdef123"}, verify=False), 165 | mock_delete.call_args_list) 166 | 167 | @patch('requests.put', side_effect=mock_request_put) 168 | def test_execute_will_put_op_request_with_url_and_params_when_form_data_param_set(self, mock_put): 169 | self.http_op = create_http_op_with_random_mock('put', 'https://server.de/', 'pet/{petId}/uploadImage', 170 | self.SAMPLE_OP_INFOS, {"X-API-Key": "abcdef123"}) 171 | self.http_op.execute() 172 | self.assertIn(mock.call(data={'status': '', 'name': ''}, headers={"X-API-Key": "abcdef123"}, 173 | url='https://server.de/pet/0/uploadImage', verify=False), mock_put.call_args_list) 174 | -------------------------------------------------------------------------------- /tntfuzzer/tests/core/patterntest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from core.pattern import Pattern, MaxLengthException, WasNotFoundException 4 | 5 | 6 | class PatternTest(TestCase): 7 | 8 | def test_gen_returns_pattern_with_demanded_length(self): 9 | result = Pattern.gen(50) 10 | 11 | self.assertEqual(50, len(result)) 12 | 13 | def test_gen_always_returns_same_pattern_with_same_param_length(self): 14 | result = Pattern.gen(50) 15 | 16 | self.assertEqual('Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab', result) 17 | 18 | def test_gen_throws_exception_when_param_length_exceeds_limit(self): 19 | self.assertRaises(MaxLengthException, Pattern.gen, Pattern.MAX_LENGTH + 50) 20 | 21 | def test_search_finds_pattern_position(self): 22 | result = Pattern.search('1Ab2Ab3Ab4') 23 | 24 | self.assertEqual(35, result) 25 | 26 | def test_search_throws_exception_when_pattern_not_found(self): 27 | self.assertRaises(WasNotFoundException, Pattern.search, "IamNotInDaPattern") 28 | -------------------------------------------------------------------------------- /tntfuzzer/tests/core/replicatortest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from core.replicator import Replicator, ReplicationException 4 | 5 | 6 | class ReplicatorTest(TestCase): 7 | SAMPLE_DEFINITION = { 8 | "Order": { 9 | "type": "object", 10 | "properties": { 11 | "id": { 12 | "type": "integer", 13 | "format": "int64" 14 | }, 15 | "petId": { 16 | "type": "integer", 17 | "format": "int64" 18 | }, 19 | "quantity": { 20 | "type": "integer", 21 | "format": "int32" 22 | }, 23 | "shipDate": { 24 | "type": "string", 25 | "format": "date-time" 26 | }, 27 | "status": { 28 | "type": "string", 29 | "description": "Order Status", 30 | "enum": [ 31 | "placed", 32 | "approved", 33 | "delivered" 34 | ] 35 | }, 36 | "complete": { 37 | "type": "boolean", 38 | "default": False 39 | } 40 | }, 41 | "xml": { 42 | "name": "Order" 43 | } 44 | }, 45 | "Category": { 46 | "type": "object", 47 | "properties": { 48 | "id": { 49 | "type": "integer", 50 | "format": "int64" 51 | }, 52 | "name": { 53 | "type": "string" 54 | } 55 | }, 56 | "xml": { 57 | "name": "Category" 58 | } 59 | }, 60 | "User": { 61 | "type": "object", 62 | "properties": { 63 | "id": { 64 | "type": "integer", 65 | "format": "int64" 66 | }, 67 | "username": { 68 | "type": "string" 69 | }, 70 | "firstName": { 71 | "type": "string" 72 | }, 73 | "lastName": { 74 | "type": "string" 75 | }, 76 | "email": { 77 | "type": "string" 78 | }, 79 | "password": { 80 | "type": "string" 81 | }, 82 | "phone": { 83 | "type": "string" 84 | }, 85 | "userStatus": { 86 | "type": "integer", 87 | "format": "int32", 88 | "description": "User Status" 89 | } 90 | }, 91 | "xml": { 92 | "name": "User" 93 | } 94 | }, 95 | "Tag": { 96 | "type": "object", 97 | "properties": { 98 | "id": { 99 | "type": "integer", 100 | "format": "int64" 101 | }, 102 | "name": { 103 | "type": "string" 104 | } 105 | }, 106 | "xml": { 107 | "name": "Tag" 108 | } 109 | }, 110 | "Pet": { 111 | "type": "object", 112 | "required": [ 113 | "name", 114 | "photoUrls" 115 | ], 116 | "properties": { 117 | "id": { 118 | "type": "integer", 119 | "format": "int64" 120 | }, 121 | "category": { 122 | "$ref": "#/definitions/Category" 123 | }, 124 | "name": { 125 | "type": "string", 126 | "example": "doggie" 127 | }, 128 | "photoUrls": { 129 | "type": "array", 130 | "xml": { 131 | "name": "photoUrl", 132 | "wrapped": True 133 | }, 134 | "items": { 135 | "type": "string" 136 | } 137 | }, 138 | "tags": { 139 | "type": "array", 140 | "xml": { 141 | "name": "tag", 142 | "wrapped": True 143 | }, 144 | "items": { 145 | "$ref": "#/definitions/Tag" 146 | } 147 | }, 148 | "status": { 149 | "type": "string", 150 | "description": "pet status in the store", 151 | "enum": [ 152 | "available", 153 | "pending", 154 | "sold" 155 | ] 156 | } 157 | }, 158 | "xml": { 159 | "name": "Pet" 160 | } 161 | }, 162 | "ApiResponse": { 163 | "type": "object", 164 | "properties": { 165 | "code": { 166 | "type": "integer", 167 | "format": "int32" 168 | }, 169 | "type": { 170 | "type": "string" 171 | }, 172 | "message": { 173 | "type": "string" 174 | } 175 | } 176 | }, 177 | "FailObject": { 178 | "type": "object", 179 | "properties": { 180 | "exists": { 181 | "$ref": "#/definitions/ApiResponse" 182 | }, 183 | "notexists": { 184 | "$ref": "#/definitions/DoesNotExist" 185 | } 186 | } 187 | } 188 | } 189 | 190 | def setUp(self): 191 | self.replicator = Replicator(definitions=self.SAMPLE_DEFINITION, 192 | use_string_pattern=True, 193 | init_rand_values=False) 194 | 195 | def test_as_dict_creates_object_from_definition_and_has_all_fields(self): 196 | self.replicator = Replicator(definitions=self.SAMPLE_DEFINITION, 197 | use_string_pattern=True, 198 | init_rand_values=False) 199 | result = self.replicator.as_dict('#/definitions/ApiResponse') 200 | self.assertIsNotNone(result['code']) 201 | self.assertEqual(str(type(result['code'])), "") 202 | self.assertIsNotNone(result['type']) 203 | self.assertEqual(str(type(result['type'])), "") 204 | self.assertIsNotNone(result['message']) 205 | self.assertEqual(str(type(result['message'])), "") 206 | 207 | def test_as_dict_creates_nested_object_from_definition_and_has_all_fields(self): 208 | self.replicator = Replicator(definitions=self.SAMPLE_DEFINITION, 209 | use_string_pattern=True, 210 | init_rand_values=False) 211 | result = self.replicator.as_dict('#/definitions/Pet') 212 | self.assertIsNotNone(result['id']) 213 | self.assertIsNotNone(result['name']) 214 | self.assertIsNotNone(result['photoUrls']) 215 | self.assertEqual(str(type(result['photoUrls'])), "") 216 | 217 | # nested array of objects 218 | self.assertIsNotNone(result['tags']) 219 | self.assertIsNotNone(result['tags'][0]['name']) 220 | 221 | # nested object 222 | self.assertIsNotNone(result['category']) 223 | self.assertEqual(str(type(result['category']['id'])), "") 224 | self.assertEqual(str(type(result['category']['name'])), "") 225 | 226 | def test_as_dict_create_nested_object_not_in_definition_results_in_error(self): 227 | self.replicator = Replicator(definitions=self.SAMPLE_DEFINITION, 228 | use_string_pattern=True, 229 | init_rand_values=False) 230 | self.assertRaises(ReplicationException, self.replicator.as_dict, '#/definitions/FailObject') 231 | 232 | def test_as_json_returns_created_object_as_json(self): 233 | self.assertEqual(self.replicator.as_json('#/definitions/Tag'), '{"id": 0, "name": ""}') 234 | -------------------------------------------------------------------------------- /tntfuzzer/tests/core/resultvalidatortest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from unittest.mock import MagicMock 4 | 5 | from core.resultvalidatior import ResultValidator 6 | 7 | 8 | class ResultValidatorTest(TestCase): 9 | SAMPLE_RESPONSES = { 10 | "405": { 11 | "description": "Invalid input" 12 | } 13 | } 14 | 15 | def setUp(self): 16 | self.validator = ResultValidator() 17 | self.response = MagicMock() 18 | self.response.status_code = 405 19 | self.response.documented_reason = 'doc reason' 20 | self.response.text = 'Error text from server' 21 | 22 | def test_evaluate_returns_none_when_resp_status_code_in_expected_responses(self): 23 | result = self.validator.evaluate(self.response, self.SAMPLE_RESPONSES, True) 24 | self.assertEqual(result, {'body': 'Error text from server', 'status_code': 405, 25 | 'documented_reason': 'Invalid input'}) 26 | 27 | def test_evaluate_returns_result_log_when_resp_status_code_not_expected_responses_and_logging_not_forced(self): 28 | self.response.status_code = 500 29 | self.response.text = 'Internal Error' 30 | result = self.validator.evaluate(self.response, self.SAMPLE_RESPONSES, True) 31 | self.assertEqual(result, {"status_code": 500, "body": 'Internal Error', "documented_reason": None}) 32 | 33 | def test_evaluate_returns_log_when_resp_status_code_in_expected_responses_and_forced_for_all_codes(self): 34 | result = self.validator.evaluate(self.response, self.SAMPLE_RESPONSES, False) 35 | self.assertEqual(result, {"status_code": 405, 36 | "body": 'Error text from server', 37 | "documented_reason": 'Invalid input'}) 38 | 39 | def test_evaluate_returns_log_when_resp_status_code_not_in_expected_responses_and_forced_for_all_codes(self): 40 | self.response.status_code = 500 41 | self.response.text = 'Internal Error' 42 | result = self.validator.evaluate(self.response, self.SAMPLE_RESPONSES, False) 43 | self.assertEqual(result, {"status_code": 500, "body": 'Internal Error', "documented_reason": None}) 44 | -------------------------------------------------------------------------------- /tntfuzzer/tests/tntfuzzertest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from tntfuzzer import TntFuzzer, SchemaException 4 | 5 | 6 | def mock_exit(value): 7 | pass 8 | 9 | 10 | def mock_get_swagger_spec(url): 11 | return {} 12 | 13 | 14 | def mock_get_swagger_spec_version_1(url): 15 | return {'swagger': '1.0'} 16 | 17 | 18 | def mock_get_swagger_spec_version_2(url): 19 | return {'swagger': '2.0', 'basePath': '', 'paths': {}, 'definitions': {}} 20 | 21 | 22 | def mock_get_swagger_spec_version_3(url): 23 | return {'swagger': '3.0'} 24 | 25 | 26 | class TntFuzzerTest(TestCase): 27 | 28 | def test_tntfuzzer_throws_exception_when_version_not_stated_in_schema(self): 29 | fuzzer = TntFuzzer('http://notexistant/swagger.json', 1, None, True, 20, True) 30 | fuzzer.get_swagger_spec = mock_get_swagger_spec 31 | 32 | self.assertRaises(SchemaException, fuzzer.start) 33 | 34 | def test_tntfuzzer_throws_exception_when_swagger_version_1_used(self): 35 | fuzzer = TntFuzzer('http://notexistant/swagger.json', 1, None, True, 20, True) 36 | fuzzer.get_swagger_spec = mock_get_swagger_spec_version_1 37 | 38 | self.assertRaises(SchemaException, fuzzer.start) 39 | 40 | def test_tntfuzzer_throws_exception_when_swagger_version_3_used(self): 41 | fuzzer = TntFuzzer('http://notexistant/swagger.json', 1, None, True, 20, True) 42 | fuzzer.get_swagger_spec = mock_get_swagger_spec_version_3 43 | 44 | self.assertRaises(SchemaException, fuzzer.start) 45 | 46 | def test_tntfuzzer_starts_fuzzing_when_version_2_used(self): 47 | fuzzer = TntFuzzer('http://notexistant/swagger.json', 1, None, True, 20, True) 48 | fuzzer.get_swagger_spec = mock_get_swagger_spec_version_2 49 | 50 | self.assertTrue(fuzzer.start()) 51 | -------------------------------------------------------------------------------- /tntfuzzer/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/tntfuzzer/tests/utils/__init__.py -------------------------------------------------------------------------------- /tntfuzzer/tests/utils/strutilstest.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from utils.strutils import StrUtils 4 | 5 | 6 | class StrUtilsTest(TestCase): 7 | 8 | def test_fill_string_up_with_blanks_appends_number_blanks_to_string_smaller_than_max(self): 9 | result = StrUtils.fill_string_up_with_blanks('this is test', 15) 10 | self.assertEqual('this is test ', result) 11 | 12 | def test_fill_string_up_with_blanks_wont_append_blanks_to_string_bigger_than_max(self): 13 | result = StrUtils.fill_string_up_with_blanks('this is test', 8) 14 | self.assertEqual('this is test', result) 15 | 16 | def test_fill_string_up_with_blanks_wont_append_blanks_to_string_bigger_exact_max(self): 17 | result = StrUtils.fill_string_up_with_blanks('this is test', 12) 18 | self.assertEqual('this is test', result) 19 | -------------------------------------------------------------------------------- /tntfuzzer/tntfuzzer.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import requests 4 | import termcolor 5 | import urllib3 6 | import sys 7 | 8 | from core.curlcommand import CurlCommand 9 | from core.httpoperation import HttpOperation 10 | from core.resultvalidatior import ResultValidator 11 | from core.replicator import Replicator 12 | from utils.strutils import StrUtils 13 | from urllib.parse import urlparse 14 | 15 | version = "2.3.1" 16 | 17 | 18 | class SchemaException(Exception): 19 | pass 20 | 21 | 22 | class TntFuzzer: 23 | 24 | def __init__(self, url, iterations, headers, log_unexpected_errors_only, 25 | max_string_length, use_string_pattern, ignore_tls=False, 26 | host=None, basepath=None, ignored_paths=[]): 27 | self.url = url 28 | self.iterations = iterations 29 | self.headers = headers 30 | self.log_unexpected_errors_only = log_unexpected_errors_only 31 | self.max_string_length = max_string_length 32 | self.use_string_pattern = use_string_pattern 33 | self.ignore_tls = ignore_tls 34 | self.host = host 35 | self.basepath = basepath 36 | self.ignored_paths = ignored_paths 37 | 38 | if self.ignore_tls: 39 | # removes warnings for insecure connections 40 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 41 | 42 | def start(self): 43 | print('Fetching open API from: ' + self.url) 44 | 45 | # Try to find the protocol, host and basePath from the Swagger spec. 46 | # host and schemes can be omitted, and the "standard" says you should use the spec's URL to derive them. 47 | # https://swagger.io/docs/specification/2-0/api-host-and-base-path/ 48 | schemes = [] 49 | host = None 50 | basePath = None 51 | 52 | try: 53 | spec = self.get_swagger_spec(self.url) 54 | specURL = urlparse(self.url) 55 | except json.JSONDecodeError: 56 | error_cant_connect() 57 | 58 | if 'openapi' not in spec: 59 | if 'swagger' not in spec: 60 | self.log_operation(None, self.url, 61 | { 62 | "status_code": "000", 63 | "documented_reason": "Specification: no version string found", 64 | "body": "Specification: no version string found in Swagger spec" 65 | }, '') 66 | raise SchemaException 67 | 68 | if not spec['swagger'].startswith('2'): 69 | self.log_operation(None, self.url, 70 | { 71 | "status_code": "000", 72 | "documented_reason": "Specification: wrong specification version", 73 | "body": "Specification: version in swagger spec not supported" 74 | }, '') 75 | raise SchemaException 76 | elif not spec['openapi'].startswith('3'): 77 | self.log_operation(None, self.url, 78 | { 79 | "status_code": "000", 80 | "documented_reason": "Specification: wrong specification version", 81 | "body": "Specification: version in openapi spec not supported" 82 | }, '') 83 | raise SchemaException 84 | baseUris = [] 85 | if 'servers' in spec and len(spec['servers']) > 0: 86 | for server in spec['servers']: 87 | baseUris.append(server['url']) 88 | elif 'schemes' in spec: 89 | schemes = spec['schemes'] 90 | else: 91 | # fake the array we'd find in the spec 92 | schemes.append(specURL.scheme) 93 | self.log_operation(None, self.url, 94 | { 95 | "status_code": "000", 96 | "documented_reason": "Specification: no schemes entry, fallback to spec URL scheme", 97 | "body": "Specification: host entry not present in Swagger spec"}, '') 98 | if len(baseUris) == 0: 99 | if self.host: 100 | host = self.host 101 | elif 'host' in spec: 102 | host = spec['host'] 103 | else: 104 | host = specURL.netloc 105 | self.log_operation(None, self.url, 106 | { 107 | "status_code": "000", 108 | "documented_reason": "Specification: no host entry, fallback to spec URL host", 109 | "body": "Specification: schemes entry not present in Swagger spec" 110 | }, '') 111 | 112 | # There is no nice way to derive the basePath from the spec's URL. They *have* to include it 113 | if 'basePath' not in spec: 114 | self.log_operation(None, self.url, 115 | { 116 | "status_code": "000", 117 | "documented_reason": "Specification: basePath entry missing from Swagger spec", 118 | "body": "Specification Error: basePath entry missing from Swagger spec" 119 | }, '') 120 | raise SchemaException 121 | basePath = self.basepath if self.basepath else spec['basePath'] 122 | host_basepath = host + basePath 123 | for protocol in schemes: 124 | baseUris.append(protocol + '://' + host_basepath) 125 | 126 | paths = spec['paths'] 127 | if 'definitions' in spec: 128 | type_definitions = spec['definitions'] 129 | elif 'components' in spec and 'schemas' in spec['components']: 130 | type_definitions = spec['components']['schemas'] 131 | else: 132 | self.log_operation(None, self.url, 133 | { 134 | "status_code": "000", 135 | "documented_reason": "Specification: type definitions missing from Swagger spec", 136 | "body": "Specification Error: type definitions missing from Swagger spec" 137 | }, '') 138 | raise SchemaException 139 | # the specifcation can list multiple schemes (http, https, ws, wss) - all should be tested. 140 | # Each scheme is a potentially different end point 141 | replicator = Replicator(type_definitions, self.use_string_pattern, True, self.max_string_length) 142 | for baseUri in baseUris: 143 | for path_key in paths.keys(): 144 | if path_key in self.ignored_paths: 145 | continue 146 | path = paths[path_key] 147 | 148 | for op_code in path.keys(): 149 | operation = HttpOperation(op_code, baseUri, path_key, 150 | replicator=replicator, op_infos=path[op_code], use_fuzzing=True, 151 | headers=self.headers, ignore_tls=self.ignore_tls) 152 | 153 | for _ in range(self.iterations): 154 | response = operation.execute() 155 | validator = ResultValidator() 156 | log = validator.evaluate(response, path[op_code]['responses'], self.log_unexpected_errors_only) 157 | curlcommand = CurlCommand(response.url, operation.op_code, operation.request_body, self.headers, 158 | self.ignore_tls) 159 | 160 | # log to screen for now 161 | self.log_operation(operation.op_code, response.url, log, curlcommand) 162 | 163 | return True 164 | 165 | def log_operation(self, op_code, url, log, curlcommand): 166 | status_code = str(log['status_code']) 167 | documented_reason = log['documented_reason'] 168 | body = log['body'].replace('\n', ' ') 169 | 170 | if documented_reason is None: 171 | StrUtils.print_log_row(op_code, url, status_code, 'None', body, curlcommand) 172 | else: 173 | if not self.log_unexpected_errors_only: 174 | StrUtils.print_log_row(op_code, url, status_code, documented_reason, body, curlcommand) 175 | 176 | def get_swagger_spec(self, url): 177 | verify_tls = not self.ignore_tls # ignore_tls defaults to False, but verify=False will disable TLS verification 178 | return json.loads(requests.get(url=url, headers=self.headers, verify=verify_tls).text) 179 | 180 | 181 | def error_cant_connect(): 182 | print('Unable to get swagger file :-(') 183 | sys.exit(1) 184 | 185 | 186 | def main(): 187 | print(termcolor.colored(r'___________ ___________ ___________', color='red')) 188 | print(termcolor.colored(r'\__ ___/__\__ ___/ \_ _____/_ __________________ ___________ ', color='red')) 189 | print(termcolor.colored(r' | | / \| | ______ | __)| | \___ /\___ // __ \_ __ \\', color='red')) 190 | print(termcolor.colored(r' | || | \ | /_____/ | \ | | // / / /\ ___/| | \/', color='red')) 191 | print(termcolor.colored(r' |____||___| /____| \___ / |____//_____ \/_____ \\\\___ >__| ', 192 | color='red')) 193 | print(termcolor.colored(r' Dynamite ', 'green') + termcolor.colored(r'\/', 'red') + 194 | termcolor.colored(r' for your API!', 'green') + 195 | termcolor.colored(r' \/ \/ \/ \/ ', 'red') + 196 | termcolor.colored(r' v', 'green') + termcolor.colored(version, 'blue')) 197 | print('') 198 | 199 | parser = argparse.ArgumentParser() 200 | 201 | parser.add_argument('--url', type=str, 202 | help='The URL pointing to your OpenAPI implementation e.g. ' 203 | 'http://petstore.swagger.io/v2/swagger.json') 204 | 205 | parser.add_argument('--iterations', type=int, default=1, 206 | help='The number of iterations one API call is fuzzed.') 207 | 208 | parser.add_argument('--log_all', action='store_true', 209 | help='If set, all responses are logged. The expected responses and the ' 210 | 'unexpected ones. By default only unexpected responses are logged.') 211 | 212 | parser.add_argument('--headers', type=json.loads, 213 | help='Send custom http headers for Cookies or api keys e.g. { \"X-API-Key\": \"abcdef12345\", ' 214 | '\"user-agent\": \"tntfuzzer\" }') 215 | 216 | parser.add_argument('--string-patterns', dest='string-patterns', action='store_true', 217 | help='Use pattern generation, when string types are replicated for requests. The pattern ' 218 | 'has a fixed reproducable form. With the search tool the position of a pattern subset' 219 | 'recalculated. Useful for finding positions of bufferoverflows.') 220 | 221 | parser.add_argument('--max-random-string-len', dest='max-random-string-len', type=int, default=200, 222 | help='The maximum length of generated strings.') 223 | 224 | parser.add_argument('--ignore-cert-errors', dest='ignore-tls', action='store_true', default=False, 225 | help='Ignore TLS errors, like self-signed certificates.') 226 | 227 | parser.add_argument('--host', type=str, 228 | help='Overrides the API host specified within the swagger file.') 229 | 230 | parser.add_argument('--basepath', type=str, 231 | help='Overrides the API basePath specified within the swagger file.') 232 | 233 | parser.add_argument('--ignored-paths', type=json.loads, dest='ignored-paths', default=[], 234 | help='List of the API paths to exclude from fuzzing.') 235 | 236 | args = vars(parser.parse_args()) 237 | 238 | if args['url'] is None: 239 | parser.print_usage() 240 | else: 241 | tnt = TntFuzzer(url=args['url'], iterations=args['iterations'], headers=args['headers'], 242 | log_unexpected_errors_only=not args['log_all'], use_string_pattern=args['string-patterns'], 243 | max_string_length=args['max-random-string-len'], ignore_tls=args["ignore-tls"], 244 | host=args["host"], basepath=args["basepath"], ignored_paths=args['ignored-paths']) 245 | try: 246 | tnt.start() 247 | except SchemaException: 248 | print('Error: Severe schema validation error. Please look in logs for more detailed error message.') 249 | 250 | 251 | if __name__ == "__main__": 252 | main() 253 | -------------------------------------------------------------------------------- /tntfuzzer/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Teebytes/TnT-Fuzzer/a1ed6841c58bbaf31e83e6abb41634abdc176bef/tntfuzzer/utils/__init__.py -------------------------------------------------------------------------------- /tntfuzzer/utils/strutils.py: -------------------------------------------------------------------------------- 1 | import termcolor 2 | import sys 3 | 4 | 5 | class StrUtils: 6 | 7 | def __init__(self): 8 | pass 9 | 10 | @staticmethod 11 | def print_log_row(op_code, url, status_code, documented_reason, body, curlcommand): 12 | if type(curlcommand) == str: 13 | curlstr = curlcommand 14 | else: 15 | curlstr = curlcommand.get() 16 | print(termcolor.colored(StrUtils.fill_string_up_with_blanks(op_code, 7), color='red') + ' | ' + 17 | termcolor.colored(StrUtils.fill_string_up_with_blanks(url, 100), color='green') + ' |-| ' + 18 | StrUtils.fill_string_up_with_blanks(status_code, 3) + ' | ' + 19 | StrUtils.fill_string_up_with_blanks(documented_reason, 20) + ' | ' + 20 | body + ' | ' + curlstr) 21 | 22 | @staticmethod 23 | def fill_string_up_with_blanks(fillup_string, num_chars): 24 | if fillup_string is None: 25 | return 'None ' 26 | if len(fillup_string) > num_chars: 27 | return fillup_string 28 | else: 29 | num_blanks = num_chars - len(fillup_string) 30 | for x in range(0, num_blanks): 31 | fillup_string = fillup_string + ' ' 32 | 33 | return fillup_string 34 | --------------------------------------------------------------------------------