diff --git a/.gitignore b/.gitignore index c1e2195..5a813ba 100644 --- a/.gitignore +++ b/.gitignore @@ -200,3 +200,6 @@ specs.json swagger.yaml swagger.json *.json + +## unknown data +.DS_Store diff --git a/README.md b/README.md index 06c8543..660e455 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OWASP OFFAT (OFFensive Api Tester) is created to automatically test API for common vulnerabilities after generating tests from openapi specification file. It provides feature to automatically fuzz inputs and use user provided inputs during tests specified via YAML config file. -![UnDocumented petstore API endpoint HTTP method results](./assets/images/tests/offat-v0.5.0.png) +![UnDocumented petstore API endpoint HTTP method results](https://owasp.org/OFFAT/assets/images/tests/offat-v0.5.0.png) ## Demo diff --git a/assets/images/logos/offat-2.png b/assets/images/logos/offat-2.png new file mode 100644 index 0000000..515cdad Binary files /dev/null and b/assets/images/logos/offat-2.png differ diff --git a/assets/images/logos/offat-3.png b/assets/images/logos/offat-3.png new file mode 100644 index 0000000..293ac06 Binary files /dev/null and b/assets/images/logos/offat-3.png differ diff --git a/src/MANIFEST.in b/src/MANIFEST.in index 641eeb8..b0b9074 100644 --- a/src/MANIFEST.in +++ b/src/MANIFEST.in @@ -2,3 +2,5 @@ include README.md include LICENSE include SECURITY.md include DISCLAIMER.md + +include offat/report/templates/report.html \ No newline at end of file diff --git a/src/README.md b/src/README.md index 27b7140..b688026 100644 --- a/src/README.md +++ b/src/README.md @@ -145,6 +145,16 @@ The disclaimer advises users to use the open-source project for ethical and legi offat -h ``` +- Save result in `json`, `yaml` or `html` formats. + + ```bash + offat -f swagger_file.json -o output.html -of html + ``` + +> `json` format is default output format. +> `yaml` format needs to be sanitized before usage since it dumps data as python objects. +> `html` format needs more visualization. + - Run tests only for endpoint paths matching regex pattern ```bash diff --git a/src/offat/__main__.py b/src/offat/__main__.py index 5af4181..16f73b6 100644 --- a/src/offat/__main__.py +++ b/src/offat/__main__.py @@ -37,7 +37,8 @@ def start(): parser.add_argument('-rl', '--rate-limit', dest='rate_limit', help='API requests rate limit. -dr should be passed in order to use this option', type=int, default=None, required=False) parser.add_argument('-dr', '--delay-rate', dest='delay_rate', help='API requests delay rate in seconds. -rl should be passed in order to use this option', type=float, default=None, required=False) parser.add_argument('-pr','--path-regex', dest='path_regex_pattern', type=str, help='run tests for paths matching given regex pattern', required=False, default=None) - parser.add_argument('-o', '--output', dest='output_file', type=str, help='path to store test results in json format', required=False, default=None) + parser.add_argument('-o', '--output', dest='output_file', type=str, help='path to store test results in specified format. Default format is html', required=False, default=None) + parser.add_argument('-of','--format', dest='output_format', type=str, choices=['json', 'yaml','html'], help='Data format to save (json, yaml, html). Default: json', required=False, default='json') parser.add_argument('-H', '--headers', dest='headers', type=str, help='HTTP requests headers that should be sent during testing eg: User-Agent: offat', required=False, default=None, action='append', nargs='*') parser.add_argument('-tdc','--test-data-config', dest='test_data_config',help='YAML file containing user test data for tests', required=False, type=str) parser.add_argument('-p', '--proxy', dest='proxy', help='Proxy server URL to route HTTP requests through (e.g., "http://proxyserver:port")', required=False, type=str) @@ -70,6 +71,7 @@ def start(): api_parser=api_parser, regex_pattern=args.path_regex_pattern, output_file=args.output_file, + output_file_format=args.output_format, req_headers=headers_dict, rate_limit=rate_limit, delay=delay_rate, diff --git a/src/offat/report/__init__.py b/src/offat/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/offat/report/generator.py b/src/offat/report/generator.py new file mode 100644 index 0000000..842bcdd --- /dev/null +++ b/src/offat/report/generator.py @@ -0,0 +1,67 @@ +from offat.report import templates +from os.path import dirname, join as path_join +from os import makedirs +from yaml import dump as yaml_dump +from json import dumps as json_dumps + +from ..logger import create_logger + + +logger = create_logger(__name__) + + +class ReportGenerator: + @staticmethod + def generate_html_report(results:list[dict]): + html_report_template_file_name = 'report.html' + html_report_file_path = path_join(dirname(templates.__file__),html_report_template_file_name) + + with open(html_report_file_path, 'r') as f: + report_file_content = f.read() + + # TODO: validate report path to avoid injection attacks. + if not isinstance(results, list): + raise ValueError('results arg expects a list[dict].') + + report_file_content = report_file_content.replace('{ results }', json_dumps(results)) + + return report_file_content + + @staticmethod + def handle_report_format(results:list[dict], report_format:str) -> str: + result = None + + match report_format: + case 'html': + logger.warning('HTML output format displays only basic data.') + result = ReportGenerator.generate_html_report(results=results) + case 'yaml': + logger.warning('YAML output format needs to be sanitized before using it further.') + result = yaml_dump({ + 'results':results, + }) + case _: # default json format + report_format = 'json' + result = json_dumps({ + 'results':results, + }) + + logger.info(f'Generated {report_format.upper()} format report.') + return result + + + @staticmethod + def save_report(report_path:str, report_file_content:str): + if report_path != '/': + dir_name = dirname(report_path) + makedirs(dir_name, exist_ok=True) + + with open(report_path, 'w') as f: + logger.info(f'Writing report to file: {report_path}') + f.write(report_file_content) + + + @staticmethod + def generate_report(results:list[dict], report_format:str, report_path:str): + formatted_results = ReportGenerator.handle_report_format(results=results, report_format=report_format) + ReportGenerator.save_report(report_path=report_path, report_file_content=formatted_results) diff --git a/src/offat/report/templates/__init__.py b/src/offat/report/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/offat/report/templates/report.html b/src/offat/report/templates/report.html new file mode 100644 index 0000000..16f2333 --- /dev/null +++ b/src/offat/report/templates/report.html @@ -0,0 +1,256 @@ + + + + + + + OWASP OFFAT + + + + + + +
+
+
+ +
+
+
+
Request
+
+
+ {{request}} +
+
+
+
+
Response
+
+
+ {{response}} +
+
+
+
+ + +
+
+
+
Test Name:
+
+
+
+
Test Result:
+
+
+
+
+
+
Result Details:
+
+
+
+
Test Response Filter:
+
+
+
+
+
+
Data Leak:
+
No Data Leak Found
+
+
+
+ +
+ + +
+
+ Endpoints Requests +
+ +
+ +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/src/offat/tester/test_generator.py b/src/offat/tester/test_generator.py index ef6018d..08dbaac 100644 --- a/src/offat/tester/test_generator.py +++ b/src/offat/tester/test_generator.py @@ -250,6 +250,7 @@ def sqli_fuzz_params_test( query_request_params = request_obj.get('query_params',[]) malicious_query_request_params = self.__inject_payload_in_params(query_request_params, sqli_payload) + # BUG: for few SQLi test, path params injected value is not matching with final URI path params in output request_obj['test_name'] = 'SQLi Test' request_obj['body_params'] = malicious_body_request_params diff --git a/src/offat/tester/tester_utils.py b/src/offat/tester/tester_utils.py index 67fdc31..d843fda 100644 --- a/src/offat/tester/tester_utils.py +++ b/src/offat/tester/tester_utils.py @@ -6,6 +6,7 @@ from .test_generator import TestGenerator from .test_runner import TestRunner from .test_results import TestResultTable +from ..report.generator import ReportGenerator from ..logger import create_logger from ..openapi import OpenAPIParser from ..utils import write_json_to_file @@ -54,7 +55,7 @@ def run_test(test_runner:TestRunner, tests:list[dict], regex_pattern:str=None, s # Note: redirects are allowed by default making it easier for pentesters/researchers -def generate_and_run_tests(api_parser:OpenAPIParser, regex_pattern:str=None, output_file:str=None, rate_limit:int=None,delay:float=None,req_headers:dict=None,proxy:str = None, ssl:bool = True, test_data_config:dict=None): +def generate_and_run_tests(api_parser:OpenAPIParser, regex_pattern:str=None, output_file:str=None, output_file_format:str=None, rate_limit:int=None,delay:float=None,req_headers:dict=None,proxy:str = None, ssl:bool = True, test_data_config:dict=None): global test_table_generator, logger test_runner = TestRunner( @@ -153,11 +154,10 @@ def generate_and_run_tests(api_parser:OpenAPIParser, regex_pattern:str=None, out # save file to output if output flag is present if output_file: - write_json_to_file( - json_data={ - 'results':results - }, - file_path=output_file + ReportGenerator.generate_report( + results=results, + report_format=output_file_format, + report_path=output_file, ) return results \ No newline at end of file diff --git a/src/poetry.lock b/src/poetry.lock index d3b471b..daa46b4 100644 --- a/src/poetry.lock +++ b/src/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "aiohttp" version = "3.8.5" description = "Async http client/server framework (asyncio)" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -113,7 +112,6 @@ speedups = ["Brotli", "aiodns", "cchardet"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -128,7 +126,6 @@ frozenlist = ">=1.1.0" name = "annotated-types" version = "0.5.0" description = "Reusable constraint types to use with typing.Annotated" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -140,7 +137,6 @@ files = [ name = "anyio" version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -161,7 +157,6 @@ trio = ["trio (<0.22)"] name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -173,7 +168,6 @@ files = [ name = "attrs" version = "23.1.0" description = "Classes Without Boilerplate" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -192,7 +186,6 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte name = "certifi" version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -204,7 +197,6 @@ files = [ name = "chardet" version = "5.2.0" description = "Universal encoding detector for Python 3" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -216,7 +208,6 @@ files = [ name = "charset-normalizer" version = "3.2.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -301,7 +292,6 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -316,7 +306,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -328,7 +317,6 @@ files = [ name = "fastapi" version = "0.103.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -349,7 +337,6 @@ all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)" name = "frozenlist" version = "1.4.0" description = "A list-like structure which implements collections.abc.MutableSequence" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -420,7 +407,6 @@ files = [ name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -432,7 +418,6 @@ files = [ name = "httptools" version = "0.6.0" description = "A collection of framework independent HTTP protocol utils." -category = "main" optional = true python-versions = ">=3.5.0" files = [ @@ -480,7 +465,6 @@ test = ["Cython (>=0.29.24,<0.30.0)"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -492,7 +476,6 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -504,7 +487,6 @@ files = [ name = "jsonschema" version = "4.17.3" description = "An implementation of JSON Schema validation for Python" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -524,7 +506,6 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- name = "jsonschema-spec" version = "0.1.6" description = "JSONSchema Spec with object-oriented paths" -category = "main" optional = false python-versions = ">=3.7.0,<4.0.0" files = [ @@ -542,7 +523,6 @@ requests = ">=2.31.0,<3.0.0" name = "lazy-object-proxy" version = "1.9.0" description = "A fast and thorough lazy object proxy." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -588,7 +568,6 @@ files = [ name = "multidict" version = "6.0.4" description = "multidict implementation" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -672,7 +651,6 @@ files = [ name = "openapi-schema-validator" version = "0.4.4" description = "OpenAPI schema validation for Python" -category = "main" optional = false python-versions = ">=3.7.0,<4.0.0" files = [ @@ -691,7 +669,6 @@ docs = ["sphinx (>=5.3.0,<6.0.0)", "sphinx-immaterial (>=0.11.0,<0.12.0)"] name = "openapi-spec-validator" version = "0.5.7" description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" -category = "main" optional = false python-versions = ">=3.7.0,<4.0.0" files = [ @@ -709,7 +686,6 @@ openapi-schema-validator = ">=0.4.2,<0.5.0" name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -721,7 +697,6 @@ files = [ name = "pathable" version = "0.4.3" description = "Object-oriented paths" -category = "main" optional = false python-versions = ">=3.7.0,<4.0.0" files = [ @@ -733,7 +708,6 @@ files = [ name = "pluggy" version = "1.3.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -749,7 +723,6 @@ testing = ["pytest", "pytest-benchmark"] name = "prance" version = "23.6.21.0" description = "Resolving Swagger/OpenAPI 2.0 and 3.0.0 Parser" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -776,7 +749,6 @@ ssv = ["swagger-spec-validator (>=2.4,<3.0)"] name = "pydantic" version = "2.3.0" description = "Data validation using Python type hints" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -796,7 +768,6 @@ email = ["email-validator (>=2.0.0)"] name = "pydantic-core" version = "2.6.3" description = "" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -915,7 +886,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" name = "pyrsistent" version = "0.19.3" description = "Persistent/Functional/Immutable data structures" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -952,7 +922,6 @@ files = [ name = "pytest" version = "7.4.2" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -973,7 +942,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "python-dotenv" version = "1.0.0" description = "Read key-value pairs from a .env file and set them as environment variables" -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -988,7 +956,6 @@ cli = ["click (>=5.0)"] name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1038,7 +1005,6 @@ files = [ name = "redis" version = "5.0.0" description = "Python client for Redis database and key-value store" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1057,7 +1023,6 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1079,7 +1044,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rfc3339-validator" version = "0.1.4" description = "A pure python RFC3339 validator" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1094,7 +1058,6 @@ six = "*" name = "rq" version = "1.15.1" description = "RQ is a simple, lightweight, library for creating background jobs, and processing them." -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -1110,7 +1073,6 @@ redis = ">=4.0.0" name = "ruamel-yaml" version = "0.17.32" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -category = "main" optional = false python-versions = ">=3" files = [ @@ -1129,7 +1091,6 @@ jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] name = "ruamel-yaml-clib" version = "0.2.7" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1176,7 +1137,6 @@ files = [ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1188,7 +1148,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1200,7 +1159,6 @@ files = [ name = "starlette" version = "0.27.0" description = "The little ASGI library that shines." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1218,7 +1176,6 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam name = "tabulate" version = "0.9.0" description = "Pretty-print tabular data" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1233,7 +1190,6 @@ widechars = ["wcwidth"] name = "typing-extensions" version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -1245,7 +1201,6 @@ files = [ name = "urllib3" version = "2.0.5" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1263,7 +1218,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "uvicorn" version = "0.23.2" description = "The lightning-fast ASGI server." -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -1278,7 +1232,7 @@ h11 = ">=0.8" httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} @@ -1289,7 +1243,6 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", name = "uvloop" version = "0.17.0" description = "Fast implementation of asyncio event loop on top of libuv" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1334,7 +1287,6 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my name = "watchfiles" version = "0.20.0" description = "Simple, modern and high performance file watching and code reload in python." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1369,7 +1321,6 @@ anyio = ">=3.0.0" name = "websockets" version = "11.0.3" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -1449,7 +1400,6 @@ files = [ name = "yarl" version = "1.9.2" description = "Yet another URL library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1534,9 +1484,9 @@ idna = ">=2.0" multidict = ">=4.0" [extras] -api = ["fastapi", "redis", "rq", "uvicorn"] +api = ["fastapi", "python-dotenv", "redis", "rq", "uvicorn"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9c6b46262bc748e00d537363a4656e9ddfac1935eae9b86ce080050ac5f709e7" +content-hash = "731a24540743243fd4387cfbe710834f243b4213be6408df32b52de1ef9ecaa0" diff --git a/src/pyproject.toml b/src/pyproject.toml index 536be97..6f0717e 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "offat" -version = "0.10.1" +version = "0.11.0" description = "Offensive API tester tool automates checks for common API vulnerabilities" authors = ["Dhrumil Mistry "] license = "MIT"