Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev Merge: RELEASE 0.11.0 #17

Merged
merged 5 commits into from
Nov 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,6 @@ specs.json
swagger.yaml
swagger.json
*.json

## unknown data
.DS_Store
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added assets/images/logos/offat-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/logos/offat-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ include README.md
include LICENSE
include SECURITY.md
include DISCLAIMER.md

include offat/report/templates/report.html
10 changes: 10 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/offat/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Empty file added src/offat/report/__init__.py
Empty file.
67 changes: 67 additions & 0 deletions src/offat/report/generator.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
256 changes: 256 additions & 0 deletions src/offat/report/templates/report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OWASP OFFAT</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
</head>

<body>
<nav class="navbar navbar-light bg-gradient">
<div class="container-fluid justify-content-center">
<a class="navbar-brand fw-bold" href="https://github.com/OWASP/OFFAT">
<img src="https://github.com/OWASP/OFFAT/blob/main/assets/images/logos/offat-3.png?raw=True"
alt="offat logo" width="40" height="40" class="d-inline-block align-text-center">
OWASP OFFAT
</a>
<button id="theme-toggle" class="btn" onclick="changeTheme()"><i id="theme-toggle-icon"
class="bi bi-brightness-high-fill"></i></button>
</div>
</nav>
<div class="container">
<div id="test-endpoint" class="text-center container fw-bold">
</div>
<!-- Requests response table -->
<div class="container text-left py-2">
<div class="row align-items-start">
<div class="col">
<div class="text-center fw-bold">Request</div>
<div class="card">
<div id="request-card" class="card-body"
style="height:430px; overflow-y: auto; font-family: 'Courier New', monospace;">
{{request}}
</div>
</div>
</div>
<div class="col">
<div class="text-center fw-bold">Response</div>
<div class="card">
<div id="response-card" class="card-body"
style="height:430px; overflow-y: auto; font-family: 'Courier New', monospace;">
{{response}}
</div>
</div>
</div>
</div>

<!-- Test Details -->
<div class="row">
<div class="col align-items-start my-2">
<div class="row">
<div class="col align-items-start text-left"><strong>Test Name:</strong></div>
<div id="test-name" class="col align-items-start text-left"></div>
</div>
<div class="row">
<div class="col align-items-start text-left"><strong>Test Result:</strong></div>
<div id="test-result" class="col align-items-start text-left"></div>
</div>
</div>
<div class=" col align-items-start my-2">
<div class="row">
<div class="col align-items-start text-left"><strong>Result Details:</strong></div>
<div id="test-result-details" class="col align-items-start text-left"></div>
</div>
<div class="row">
<div class="col align-items-start text-left"><strong>Test Response Filter:</strong></div>
<div id="test-response-filter" class="col align-items-start text-left"></div>
</div>
</div>
<div class=" col align-items-start my-2">
<div class="row">
<div class="col align-items-start text-left"><strong>Data Leak:</strong></div>
<div id="test-data-leak" class="col align-items-start text-left">No Data Leak Found</div>
</div>
</div>
</div>
<!-- End of Test Details -->
</div>

<!-- Endpoint Requests -->
<div class="row align-items-start">
<div class="col fs-4 fw-bold">
Endpoints Requests
</div>
<div id="test-requests-list-group" class="list-group list-group-numbered my-2"
style="height:500px; overflow-y: auto;"">
<a class=" list-group-item list-group-item-action active">
The current link item
</a>
<a class="list-group-item list-group-item-action">A second link item</a>
<a class="list-group-item list-group-item-action">A third link item</a>
<a class="list-group-item list-group-item-action">A fourth link item</a>
<a class="list-group-item list-group-item-action">A disabled link item</a>
</div>
</div>
<!-- End of Endpoint requests -->
</div>

<!-- External import scripts -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/browser/xml-formatter-singleton.min.js"></script>

<script>
function changeTheme() {
const htmlElement = document.documentElement;
const themeToggle = document.getElementById("theme-toggle");
const themeToggleIcon = document.getElementById("theme-toggle-icon");

if (htmlElement.getAttribute("data-bs-theme") === "dark") {
themeToggleIcon.setAttribute("class", "bi bi-brightness-high-fill");
htmlElement.setAttribute("data-bs-theme", "light");
} else {
themeToggleIcon.setAttribute("class", "bi bi-brightness-high");
htmlElement.setAttribute("data-bs-theme", "dark");
}
}

