Adding integration test system

This commit is contained in:
RMI78 2023-05-05 08:20:33 +00:00
parent 2484cde921
commit 16f6b9f3ed
13 changed files with 1289 additions and 0 deletions

View File

@ -64,3 +64,19 @@ jobs:
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
integrate:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run Docker Compose
run: bash ${GITHUB_WORKSPACE}/tests/integration/run.sh
- name: Archive production artifact
if: always()
uses: actions/upload-artifact@v3
with:
name: diff-reports-file
path: tests/integration/.dump_diff_file.txt

4
.gitignore vendored
View File

@ -43,3 +43,7 @@ pip-delete-this-directory.txt
### Pycharm ###
.idea/
### integration test system ###
tests/integrations/.dump_diff_file.txt
tests/integrations/.test/

287
doc/integration_system.md Normal file
View File

@ -0,0 +1,287 @@
# Integration test documentation
----
Table of content
- [Working principle](#working-principle)
- [How to use it](#how-to-use-it)
- [Basic usage](#basic-usage)
- [Integration test creation guide](#integration-test-creation-guide)
- [Creating and understanding filters](#creating-and-understanding-filters)
- [Miscellaneous and notes](#miscellaneous-and-notes)
----
## Working principle
This integration test system relies on scenarios. A scenario is basically made up of :
- Wapiti attacking one or more target with some options (one or more modules, a custom endpoint etc.).
- Wapiti generating one report per target, agnostic of any non-constant data (date, IDs, etc.) and filtered of another redundant data.
- Reports are then compared to reference reports (also called assertions), if they look exactly the same, then the test passes, otherwise, a diff is dumped in the logs and in a text file that can be retrieved as a GitHub CI artifact.
A target is a website Wapiti can scan or crawl with some vulnerabilities or not.
All the scenarios are executed on Docker with one or more container per target, one for Wapiti and another one for the endpoint.
During an usual test run, the system will acknowledge all the scenarios you want to test (they can be set through the ``TESTS`` environment variable), and will then try to attack targets. Scenarios and targets order are not fixed.
As some targets are mutualized for some scenario, all the containers (even those who will not be used) will be built and executed during a run.
## How to use it
### Basic usage
The whole system is located at tests/integration.
```Bash
Entrypoint to run integration tests
Usage: ./run.sh [options]
Options:
--help Display this message and exit
--docker-clean Kill containers, remove and prune all docker images, volumes, and system, be carefull when using this option
--verbose-build Print the build messages before running the tests
```
After the run, a file named .dump_diff_file.txt containing a concatenation of all the reports differences of all the targets of all the scenarios will be created at tests/integration.
In case you want to disable a scenario, you can open the ``run.sh`` file and remove scenarios in the variable ``TESTS``. You can also export this variable in you shell but make sure to comment the one in ``run.sh`` to avoid overwriting. The formats of scenario in this variable should be single-space-separated.
```Bash
TESTS="test_mod_wp_enum test_mod_http_headers test_mod_csp"
```
There is no checking regarding what you put in this variable, use it carefully.
### Integration test creation guide
To create your own scenario:
1. Start by create a directory in tests/integration. By convention, follow the other directories when it comes to naming. Name start by `test_` followed by what you test, underscore-separated. Keep in mind that this will be your scenario name.
<br/>
2. Populate your directory just like this:
```
- test_dummy_name
- assertions
- check.sh
```
It is mandatory to have a `check.sh` inside an `assertions` directory so the system can check your assertions
<br/>
__check.sh__
The system either let you the choice to set a symbolic link the default check.sh located in ``tests\integration`` to the ``assertions`` directory.
```Bash
# Admitting you are at the root of the git project :
ln -s ../../check.sh test/integrations/test_dummy_name/assertions/check.sh
```
Or write your own if you need a specific way to check the reports. The only constraints are: be named ``check.sh`` and be a bash script
<br/>
3. Populate your directory with files required by your targets (aside the assertions directory) such as php files, Dockerfiles, executables etc.
<br/>
4. Modify the `docker-compose.setup.yml` to add your targets as containers .This file already contains severals shortcuts to help you setup a PHP server as well as some hashes of images. It is mandatory to:
- Use existing or setup your own healthchecks.
- Use images by their hashes, either the one provided or by adding them to the `.env` file
For shortcuts:
- `default_php_setup` setup a PHP web server and connect it to the test-network
- `default_mysql_setup` setup a Mysql database and connect it to the test-network
- `healthcheck_mysql` setup a healthcheck for a Mysql database
- `healthcheck_web` setup a healthcheck for a server hosting a website
Here are 2 targets examples:
```yml
dummy_target:
<<: [ *default_php_setup, *healthcheck_web ]
depends_on:
endpoint:
condition: service_healthy
volumes:
- ./test_dummy_name/target_1/php/src/:/var/www/html/
built_dummy_target:
build:
context: ./test_dummy_name/target_2/
dockerfile: "./test_dummy_name/target_2/Dockerfile"
args:
PHP_HASH_TAG: ${PHP_HASH}
volumes:
- ./test_dummy_name/target_2/php/src/:/var/www/html/
<<: *healthcheck_web
networks:
- test-network
```
To make sure Wapiti waits for the containers to be ready, add dependances :
```yml
depends_on:
dummy_target:
condition: service_healthy
built_dummy_target:
condition: service_healthy
```
<br/>
5. Modify the ``tests/integration/wapiti/module.json`` to define the behavior of Wapiti toward the target(s). You can supply:
- A filter per scenario to avoid bloating the reports and the assertions. If you don't a default one will be supplied (see [this section](#creating-and-understanding-filters) for more informations).
- Supplementary arguments per scenario or per target (supplementary arguments will sum up unless you specify you want target supplementary argument to override scenario supplementary argument)
- Modules
<br/>
As Docker relies on hostnames, you can indicate them as their names preprended by ``http(s)://`` so Wapiti can attack them.
Here is an example:
```JSON
"test_dummy_name": {
"modules": "dummy",
"supplementary_argument": "--auth-method digest",
"report_filter_tree": {},
"targets": [
{
"name": "http://dummy_target/endpoint1/index.php"
},
{
"name": "http://dummy_target/endpoint2/index.php",
"supplementary_argument": "--endpoint http://endpoint/"
},
{
"name": "http://built_dummy_target",
"supplementary_argument": "--auth-method basic",
"erase_global_supplementary": true
}
]
},
```
<br/>
As shown, you can also define multiples targets on a single container, which allow you to host mutliple websites on a single server. Wapiti will be launched on each target and thus, will produce as many reports as there is target for a given scenario.
<br/>
``supplementary_argument`` and ``report_filter_tree`` can be omitted. All the other keys are mandatory (``modules`` should be left as an empty string when testing without any module)
<br/>
__supplementary_argument__
As you can see above, the first target will inherit from the scenario supplementary argument, the second one will have both argument and the third one runs with it own supplementary argument
Some arguments are already supplied by default and can't be changed. Wapiti will always be run with ``--detailed-report --flush-session --verbose 2 -f json``. The outpout path of the reports will also be supplied, supplying it here may break you scenario.
<br/>
__report_filter_tree__
The report filter tree value should be a json following strictly the same structure of a Wapiti report in json, you can find what it looks like in ``tests/integration/wapiti/templates_and_data.py``. The goal of applying a filter is not only to prevent having large reports made of useless data, but also remove data that may vary arbitrarily from one report to another.
<br/>
6. Generate (or regenerate your own assertions)
Run the tests once
```Bash
./run.sh
```
All the reports from the different targets will be generated in the ``tests/integration/.test`` directory. From here you can generate or regenerate your assertions by using the script ``regenerate_assertions.sh``, if left empty, it will erase all the assertions by the produced reports. To replace specific assertions, specify them by their names
```Bash
./regenerate_assertions.sh test_dummy_name
```
(This script doesn't have any checking system, supplying unknown or mistyped arguments may lead to unexpected behavior, use it carefully)
Or you can copy it yourself:
```Bash
cp tests/integrations/.test/test_dummy_name/dummy_target_endpoint1_index.php.out tests/integrations/test_dummy_name/assertions/dummy_target_endpoint1_index.php.json
cp tests/integrations/.test/test_dummy_name/dummy_target_endpoint2_index.php.out tests/integrations/test_dummy_name/assertions/dummy_target_endpoint2_index.php.json
cp tests/integrations/.test/test_dummy_name/built_dummy_target.out tests/integrations/test_dummy_name/assertions/built_dummy_target.json
```
<br/>
You can finally, re-run the tests and observe if the assertions are respected or not.
### Creating and understanding filters
The default filter can be found in ``tests\integration\wapiti\templates_and_data.py``. It will remove every WSTG code explanations shipped by default on each report:
```JSON
{
"vulnerabilities": {},
"anomalies": {},
"additionals": {},
"infos": {}
}
```
<br/>
If you want to create your own filter, you can look at the general template in ``tests\integration\wapiti\templates_and_data.py``. Any key with a corresponding empty object in the filter will indicate to the system that everything produced in the report inside this key will be copied. Non-written keys will be ignored.
For arrays, you can indicate in the filter, a single element and the system will treat every elements of the arrays in the output report as the first occurence of the report.
<br/>
As an example, for this dummy raw output:
```JSON
{
"vulnerabilities": {
"A dummy vuln":{
"wanted_data": 34,
"wanted_array": [
{
"wanted_array_data": "blablabla",
"bloat_array_data": 455
},
{
"wanted_array_data": "blebleble",
"bloat_array_data": 456
},
{
"wanted_array_data": "bliblibli",
"bloat_array_data": 457
}
],
"bloat_data": "blablabla"
}
},
"info":{
"value1": 1,
"value2": 2,
"value3": 3,
"value4": 4,
"value5": 5
}
}
```
If we want to keep the wanted data, the infos and get rid of the bloat data, we should write the following filter:
```JSON
{
"vulnerabilities":{
"A dummy vuln":{
"wanted_data": 0,
"wanted_array":[
{
"wanted_array_data": ""
}
]
}
},
"info":{}
}
```
The produced output will be:
```JSON
{
"vulnerabilities": {
"A dummy vuln":{
"wanted_data": 34,
"wanted_array": [
{
"wanted_array_data": "blablabla"
},
{
"wanted_array_data": "blebleble"
},
{
"wanted_array_data": "bliblibli"
}
]
}
},
"info":{
"value1": 1,
"value2": 2,
"value3": 3,
"value4": 4,
"value5": 5
}
}
```
### Miscellaneous and notes
- As modules are added to Wapiti, the constant ``EXISTING_MODULES`` in ``tests/integrations/wapiti/templates_and_data.py`` should be updated in consequences, not having a new module in this variable will make the system crash. This is a security to prevent you from launching tests with modules that doesn't exist or with a typo

44
tests/integration/.env Normal file
View File

@ -0,0 +1,44 @@
# The following hash corresponds to the image php:8.1.18-apache
# php@sha256:5d5f0dbea68afab7e6fa7649fb818e078680e338a3265ec5cf237a6a791dd471
# it can be found here: https://hub.docker.com/layers/library/php/8.1.18-apache/images/sha256-5d5f0dbea68afab7e6fa7649fb818e078680e338a3265ec5cf237a6a791dd471?context=explore
PHP_HASH='@sha256:5d5f0dbea68afab7e6fa7649fb818e078680e338a3265ec5cf237a6a791dd471'
# The following hash corresponds to the image mysql:8
# mysql@sha256:13e429971e970ebcb7bc611de52d71a3c444247dc67cf7475a02718f6a5ef559
# it can be found here: https://hub.docker.com/layers/library/mysql/8/images/sha256-13e429971e970ebcb7bc611de52d71a3c444247dc67cf7475a02718f6a5ef559?context=explore
MYSQL_HASH='@sha256:13e429971e970ebcb7bc611de52d71a3c444247dc67cf7475a02718f6a5ef559'
# The following hashes corresponds respectively to the images, drupal:9-apache, drupal:10-apache
# drupal@sha256:18692a0792c882957024f4086cadbc966778c5593850dfa89edb7780ba8b794d
# drupal@sha256:d85280f104d6c8e1eff7e2613b5ee584d0a4105d54f4ffe352f945e38d095514
# They can respectively be found at:
# https://hub.docker.com/layers/library/drupal/9-apache/images/sha256-18692a0792c882957024f4086cadbc966778c5593850dfa89edb7780ba8b794d?context=explore
# https://hub.docker.com/layers/library/drupal/10-apache/images/sha256-d85280f104d6c8e1eff7e2613b5ee584d0a4105d54f4ffe352f945e38d095514?context=explore
DRUPAL9_HASH='@sha256:18692a0792c882957024f4086cadbc966778c5593850dfa89edb7780ba8b794d'
DRUPAL10_HASH='@sha256:d85280f104d6c8e1eff7e2613b5ee584d0a4105d54f4ffe352f945e38d095514'
# Some variables allowing the drupal instances and their DB to run
DRUPAL_MYSQL_DB='drupal'
DRUPAL_MYSQL_USER='drupal'
DRUPAL_MYSQL_PASSWORD='drupalpasswd'
DRUPAL_MYSQL_ROOT_PASSWORD='rootpasswd'
# This wordpress image refers to the 6.2.0 tag, it can be found here:
# https://hub.docker.com/layers/library/wordpress/6.2.0/images/sha256-c3d6df13e49ed4039fbbe5bd1ec172b166a7a4df603716fd06f5bd66b7e60f90?context=explore
WP_HASH='@sha256:c3d6df13e49ed4039fbbe5bd1ec172b166a7a4df603716fd06f5bd66b7e60f90'
# Credentials for the wordpress database
WP_MYSQL_ROOT_PASSWORD="somewordpress"
WP_MYSQL_DATABASE="wordpress"
WP_MYSQL_USER="wordpress"
WP_MYSQL_PASSWORD="wordpress"
# VARIABLES USED IN HEALTHCHECK SYSTEM
# Various commands to check if a container is healthy or not
DEFAULT_MYSQL_HEALTHCHECK_COMMAND='mysqladmin ping --silent'
DEFAULT_WEB_HEALTHCHECK_COMMAND='curl --silent --fail http://localhost/'
HTTPS_WEB_HEALTHCHECK_COMMAND='curl --silent --fail --insecure https://localhost/'
# Some other default variables related to the healthcheck conditions
DEFAULT_HEALTHCHECKS_RETRIES=30
DEFAULT_HEALTHCHECKS_TIMEOUT=3s
DEFAULT_HEALTHCHECKS_INTERVAL=5s
DEFAULT_HEALTHCHECKS_START_PERIOD=5s

View File

@ -0,0 +1,77 @@
#!/bin/bash
# Symlink this file to each module so it can
# check the assertions.
#define some colors
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # no colors
# exit upon any error
set -o errexit
# exit upon using undeclared variables
set -o nounset
die(){
echo >&2 "$@"
exit 1
}
# Ensure we are on the good execution directory
cd "$(dirname "$0")"
MODULE_NAME=$(basename "$(realpath ..)")
# get all the assertions (paths to text files that we want to
# find in the report).
# 1 assertion per target, one or more targets per module,
# assertions can be found in the same directory than this file
declare -a assertions
mapfile -t assertions < <(find . -name "*.json")
if [[ ${#assertions[@]} -eq 0 ]]; then
die "Error: No assertions found in module $MODULE_NAME"
fi
# Since the assertions and the target reports must have the same name
# (except their extention names, .json and .out), we extract the targets names
# from the assertion array. We also use their names to log some informations
# so keeping an array is somewhat useful
declare -a targets_name
for i in "${assertions[@]}"; do
targets_name+=("$(basename "$i" .json)")
done
# We store the target reports paths in an array with respect to the target_name
# array order. That way, our third array is ordered with the 2 other ones and
# we can safely use indexes to compare
declare -a outputs
for target in "${targets_name[@]}"; do
outputs+=("$(realpath "../../.test/$MODULE_NAME/$target.out")")
done
if [[ ${#outputs[@]} -eq 0 ]]; then
die "Error: No targets found in module $MODULE_NAME"
fi
# A special case is if we don't get the same number of reports as they are targets.
# Wapiti may not detect some targets or it can be due to various misconfigurations
# so we check the size of each array to be sure they match
if [[ ${#assertions[@]} -ne ${#targets_name[@]} || ${#targets_name[@]} -ne ${#outputs[@]} ]] ; then
die "Error: different number of reports/assertion files, found ${#outputs[@]} outputs for ${#assertions[@]} assertions"
fi
EXIT_CODE=0
# Comparing outputs and assertions :
for i in "${!outputs[@]}"; do
if [[ "$(cat "${outputs[$i]}")" != "$(cat "${assertions[$i]}")" ]]; then
echo -e "Assertion $(basename "${assertions[$i]}" .json) of module $MODULE_NAME is ${RED}not respected:${NC}"
echo "< : assertion"
echo "> : output"
diff <(jq --sort-keys . "${outputs[$i]}") <(jq --sort-keys . "${assertions[$i]}") || echo "---End of diff of assertion $(basename "${assertions[$i]}" .json) module ${MODULE_NAME}---"
EXIT_CODE=1
else
echo -e "Assertion $(basename "${assertions[$i]}" .json) of module $MODULE_NAME is ${GREEN}respected${NC}"
fi
done
exit $EXIT_CODE

View File

@ -0,0 +1,49 @@
version: '3.9'
# Following the DRY philosophy
x-default_php_setup:
&default_php_setup
image: php${PHP_HASH}
networks:
- test-network
x-healthcheck_web:
&healthcheck_web
healthcheck:
test: ${DEFAULT_WEB_HEALTHCHECK_COMMAND}
interval: ${DEFAULT_HEALTHCHECKS_INTERVAL}
timeout: ${DEFAULT_HEALTHCHECKS_TIMEOUT}
start_period: ${DEFAULT_HEALTHCHECKS_START_PERIOD}
retries: ${DEFAULT_HEALTHCHECKS_RETRIES}
x-default_mysql_setup:
&default_mysql_setup
image: mysql${MYSQL_HASH}
networks:
- test-network
x-healthcheck_mysql:
&healthcheck_mysql
healthcheck:
test: ${DEFAULT_MYSQL_HEALTHCHECK_COMMAND}
start_period: ${DEFAULT_HEALTHCHECKS_START_PERIOD}
interval: ${DEFAULT_HEALTHCHECKS_INTERVAL}
timeout: ${DEFAULT_HEALTHCHECKS_TIMEOUT}
retries: ${DEFAULT_HEALTHCHECKS_RETRIES}
services:
# Wapiti container
# requires all the targets containers to work perfectly
wapiti:
build:
context: "../../"
dockerfile: "./tests/integration/wapiti/Dockerfile.integration"
no_cache: true
container_name: wapiti
volumes:
- ./.test:/home/
networks:
- test-network
command: "${TESTS}"
networks:
test-network:

View File

@ -0,0 +1,29 @@
#!/bin/bash
# exit upon any error
set -o errexit
# exit upon using undeclared variables
set -o nounset
cd "$(dirname "$0")"
if [[ $# -eq 0 ]]; then
mapfile -t modules_dirs < <(find .test -maxdepth 1 -mindepth 1 -type d)
else
tests=$*
mapfile -t modules_dirs < <(echo -e "${tests// /\\n}" | sed 's/^/.test\//')
fi
for module in "${modules_dirs[@]}";do
if [[ -d "./$(basename "${module}")/assertions" ]]; then
cp "${module}/"* "./$(basename "${module}")/"
mapfile -t assertion_files < <(find "./$(basename "${module}")/" -name "*.out")
for assertion_file in "${assertion_files[@]}";do
mv -- "${assertion_file}" "$(dirname "${assertion_file}")/assertions/$(basename "${assertion_file}" .out).json"
done
echo "assertions of module $(basename "${module}") copied"
else
echo "directory ./$(basename "${module}")/assertions/ does not exist, skipping..."
fi
done

81
tests/integration/run.sh Executable file
View File

@ -0,0 +1,81 @@
#!/bin/bash
# List of modules to be tested
TESTS=""
# Normalize spaces for shell substitution
if [[ ! -z "$TESTS" ]]; then
export TESTS="$(echo "$TESTS" | xargs) "
fi
# exit upon any error
set -o errexit
# exit upon using undeclared variables
set -o nounset
# Placing ourselves in the right directory
cd "$(dirname "$0")"
# Parsing script arguments
declare -A args
for arg in "$@"; do
args[$arg]=1;
done;
if [[ -v args["--help"] ]]; then
# Printing some help
printf "%s\n" \
"Entrypoint to run integration tests" \
"Usage: ./run.sh [options]" \
"Options:" \
" --help Display this message and exit"\
" --docker-clean Kill containers, remove and prune all docker images, volumes, and system, be carefull when using this option"\
" --verbose-build Print the build messages before running the tests";
exit 0;
fi
if [[ -v args["--docker-clean"] ]]; then
# Cleaning docker
echo "Cleaning docker..."
docker kill $(docker ps -q) 2> /dev/null || echo "No containers to kill"
docker container prune -f 2> /dev/null || echo "No containers to prune"
docker volume prune -f 2> /dev/null || echo "No volumes to prune"
docker volume rm $(docker volume ls -q) 2> /dev/null || echo "No volume to remove"
docker rmi $(docker images -a -q) 2> /dev/null || echo "No images to remove"
(docker system prune -f && docker network create test-network) 2> /dev/null || echo "No need to prune the system"
fi
# Fallback to create the test-network in case it doesn't exist
docker network inspect test-network > /dev/null || docker network create test-network > /dev/null
echo "Building images..."
if [[ ! -v args["--verbose-build"] ]];then
# Quietly build all Dockerfiles
docker compose -f docker-compose.setup.yml build --quiet
fi
# Start the tests
docker compose --progress quiet -f docker-compose.setup.yml up --abort-on-container-exit
declare -a asserters=()
# If the TESTS env variable is supplied, we will only check the specified tests
if [[ ! -z "$TESTS" ]]; then
# Assuming all the tests in the TESTS variable are well written and exist
mapfile -t asserters < <(echo -e "${TESTS// /\/assertions\/check.sh\\n}" | head -n -1)
else
# Otherwise, we take all the tests
mapfile -t asserters < <(find . -mindepth 2 -type l,f -name check.sh)
fi
EXIT_CODE=0
for path in "${asserters[@]}"; do
cd "$(dirname "${path}")"
bash "check.sh" | tee -a ../../.dump_diff_file.txt
# Workaround to check if check.sh succeed, may not work on zsh
if [[ "${PIPESTATUS[0]}" -eq 1 ]]; then
EXIT_CODE=1
fi
cd - > /dev/null
done
exit $EXIT_CODE

View File

@ -0,0 +1,42 @@
FROM debian:bullseye-slim as build
ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_US.UTF-8
WORKDIR /usr/src/app
RUN apt-get -y update &&\
apt-get -y install --no-install-recommends\
python3 python3-pip python3-setuptools ca-certificates &&\
apt-get -y clean &&\
apt-get -y autoremove &&\
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\
truncate -s 0 /var/log/*log
COPY . .
RUN pip3 install . requests
FROM debian:bullseye-slim
ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_US.UTF-8 \
PYTHONDONTWRITEBYTECODE=1
RUN apt-get -y update &&\
apt-get -y install --no-install-recommends \
python3 python3-setuptools curl &&\
apt-get -y clean &&\
apt-get -y autoremove &&\
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\
truncate -s 0 /var/log/*log
COPY --from=build /usr/local/lib/python3.9/dist-packages/ /usr/local/lib/python3.9/dist-packages/
COPY --from=build /usr/local/bin/wapiti /usr/local/bin/wapiti-getcookie /usr/local/bin/
COPY ./tests/integration/wapiti/test.py /usr/local/bin/test.py
COPY ./tests/integration/wapiti/templates_and_data.py /usr/local/bin/templates_and_data.py
COPY ./tests/integration/wapiti/misc_functions.py /usr/local/bin/misc_functions.py
COPY ./tests/integration/wapiti/modules.json /usr/local/bin/modules.json
ENTRYPOINT [ "python3","-u","/usr/local/bin/test.py"]

View File

@ -0,0 +1,78 @@
from collections import defaultdict
from re import findall, MULTILINE
from json import dumps
from templates_and_data import TREE_CHECKER
def purge_irrelevant_data(data) -> None:
"""
Look recursively for any pattern matching a 2 lenght sized list with
"date", "last-modified", "keep-alive" or "etag" in a dictionnary containing lists,
dictionnaries, and other non-collections structures. Removing them because those
datas can change from one test to another and aren't really relevant
"""
if isinstance(data, dict):
for key in data.keys():
purge_irrelevant_data(data[key])
elif isinstance(data, list) and len(data) != 0:
indexes_to_remove = []
for i, item in enumerate(data):
if isinstance(item, list) and len(item) == 2 and item[0] in ("date", "last-modified", "etag", "keep-alive"):
indexes_to_remove.append(i)
elif isinstance(item, dict) or (isinstance(item, list) and len(item) > 2):
purge_irrelevant_data(item)
for i in indexes_to_remove[::-1]:
data.pop(i)
else:
return
def filter_data(data, filter):
"""
Filter recursively data from report using a filter, is sensitive to report changes and don't check if the filter is correct
make sure to write filter correctly or reinforce this function
"""
# Another check, type based, also considering if filter and data order match
assert (type(data) is type(filter)) or (type(data) is type(None)), \
f"Mismatch content, filter element is {type(filter)} and data element is {type(data)}"
if isinstance(data, dict):
filtered_tree = defaultdict()
for data_key, data_content in data.items():
if data_key in filter:
nested_content = \
filter[data_key] and (isinstance(filter[data_key], dict) or isinstance(filter[data_key], list))
filtered_tree[data_key] = \
filter_data(data_content, filter[data_key]) if nested_content else data_content
return dict(filtered_tree)
elif isinstance(data, list) and filter:
filtered_list = list()
for element in data:
filtered_list.append(filter_data(element, filter[0]))
return filtered_list
def all_keys_dicts(data: dict) -> set:
"""
Function to return a set of every keys in a nested dictionary
"""
return set(findall(r"^[ ]*\"(.+?)\"\s*:", dumps(data, indent=4), MULTILINE))
def sort_lists_in_dict(data):
"""
Function that recursively sort every lists in a dictionary to normalize them
Mainly used because requests in the detailed reports aren't always in the same order
"""
if data:
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, dict):
sort_lists_in_dict(data[key])
elif isinstance(value, list):
sort_lists_in_dict(data[key])
# sort the array here
data[key] = sorted(data[key], key=str)
elif isinstance(data, list):
for item in data:
sort_lists_in_dict(item)

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,463 @@
DEFAULT_FILTER_TREE = {
"vulnerabilities": {},
"anomalies": {},
"additionals": {},
"infos": {}
}
EXISTING_MODULES = {
"backup", "brute_login_form",
"buster", "cookieflags",
"crlf", "csp",
"csrf", "drupal_enum",
"exec", "file",
"htaccess", "htp",
"http_headers", "log4shell",
"methods", "nikto",
"permanentxss", "redirect",
"shellshock", "sql",
"ssl", "ssrf",
"takeover", "timesql",
"wapp", "wp_enum",
"xss", "xxe", ""
}
TREE_CHECKER = {
"vulnerabilities": {'Backup file': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Blind SQL Injection': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'CRLF Injection': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Command execution': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Content Security Policy Configuration': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Cross Site Request Forgery': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Fingerprint web application framework': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Fingerprint web server': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'HTTP Secure Headers': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Htaccess Bypass': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'HttpOnly Flag cookie': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Log4Shell': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Open Redirect': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Path Traversal': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Potentially dangerous file': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Reflected Cross Site Scripting': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'SQL Injection': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Secure Flag cookie': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Server Side Request Forgery': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Stored Cross Site Scripting': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Subdomain takeover': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'TLS/SSL misconfigurations': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'Weak credentials': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}],
'XML External Entity': [{'curl_command': '',
'detail': {
'response': {
'body': '',
'headers': [],
'status_code': 0
}
},
'http_request': '',
'info': '',
'level': 0,
'method': '',
'module': '',
'parameter': '',
'path': '',
'referer': '',
'wstg': []}]},
"anomalies": {
'Internal Server Error': [],
'Resource consumption': []},
"additionals": {
'Fingerprint web technology': [],
'HTTP Methods': [],
'Review Webserver Metafiles for Information Leakage': []},
"infos": {'auth': None,
'crawled_pages': [{
'request': {
'depth': 0,
'encoding': "",
'enctype': "",
'headers': [],
'methods': "",
'referer': "",
'url': ""},
'response': {
'body': "",
'headers': [],
'status_code': 0
}
}],
'crawled_pages_nbr': 0,
'detailed_report': False,
'scope': "",
'target': "",
'version': ""
}
}

View File

@ -0,0 +1,118 @@
import json
import os
import sys
import requests
import re
from itertools import cycle
from collections import defaultdict
from itertools import chain
from uuid import uuid4
from time import sleep
from misc_functions import purge_irrelevant_data, filter_data, all_keys_dicts, sort_lists_in_dict
from templates_and_data import DEFAULT_FILTER_TREE, EXISTING_MODULES, TREE_CHECKER
# parsing and checking the json file containing the modules
with open('/usr/local/bin/modules.json', 'r') as integration_file:
integration_data = json.load(integration_file)
wanted_modules = set(chain.from_iterable([test["modules"].split(",")
for _, test in integration_data.items()]))
assert wanted_modules.issubset(EXISTING_MODULES), f"{wanted_modules-EXISTING_MODULES} modules not existing"
# Adding on-the-fly uuid for each target for each test
# and using quotes for empty modules
for _, integ_test in integration_data.items():
if not len(integ_test["modules"]):
integ_test["modules"] = "\"\""
for target in integ_test['targets']:
target.update({"uid": str(uuid4())})
# Eventually filter arguments if any
if len(sys.argv) > 1:
# first checking unknown tests/typo errors
assert set(sys.argv[1:]).issubset(set(key for key, _ in integration_data.items()))
# then filtering the wanted integration tests
integration_data = {key: test for key, test in integration_data.items() if key in sys.argv[1:]}
# creating folders for the logs
for test_key, _ in integration_data.items():
if not os.path.exists(f"/home/{test_key}"):
os.mkdir(f"/home/{test_key}")
# All keys available in a general default report
# to check syntax of filters on the fly
KEYS_AVAILABLE = all_keys_dicts(TREE_CHECKER)
# data structures to count and cycle through targets
targets_done = set()
iter_tests = cycle(integration_data.items())
total_targets = sum([len(test["targets"]) for _, test in integration_data.items()])
# If any target recieve too many requests, it might not have
# started well, this is another way to fill the set to break
# the loop
requests_counter = defaultdict(int)
MAX_REQ = 25
# Running wapiti for each module for each target
# If a target isn't set up, passing to another and so on
# That way we don't have a strict order and spare testing time
for key_test, content_test in iter_tests:
if len(targets_done) == total_targets:
break
for target in content_test["targets"]:
if target['uid'] not in targets_done:
sys.stdout.write(f"Querying target {target['name']}...\n")
requests_counter[target['name']] += 1
try:
requests.get(f"{target['name']}", verify=False)
json_output_path = f"/home/{key_test}/{re.sub('/','_',re.sub(r'^https?://', '', target['name']))}.out"
# We define supplementary arguments globally and for each target:
more_args = target.get('supplementary_argument', '') + \
('' if target.get("erase_global_supplementary", False)
else content_test.get('supplementary_argument', ''))
# We then call wapiti on each target of each module, generating a detailed JSON report
os.system(f"wapiti -u {target['name']} -m {content_test['modules']} "
f"-f json -o {json_output_path} "
f"{more_args} "
f"--detailed-report --flush-session --verbose 2 ")
# Now we reparse the JSON to get only useful tests informations:
with open(json_output_path, "r") as bloated_output_file:
bloated_output_data = json.load(bloated_output_file)
with open(json_output_path, "w") as output_file:
# is a filter_tree supplied for this test ?
if "report_filter_tree" in content_test and len(content_test["report_filter_tree"]):
filter_tree = content_test["report_filter_tree"]
# We look for key that CANNOT exist at all,
# not even with a full report
filter_keys = all_keys_dicts(filter_tree)
assert filter_keys.issubset(KEYS_AVAILABLE), \
f"Keys not existing at all: {filter_keys - KEYS_AVAILABLE}"
else:
filter_tree = DEFAULT_FILTER_TREE
filtered_data = filter_data(bloated_output_data, filter_tree)
# Some dates and other non determinist data
# still exists somewhere in the detailed report
bloated_output_data.get("infos", {}).pop("date", None)
purge_irrelevant_data(filtered_data)
# Arrays in JSONs needs to be ordered to prevent false positive
sort_lists_in_dict(filtered_data)
# Rewriting the file
json.dump(filtered_data, output_file, indent=4)
targets_done.add(target['uid'])
except requests.exceptions.ConnectionError:
sys.stdout.write(f"Target {target['name']} from test {key_test} is not read...(yet ?)\n")
# 0.5 seconds penalty in case of no response to avoid requests spamming and being
# too fast at blacklisting targets
sleep(0.5)
if requests_counter[target['name']] > MAX_REQ:
sys.stdout.write(
f"Target {target['name']} from test {key_test} takes too long to respond\nSkipping...\n")
targets_done.add(target['uid'])