function prettyPrintResponseBody(responseBody) {
let formattedBody = '';

try {
// Try to parse as JSON
const parsedJson = JSON.parse(responseBody);
formattedBody = JSON.stringify(parsedJson, null, 2);
} catch (jsonError) {
try {
// Try to format as XML
formattedBody = xmlFormatter(responseBody);
} catch (xmlError) {
// If not JSON or XML, treat as plain text
formattedBody = responseBody;
}
}

return formattedBody;
}

function formatDataLeak(dataLeak) {
if (dataLeak === undefined) {
return "";
}

// Extract and format unique non-empty keys
const uniqueKeys = Array.from(new Set(Object.keys(dataLeak).filter(key => key !== "")));

// Join the keys into a comma-separated string
const result = uniqueKeys.join(', ');

return result;
}

// Function to build the query string from the query_params list
function buildQueryString(query_params) {
let queryString = "";

query_params.forEach((param, index) => {
// Check if the parameter is required and has a value
if (param.required && param.value) {
// Use encodeURIComponent to encode parameter values
const encodedValue = encodeURIComponent(param.value);
const separator = index === 0 ? '?' : '&';

// Add the parameter to the query string
queryString += `${separator}${param.name}=${encodedValue}`;
}
});

return queryString;
}

function updateHttpView(result) {

// Reconstruct the HTTP request string
const requestMethod = result.method;
const requestHeaders = Object.entries(result.request_headers)
.map(([header, value]) => `${header}: ${value}`)
.join('\n');
// Check if "kwargs" contains a "json" key and if it's an object
let jsonBody = "";
if (result.kwargs && typeof result.kwargs.json === 'object') {
jsonBody = JSON.stringify(result.kwargs.json, null, 2);
}
// build query string
const queryParamsString = buildQueryString(result.query_params);
const requestPath = result.endpoint + queryParamsString;

const httpRequest = `${requestMethod} ${requestPath} HTTP/1.1\n${requestHeaders}\n\n${jsonBody}`;


// Reconstruct the HTTP response string
const responseStatus = result.response_status_code;
const responseHeaders = Object.entries(result.response_headers)
.map(([header, value]) => `${header}: ${value}`)
.join('\n');
const responseBody = result.response_body;
const httpResponse = `HTTP/1.1 ${responseStatus}\n${responseHeaders}\n\n${prettyPrintResponseBody(responseBody)}`;

// Find the HTTP request and response div containers by their IDs
const requestContainer = document.getElementById('request-card');
const responseContainer = document.getElementById('response-card');

// update request and response cards texts
requestContainer.innerText = httpRequest;
responseContainer.innerText = httpResponse;

// update test data
const testNameContainer = document.getElementById('test-name');
const testEndpointContainer = document.getElementById('test-endpoint');
const testResultContainer = document.getElementById('test-result');
const testResultDetailsContainer = document.getElementById('test-result-details');
const testResponseFilterContainer = document.getElementById('test-response-filter');
const testDataLeakContainer = document.getElementById('test-data-leak');

const dataLeaked = formatDataLeak(result.data_leak);

testNameContainer.innerText = result.test_name;
testEndpointContainer.innerText = `${result.method} ${result.endpoint} (${result.url})`;
testResultContainer.innerText = result.result ? "✅ Passed" : "❌ Failed";
testResultDetailsContainer.innerText = result.result_details;
testResponseFilterContainer.innerText = result.response_filter;
testDataLeakContainer.innerText = dataLeaked === "" ? "No Data Leakage Found" : dataLeaked;

}

function createEndpointResultListComponent(result, num, isActive) {
return `<a class="list-group-item list-group-item-action ${isActive ? "active" : ""}" data-bs-toggle="list" onclick="updateHttpView(results[${num}])">${result.result ? "✅ Passed" : "❌ Failed"} ${result.url} (${result.response_status_code} ${result.method} ${result.endpoint})</a>`
}

function updateEndpointResultsView(results) {
const requestListGroupContainer = document.getElementById('test-requests-list-group');
let endpointResultComponents = [];

for (let i = 0; i < results.length; i++) {
endpointResultComponents += createEndpointResultListComponent(results[i], i, i === 0);
}

requestListGroupContainer.innerHTML = endpointResultComponents;

}

const results = { results };

updateEndpointResultsView(results);
updateHttpView(results[0]);
</script>
<!-- End of Scripts -->

</body>
<!-- End of Body -->

</html>
1 change: 1 addition & 0 deletions src/offat/tester/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading